From 15c5a44b7d0eade30d1c0d120c87ab005f1fbbe4 Mon Sep 17 00:00:00 2001 From: otsmr Date: Tue, 20 Jan 2026 00:44:32 +0100 Subject: [PATCH] starting with #366 --- CHANGELOG.md | 4 +- lib/src/services/intent/links.intent.dart | 10 +- .../camera_preview_controller_view.dart | 17 +- .../main_camera_controller.dart | 7 + .../camera_preview_components/send_to.dart | 25 +-- .../views/camera/share_image_editor.view.dart | 12 +- .../{data => }/image_item.dart | 0 .../{data/layer.dart => layer_data.dart} | 15 +- .../layers/background.layer.dart | 2 +- .../share_image_editor/layers/draw.layer.dart | 2 +- .../layers/emoji.layer.dart | 2 +- .../layers/filter.layer.dart | 2 +- .../layers/link_preview.layer.dart | 25 +++ .../layers/link_preview/parse_link.dart | 185 ++++++++++++++++++ .../layers/link_preview/parser/base.dart | 29 +++ .../link_preview/parser/html_parser.dart | 40 ++++ .../link_preview/parser/json_ld_parser.dart | 80 ++++++++ .../layers/link_preview/parser/og_parser.dart | 35 ++++ .../link_preview/parser/other_parser.dart | 39 ++++ .../link_preview/parser/twitter_parser.dart | 48 +++++ .../layers/link_preview/parser/util.dart | 38 ++++ .../link_preview/parser/youtube_parser.dart | 80 ++++++++ .../layers/link_preview/utils.dart | 62 ++++++ .../share_image_editor/layers/text.layer.dart | 2 +- .../share_image_editor/layers_viewer.dart | 10 +- .../message_context_menu.dart | 2 +- .../reaction_buttons.component.dart | 2 +- .../views/components/emoji_picker.bottom.dart | 2 +- lib/src/views/home.view.dart | 16 +- pubspec.lock | 2 +- pubspec.yaml | 1 + test/features/link_parser_test.dart | 50 +++++ 32 files changed, 806 insertions(+), 40 deletions(-) rename lib/src/views/camera/share_image_editor/{data => }/image_item.dart (100%) rename lib/src/views/camera/share_image_editor/{data/layer.dart => layer_data.dart} (88%) create mode 100644 lib/src/views/camera/share_image_editor/layers/link_preview.layer.dart create mode 100644 lib/src/views/camera/share_image_editor/layers/link_preview/parse_link.dart create mode 100644 lib/src/views/camera/share_image_editor/layers/link_preview/parser/base.dart create mode 100644 lib/src/views/camera/share_image_editor/layers/link_preview/parser/html_parser.dart create mode 100644 lib/src/views/camera/share_image_editor/layers/link_preview/parser/json_ld_parser.dart create mode 100644 lib/src/views/camera/share_image_editor/layers/link_preview/parser/og_parser.dart create mode 100644 lib/src/views/camera/share_image_editor/layers/link_preview/parser/other_parser.dart create mode 100644 lib/src/views/camera/share_image_editor/layers/link_preview/parser/twitter_parser.dart create mode 100644 lib/src/views/camera/share_image_editor/layers/link_preview/parser/util.dart create mode 100644 lib/src/views/camera/share_image_editor/layers/link_preview/parser/youtube_parser.dart create mode 100644 lib/src/views/camera/share_image_editor/layers/link_preview/utils.dart create mode 100644 test/features/link_parser_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index aa14e5a..e222556 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,9 +3,9 @@ ## 0.0.89 - Adds option to manual focus in the camera -- Adds support to switch between front and back camera during video recording +- Adds support to switch between front and back cameras during video recording - Adds basic face filters -- Improves image editor like emojies or text under a drawing can be moved +- Improves image editor, like emojis or text under a drawing can be moved - Fixes issue with emojis disappearing in the image editor ## 0.0.86 diff --git a/lib/src/services/intent/links.intent.dart b/lib/src/services/intent/links.intent.dart index 055eae8..305ae93 100644 --- a/lib/src/services/intent/links.intent.dart +++ b/lib/src/services/intent/links.intent.dart @@ -148,10 +148,8 @@ Future handleIntentMediaFile( ); } -Future handleIntentSharedFile( - BuildContext context, - List files, -) async { +Future handleIntentSharedFile(BuildContext context, + List files, void Function(Uri) onUrlCallBack) async { for (final file in files) { if (file.value == null) { Log.error( @@ -163,7 +161,9 @@ Future handleIntentSharedFile( switch (file.type) { case SharedMediaType.URL: - // await handleIntentUrl(context, Uri.parse(file.value!)); + if (file.value?.startsWith('http') ?? false) { + onUrlCallBack(Uri.parse(file.value!)); + } case SharedMediaType.IMAGE: var type = MediaType.image; if (file.value!.endsWith('.gif')) { diff --git a/lib/src/views/camera/camera_preview_components/camera_preview_controller_view.dart b/lib/src/views/camera/camera_preview_components/camera_preview_controller_view.dart index b5ae1b7..c7dbfa1 100644 --- a/lib/src/views/camera/camera_preview_components/camera_preview_controller_view.dart +++ b/lib/src/views/camera/camera_preview_components/camera_preview_controller_view.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io'; + import 'package:camera/camera.dart'; import 'package:clock/clock.dart'; import 'package:device_info_plus/device_info_plus.dart'; @@ -27,8 +28,8 @@ import 'package:twonly/src/views/camera/camera_preview_components/permissions_vi import 'package:twonly/src/views/camera/camera_preview_components/send_to.dart'; import 'package:twonly/src/views/camera/camera_preview_components/video_recording_time.dart'; import 'package:twonly/src/views/camera/camera_preview_components/zoom_selector.dart'; -import 'package:twonly/src/views/camera/share_image_editor/action_button.dart'; import 'package:twonly/src/views/camera/share_image_editor.view.dart'; +import 'package:twonly/src/views/camera/share_image_editor/action_button.dart'; import 'package:twonly/src/views/components/avatar_icon.component.dart'; import 'package:twonly/src/views/components/loader.dart'; import 'package:twonly/src/views/components/media_view_sizing.dart'; @@ -351,6 +352,7 @@ class _CameraPreviewViewState extends State { sendToGroup: widget.sendToGroup, mediaFileService: mediaFileService, mainCameraController: mc, + previewLink: mc.sharedLinkForPreview, ), transitionsBuilder: (context, animation, secondaryAnimation, child) { return child; @@ -631,7 +633,18 @@ class _CameraPreviewViewState extends State { if (!_sharePreviewIsShown && widget.sendToGroup != null && !_isVideoRecording) - SendToWidget(sendTo: widget.sendToGroup!.groupName), + ShowTitleText( + title: widget.sendToGroup!.groupName, + desc: context.lang.cameraPreviewSendTo, + ), + if (!_sharePreviewIsShown && + mc.sharedLinkForPreview != null && + !_isVideoRecording) + ShowTitleText( + title: mc.sharedLinkForPreview?.host ?? '', + desc: 'Link', + isLink: true, + ), if (!_sharePreviewIsShown && !_isVideoRecording && !widget.hideControllers) diff --git a/lib/src/views/camera/camera_preview_components/main_camera_controller.dart b/lib/src/views/camera/camera_preview_components/main_camera_controller.dart index fb433ce..50bc290 100644 --- a/lib/src/views/camera/camera_preview_components/main_camera_controller.dart +++ b/lib/src/views/camera/camera_preview_components/main_camera_controller.dart @@ -55,6 +55,13 @@ class MainCameraController { GlobalKey cameraPreviewKey = GlobalKey(); bool isSelectingFaceFilters = false; + Uri? sharedLinkForPreview; + + void setSharedLinkForPreview(Uri url) { + sharedLinkForPreview = url; + setState(); + } + final BarcodeScanner _barcodeScanner = BarcodeScanner(); final FaceDetector _faceDetector = FaceDetector( options: FaceDetectorOptions( diff --git a/lib/src/views/camera/camera_preview_components/send_to.dart b/lib/src/views/camera/camera_preview_components/send_to.dart index 76e7a7c..5b55bc0 100644 --- a/lib/src/views/camera/camera_preview_components/send_to.dart +++ b/lib/src/views/camera/camera_preview_components/send_to.dart @@ -1,22 +1,25 @@ import 'package:flutter/material.dart'; import 'package:twonly/src/database/daos/contacts.dao.dart'; -import 'package:twonly/src/utils/misc.dart'; -class SendToWidget extends StatelessWidget { - const SendToWidget({ - required this.sendTo, +class ShowTitleText extends StatelessWidget { + const ShowTitleText({ + required this.desc, + required this.title, + this.isLink = false, super.key, }); - final String sendTo; + final String title; + final String desc; + final bool isLink; @override Widget build(BuildContext context) { - const textStyle = TextStyle( + final textStyle = TextStyle( color: Colors.white, fontWeight: FontWeight.bold, - fontSize: 24, + fontSize: isLink ? 14 : 24, decoration: TextDecoration.none, - shadows: [ + shadows: const [ Shadow( color: Color.fromARGB(122, 0, 0, 0), blurRadius: 5, @@ -26,7 +29,7 @@ class SendToWidget extends StatelessWidget { final boldTextStyle = textStyle.copyWith( fontWeight: FontWeight.normal, - fontSize: 28, + fontSize: isLink ? 17 : 28, ); return Positioned( @@ -36,12 +39,12 @@ class SendToWidget extends StatelessWidget { child: Column( children: [ Text( - context.lang.cameraPreviewSendTo, + desc, textAlign: TextAlign.center, style: textStyle, ), Text( - substringBy(sendTo, 20), + substringBy(title, isLink ? 30 : 20), textAlign: TextAlign.center, style: boldTextStyle, // Use the bold text style here ), diff --git a/lib/src/views/camera/share_image_editor.view.dart b/lib/src/views/camera/share_image_editor.view.dart index cd4ecbf..87dcecb 100644 --- a/lib/src/views/camera/share_image_editor.view.dart +++ b/lib/src/views/camera/share_image_editor.view.dart @@ -22,8 +22,8 @@ import 'package:twonly/src/views/camera/camera_preview_components/save_to_galler import 'package:twonly/src/views/camera/share_image_contact_selection.view.dart'; import 'package:twonly/src/views/camera/share_image_contact_selection/select_show_time.dart'; import 'package:twonly/src/views/camera/share_image_editor/action_button.dart'; -import 'package:twonly/src/views/camera/share_image_editor/data/image_item.dart'; -import 'package:twonly/src/views/camera/share_image_editor/data/layer.dart'; +import 'package:twonly/src/views/camera/share_image_editor/image_item.dart'; +import 'package:twonly/src/views/camera/share_image_editor/layer_data.dart'; import 'package:twonly/src/views/camera/share_image_editor/layers_viewer.dart'; import 'package:twonly/src/views/components/emoji_picker.bottom.dart'; import 'package:twonly/src/views/components/media_view_sizing.dart'; @@ -38,6 +38,7 @@ class ShareImageEditorView extends StatefulWidget { const ShareImageEditorView({ required this.sharedFromGallery, required this.mediaFileService, + this.previewLink, super.key, this.imageBytesFuture, this.sendToGroup, @@ -48,6 +49,7 @@ class ShareImageEditorView extends StatefulWidget { final bool sharedFromGallery; final MediaFileService mediaFileService; final MainCameraController? mainCameraController; + final Uri? previewLink; @override State createState() => _ShareImageEditorView(); } @@ -78,6 +80,12 @@ class _ShareImageEditorView extends State { layers.add(FilterLayerData(key: GlobalKey())); } + if (widget.previewLink != null) { + layers.add( + LinkPreviewLayerData(key: GlobalKey(), link: widget.previewLink!), + ); + } + if (widget.sendToGroup != null) { selectedGroupIds.add(widget.sendToGroup!.groupId); } diff --git a/lib/src/views/camera/share_image_editor/data/image_item.dart b/lib/src/views/camera/share_image_editor/image_item.dart similarity index 100% rename from lib/src/views/camera/share_image_editor/data/image_item.dart rename to lib/src/views/camera/share_image_editor/image_item.dart diff --git a/lib/src/views/camera/share_image_editor/data/layer.dart b/lib/src/views/camera/share_image_editor/layer_data.dart similarity index 88% rename from lib/src/views/camera/share_image_editor/data/layer.dart rename to lib/src/views/camera/share_image_editor/layer_data.dart index 04b5e34..d28b967 100755 --- a/lib/src/views/camera/share_image_editor/data/layer.dart +++ b/lib/src/views/camera/share_image_editor/layer_data.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:hand_signature/signature.dart'; -import 'package:twonly/src/views/camera/share_image_editor/data/image_item.dart'; +import 'package:twonly/src/views/camera/share_image_editor/image_item.dart'; /// Layer class with some common properties class Layer { @@ -28,7 +28,6 @@ class Layer { bool showCustomButtons; } -/// Attributes used by [BackgroundLayer] class BackgroundLayerData extends Layer { BackgroundLayerData({ required super.key, @@ -38,6 +37,14 @@ class BackgroundLayerData extends Layer { bool imageLoaded = false; } +class LinkPreviewLayerData extends Layer { + LinkPreviewLayerData({ + required super.key, + required this.link, + }); + Uri link; +} + class FilterLayerData extends Layer { FilterLayerData({ required super.key, @@ -46,7 +53,6 @@ class FilterLayerData extends Layer { int page = 1; } -/// Attributes used by [EmojiLayer] class EmojiLayerData extends Layer { EmojiLayerData({ required super.key, @@ -62,7 +68,6 @@ class EmojiLayerData extends Layer { double size; } -/// Attributes used by [TextLayer] class TextLayerData extends Layer { TextLayerData({ required super.key, @@ -78,9 +83,7 @@ class TextLayerData extends Layer { int textLayersBefore; } -/// Attributes used by [DrawLayer] class DrawLayerData extends Layer { - // String text; DrawLayerData({ required super.key, super.offset, diff --git a/lib/src/views/camera/share_image_editor/layers/background.layer.dart b/lib/src/views/camera/share_image_editor/layers/background.layer.dart index e4158af..f12816c 100755 --- a/lib/src/views/camera/share_image_editor/layers/background.layer.dart +++ b/lib/src/views/camera/share_image_editor/layers/background.layer.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:twonly/src/views/camera/share_image_editor/data/layer.dart'; +import 'package:twonly/src/views/camera/share_image_editor/layer_data.dart'; class BackgroundLayer extends StatefulWidget { const BackgroundLayer({ diff --git a/lib/src/views/camera/share_image_editor/layers/draw.layer.dart b/lib/src/views/camera/share_image_editor/layers/draw.layer.dart index 64d528e..aabddfd 100644 --- a/lib/src/views/camera/share_image_editor/layers/draw.layer.dart +++ b/lib/src/views/camera/share_image_editor/layers/draw.layer.dart @@ -4,7 +4,7 @@ import 'package:hand_signature/signature.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/views/camera/share_image_editor/action_button.dart'; -import 'package:twonly/src/views/camera/share_image_editor/data/layer.dart'; +import 'package:twonly/src/views/camera/share_image_editor/layer_data.dart'; import 'package:twonly/src/views/camera/share_image_editor/layers/draw/custom_hand_signature.dart'; class DrawLayer extends StatefulWidget { diff --git a/lib/src/views/camera/share_image_editor/layers/emoji.layer.dart b/lib/src/views/camera/share_image_editor/layers/emoji.layer.dart index b82c1ee..ac6020f 100755 --- a/lib/src/views/camera/share_image_editor/layers/emoji.layer.dart +++ b/lib/src/views/camera/share_image_editor/layers/emoji.layer.dart @@ -6,7 +6,7 @@ import 'package:flutter/services.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/views/camera/share_image_editor/action_button.dart'; -import 'package:twonly/src/views/camera/share_image_editor/data/layer.dart'; +import 'package:twonly/src/views/camera/share_image_editor/layer_data.dart'; /// Emoji layer class EmojiLayer extends StatefulWidget { diff --git a/lib/src/views/camera/share_image_editor/layers/filter.layer.dart b/lib/src/views/camera/share_image_editor/layers/filter.layer.dart index 1648dff..8ee46fe 100644 --- a/lib/src/views/camera/share_image_editor/layers/filter.layer.dart +++ b/lib/src/views/camera/share_image_editor/layers/filter.layer.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; -import 'package:twonly/src/views/camera/share_image_editor/data/layer.dart'; +import 'package:twonly/src/views/camera/share_image_editor/layer_data.dart'; import 'package:twonly/src/views/camera/share_image_editor/layers/filters/datetime_filter.dart'; import 'package:twonly/src/views/camera/share_image_editor/layers/filters/image_filter.dart'; import 'package:twonly/src/views/camera/share_image_editor/layers/filters/location_filter.dart'; diff --git a/lib/src/views/camera/share_image_editor/layers/link_preview.layer.dart b/lib/src/views/camera/share_image_editor/layers/link_preview.layer.dart new file mode 100644 index 0000000..029a536 --- /dev/null +++ b/lib/src/views/camera/share_image_editor/layers/link_preview.layer.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:twonly/src/views/camera/share_image_editor/layer_data.dart'; + +class LinkPreviewLayer extends StatefulWidget { + const LinkPreviewLayer({ + required this.layerData, + super.key, + this.onUpdate, + }); + final LinkPreviewLayerData layerData; + final VoidCallback? onUpdate; + + @override + State createState() => _LinkPreviewLayerState(); +} + +class _LinkPreviewLayerState extends State { + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.zero, + child: Text(widget.layerData.link.toString()), + ); + } +} diff --git a/lib/src/views/camera/share_image_editor/layers/link_preview/parse_link.dart b/lib/src/views/camera/share_image_editor/layers/link_preview/parse_link.dart new file mode 100644 index 0000000..aa75259 --- /dev/null +++ b/lib/src/views/camera/share_image_editor/layers/link_preview/parse_link.dart @@ -0,0 +1,185 @@ +// Based on: https://github.com/sur950/any_link_preview +// Copyright (c) 2020-2024 Konakanchi Venkata Suresh Babu + +import 'dart:convert'; + +import 'package:html/dom.dart' show Document; +import 'package:html/parser.dart'; +import 'package:http/http.dart' as http; +import 'package:twonly/src/utils/log.dart'; +import 'package:twonly/src/views/camera/share_image_editor/layers/link_preview/parser/base.dart'; +import 'package:twonly/src/views/camera/share_image_editor/layers/link_preview/parser/html_parser.dart'; +import 'package:twonly/src/views/camera/share_image_editor/layers/link_preview/parser/json_ld_parser.dart'; +import 'package:twonly/src/views/camera/share_image_editor/layers/link_preview/parser/og_parser.dart'; +import 'package:twonly/src/views/camera/share_image_editor/layers/link_preview/parser/other_parser.dart'; +import 'package:twonly/src/views/camera/share_image_editor/layers/link_preview/parser/twitter_parser.dart'; +import 'package:twonly/src/views/camera/share_image_editor/layers/link_preview/parser/util.dart'; +import 'package:twonly/src/views/camera/share_image_editor/layers/link_preview/parser/youtube_parser.dart'; +import 'package:twonly/src/views/camera/share_image_editor/layers/link_preview/utils.dart'; + +Future getMetadata(String link) async { + const userAgent = 'WhatsApp/2.21.12.21 A'; + try { + final linkToFetch = link.trim(); + final info = await getInfo(linkToFetch, userAgent); + + final img = info?.image ?? ''; + if (img.isNotEmpty) { + info?.image = resolveImageUrl(link, img); + } + + return info; + } catch (error) { + return null; + } +} + +String resolveImageUrl(String baseUrl, String imageUrl) { + try { + final baseUri = Uri.parse(baseUrl); + return baseUri.resolve(imageUrl).toString(); + } catch (e) { + return imageUrl; + } +} + +Future getInfo( + String url, + String userAgent, +) async { + Metadata? info; + + info = Metadata() + ..title = getDomain(url) + ..desc = url + ..siteName = getDomain(url) + ..url = url; + + try { + final videoId = getYouTubeVideoId(url); + final response = videoId == null + ? await fetchWithRedirects( + Uri.parse(url), + userAgent: userAgent, + ) + : await getYoutubeData( + videoId, + userAgent, + ); + + final headerContentType = response.headers['content-type']; + + if (headerContentType != null && headerContentType.startsWith('image/')) { + info + ..title = '' + ..desc = '' + ..siteName = '' + ..image = url; + return info; + } + + final document = responseToDocument(response); + if (document == null) return info; + + final data_ = _parse(document, url: url); + + return data_; + } catch (error) { + Log.warn('Error in $url response ($error)'); + return info; + } +} + +Document? responseToDocument(http.Response response) { + if (response.statusCode != 200) return null; + + Document? document; + try { + document = parse(utf8.decode(response.bodyBytes)); + } catch (err) { + return document; + } + + return document; +} + +Metadata _parse(Document? document, {String? url}) { + final output = Metadata(); + + final parsers = [ + _openGraph(document), + _twitterCard(document), + _youtubeCard(document), + _jsonLdSchema(document), + _htmlMeta(document), + _otherParser(document), + ]; + + for (final p in parsers) { + if (p == null) continue; + + output.title ??= p.title; + output.desc ??= p.desc; + output.image ??= p.image; + output.siteName ??= p.siteName; + output.url ??= p.url ?? url; + + if (output.hasAllMetadata) break; + } + + final url_ = output.url ?? url; + final image = output.image; + if (url_ != null && image != null) { + output.image = Uri.parse(url_).resolve(image).toString(); + } + + return output; +} + +Metadata? _openGraph(Document? document) { + try { + return OpenGraphParser(document).parse(); + } catch (e) { + return null; + } +} + +Metadata? _htmlMeta(Document? document) { + try { + return HtmlMetaParser(document).parse(); + } catch (e) { + return null; + } +} + +Metadata? _jsonLdSchema(Document? document) { + try { + return JsonLdParser(document).parse(); + } catch (e) { + return null; + } +} + +Metadata? _youtubeCard(Document? document) { + try { + return YoutubeParser(document).parse(); + } catch (e) { + return null; + } +} + +Metadata? _twitterCard(Document? document) { + try { + return TwitterParser(document).parse(); + } catch (e) { + return null; + } +} + +Metadata? _otherParser(Document? document) { + try { + return OtherParser(document).parse(); + } catch (e) { + return null; + } +} diff --git a/lib/src/views/camera/share_image_editor/layers/link_preview/parser/base.dart b/lib/src/views/camera/share_image_editor/layers/link_preview/parser/base.dart new file mode 100644 index 0000000..37a4e0e --- /dev/null +++ b/lib/src/views/camera/share_image_editor/layers/link_preview/parser/base.dart @@ -0,0 +1,29 @@ +mixin BaseMetaInfo { + String? title; + String? desc; + String? image; + String? url; + String? siteName; + + /// Returns `true` if any parameter other than [url] is filled. + bool get hasData => + ((title?.isNotEmpty ?? false) && title != 'null') || + ((desc?.isNotEmpty ?? false) && desc != 'null') || + ((image?.isNotEmpty ?? false) && image != 'null'); + + Metadata parse() { + return Metadata() + ..title = title + ..desc = desc + ..image = image + ..url = url + ..siteName = siteName; + } +} + +/// Container class for Metadata. +class Metadata with BaseMetaInfo { + bool get hasAllMetadata { + return title != null && desc != null && image != null && url != null; + } +} diff --git a/lib/src/views/camera/share_image_editor/layers/link_preview/parser/html_parser.dart b/lib/src/views/camera/share_image_editor/layers/link_preview/parser/html_parser.dart new file mode 100644 index 0000000..78d53c4 --- /dev/null +++ b/lib/src/views/camera/share_image_editor/layers/link_preview/parser/html_parser.dart @@ -0,0 +1,40 @@ +import 'package:html/dom.dart'; + +import 'base.dart'; +import 'util.dart'; + +/// Parses [Metadata] from ``, ``, and `<img>` tags. +class HtmlMetaParser with BaseMetaInfo { + HtmlMetaParser(this._document); + + /// The [Document] to parse. + final Document? _document; + + /// Get the [Metadata.title] from the <title> tag. + @override + String? get title => _document?.head?.querySelector('title')?.text; + + /// Get the [Metadata.desc] from the content of the + /// <meta name="description"> tag. + @override + String? get desc => _document?.head + ?.querySelector("meta[name='description']") + ?.attributes + .get('content'); + + /// Get the [Metadata.image] from the first <img> tag in the body. + @override + String? get image => + _document?.body?.querySelector('img')?.attributes.get('src'); + + /// Get the [Metadata.siteName] from the content of the + /// <meta name="site_name"> meta tag. + @override + String? get siteName => _document?.head + ?.querySelector("meta[name='site_name']") + ?.attributes + .get('content'); + + @override + String toString() => parse().toString(); +} diff --git a/lib/src/views/camera/share_image_editor/layers/link_preview/parser/json_ld_parser.dart b/lib/src/views/camera/share_image_editor/layers/link_preview/parser/json_ld_parser.dart new file mode 100644 index 0000000..7a0c497 --- /dev/null +++ b/lib/src/views/camera/share_image_editor/layers/link_preview/parser/json_ld_parser.dart @@ -0,0 +1,80 @@ +import 'dart:convert'; + +import 'package:html/dom.dart'; +import 'package:twonly/src/views/camera/share_image_editor/layers/link_preview/parser/base.dart'; +import 'package:twonly/src/views/camera/share_image_editor/layers/link_preview/parser/og_parser.dart'; +import 'package:twonly/src/views/camera/share_image_editor/layers/link_preview/parser/util.dart'; + +/// Parses [Metadata] from `json-ld` data in `<script>` tags. +class JsonLdParser with BaseMetaInfo { + JsonLdParser(this.document) { + _jsonData = _parseToJson(document); + } + + /// The [Document] to parse. + Document? document; + dynamic _jsonData; + + dynamic _parseToJson(Document? document) { + final data = document?.head + ?.querySelector("script[type='application/ld+json']") + ?.innerHtml; + if (data == null) return null; + // For multiline json file + // Replacing all new line characters with empty space + // before performing json decode on data + return jsonDecode(data.replaceAll('\n', ' ')); + } + + /// Get the [Metadata.title] from the <title> tag. + @override + String? get title { + final data = _jsonData; + if (data is Map<String, dynamic>) { + return data['name'] as String? ?? data['headline'] as String?; + } + return null; + } + + /// Get the [Metadata.desc] from the content of the + /// <meta name="description"> tag. + @override + String? get desc { + final data = _jsonData; + if (data is List<Map<String, dynamic>>) { + return data.first['description'] as String? ?? + data.first['headline'] as String?; + } else if (data is Map<String, dynamic>) { + return data['description'] as String? ?? data['description'] as String?; + } + return null; + } + + /// Get the [Metadata.image] from the first <img> tag in the body. + @override + String? get image { + final data = _jsonData; + if (data is List && data.isNotEmpty) { + return _imgResultToStr(data.first['logo'] ?? data.first['image']); + } else if (data is Map) { + return _imgResultToStr( + data.getDynamic('logo') ?? data.getDynamic('image'), + ); + } + return null; + } + + /// JSON LD does not have a siteName property, so we get it from + /// [og:site_name] if available. + @override + String? get siteName => OpenGraphParser(document).siteName; + + String? _imgResultToStr(dynamic result) { + if (result is List && result.isNotEmpty) result = result.first; + if (result is String) return result; + return null; + } + + @override + String toString() => parse().toString(); +} diff --git a/lib/src/views/camera/share_image_editor/layers/link_preview/parser/og_parser.dart b/lib/src/views/camera/share_image_editor/layers/link_preview/parser/og_parser.dart new file mode 100644 index 0000000..a1a0efd --- /dev/null +++ b/lib/src/views/camera/share_image_editor/layers/link_preview/parser/og_parser.dart @@ -0,0 +1,35 @@ +import 'package:html/dom.dart'; + +import 'base.dart'; +import 'util.dart'; + +/// Parses [Metadata] from `<meta property='og:*'>` tags. +class OpenGraphParser with BaseMetaInfo { + OpenGraphParser(this._document); + + /// The [Document] to parse. + final Document? _document; + + /// Get [Metadata.title] from 'og:title'. + @override + String? get title => getProperty(_document, property: 'og:title'); + + /// Get [Metadata.desc] from 'og:description'. + @override + String? get desc => getProperty(_document, property: 'og:description'); + + /// Get [Metadata.image] from 'og:image'. + @override + String? get image => getProperty(_document, property: 'og:image'); + + /// Get [Metadata.siteName] from 'og:site_name'. + @override + String? get siteName => getProperty(_document, property: 'og:site_name'); + + /// Get [Metadata.url] from 'og:url'. + @override + String? get url => getProperty(_document, property: 'og:url'); + + @override + String toString() => parse().toString(); +} diff --git a/lib/src/views/camera/share_image_editor/layers/link_preview/parser/other_parser.dart b/lib/src/views/camera/share_image_editor/layers/link_preview/parser/other_parser.dart new file mode 100644 index 0000000..132ce19 --- /dev/null +++ b/lib/src/views/camera/share_image_editor/layers/link_preview/parser/other_parser.dart @@ -0,0 +1,39 @@ +import 'package:html/dom.dart'; + +import 'base.dart'; +import 'util.dart'; + +/// Parses [Metadata] from `<meta attribute: 'name' property='*'>` tags. +class OtherParser with BaseMetaInfo { + OtherParser(this._document); + + /// The [Document] to be parse + final Document? _document; + + /// Get [Metadata.title] from 'title'. + @override + String? get title => + getProperty(_document, attribute: 'name', property: 'title'); + + /// Get [Metadata.desc] from 'description'. + @override + String? get desc => + getProperty(_document, attribute: 'name', property: 'description'); + + /// Get [Metadata.image] from 'image'. + @override + String? get image => + getProperty(_document, attribute: 'name', property: 'image'); + + /// Get [Metadata.siteName] from 'description'. + @override + String? get siteName => + getProperty(_document, attribute: 'name', property: 'site_name'); + + /// Get [Metadata.url] from 'url'. + @override + String? get url => getProperty(_document, attribute: 'name', property: 'url'); + + @override + String toString() => parse().toString(); +} diff --git a/lib/src/views/camera/share_image_editor/layers/link_preview/parser/twitter_parser.dart b/lib/src/views/camera/share_image_editor/layers/link_preview/parser/twitter_parser.dart new file mode 100644 index 0000000..07147e6 --- /dev/null +++ b/lib/src/views/camera/share_image_editor/layers/link_preview/parser/twitter_parser.dart @@ -0,0 +1,48 @@ +import 'package:html/dom.dart'; + +import 'base.dart'; +import 'og_parser.dart'; +import 'util.dart'; + +/// Parses [Metadata] from `<meta property='twitter:*'>` tags. +class TwitterParser with BaseMetaInfo { + TwitterParser(this._document); + + /// The [Document] to parse. + final Document? _document; + + /// Get [Metadata.title] from 'twitter:title' + @override + String? get title => + getProperty(_document, attribute: 'name', property: 'twitter:title') ?? + getProperty(_document, property: 'twitter:title'); + + /// Get [Metadata.desc] from 'twitter:description' + @override + String? get desc => + getProperty( + _document, + attribute: 'name', + property: 'twitter:description', + ) ?? + getProperty(_document, property: 'twitter:description'); + + /// Get [Metadata.image] from 'twitter:image' + @override + String? get image => + getProperty(_document, attribute: 'name', property: 'twitter:image') ?? + getProperty(_document, property: 'twitter:image'); + + /// Twitter Cards do not have a siteName property, so we get it from + /// [og:site_name] if available. + @override + String? get siteName => OpenGraphParser(_document).siteName; + + /// Twitter Cards do not have a url property, so we get the url from + /// [og:url] if available. + @override + String? get url => OpenGraphParser(_document).url; + + @override + String toString() => parse().toString(); +} diff --git a/lib/src/views/camera/share_image_editor/layers/link_preview/parser/util.dart b/lib/src/views/camera/share_image_editor/layers/link_preview/parser/util.dart new file mode 100644 index 0000000..66f9d51 --- /dev/null +++ b/lib/src/views/camera/share_image_editor/layers/link_preview/parser/util.dart @@ -0,0 +1,38 @@ +import 'package:html/dom.dart'; + +// ignore: strict_raw_type +extension GetMethod on Map { + String? get(dynamic key) { + final value = this[key]; + if (value is List<String>) return value.first; + return value?.toString(); + } + + dynamic getDynamic(dynamic key) { + return this[key]; + } +} + +String? getDomain(String url) { + return Uri.parse(url).host.split('.')[0]; +} + +String? getProperty( + Document? document, { + String tag = 'meta', + String attribute = 'property', + String? property, + String key = 'content', +}) { + final value_ = document + ?.getElementsByTagName(tag) + .cast<Element?>() + .firstWhere( + (element) => element?.attributes[attribute] == property, + orElse: () => null, + ) + ?.attributes + .get(key); + + return value_; +} diff --git a/lib/src/views/camera/share_image_editor/layers/link_preview/parser/youtube_parser.dart b/lib/src/views/camera/share_image_editor/layers/link_preview/parser/youtube_parser.dart new file mode 100644 index 0000000..9fa887a --- /dev/null +++ b/lib/src/views/camera/share_image_editor/layers/link_preview/parser/youtube_parser.dart @@ -0,0 +1,80 @@ +import 'dart:convert'; +import 'package:html/dom.dart'; +import 'base.dart'; +import 'util.dart'; + +class YoutubeParser with BaseMetaInfo { + YoutubeParser(this.document) { + _jsonData = _parseToJson(document); + } + + Document? document; + dynamic _jsonData; + + dynamic _parseToJson(Document? document) { + final data = document?.outerHtml + .replaceAll('<html><head></head><body>', '') + .replaceAll('</body></html>', ''); + if (data == null) return null; + /* For multiline json file */ + // Replacing all new line characters with empty space + // before performing json decode on data + final d = jsonDecode(data.replaceAll('\n', ' ')); + return d; + } + + /// Get the [Metadata.title] from the [<title>] tag + @override + String? get title { + final data = _jsonData; + if (data is List<Map<String, dynamic>>) { + return data.first['title'] as String?; + } else if (data is Map) { + return data.get('title'); + } + return null; + } + + /// Get the [Metadata.image] from the first <img> tag in the body + @override + String? get image { + final data = _jsonData; + if (data is List && data.isNotEmpty) { + return _imgResultToStr(data.first['thumbnail_url']); + } else if (data is Map) { + return _imgResultToStr(data.getDynamic('thumbnail_url')); + } + return null; + } + + @override + String? get siteName { + final data = _jsonData; + if (data is List<Map<String, dynamic>>) { + return data.first['provider_name'] as String?; + } else if (data is Map) { + return data.get('provider_name'); + } + return null; + } + + @override + String? get url { + final data = _jsonData; + if (data is List<Map<String, dynamic>>) { + return data.first['provider_url'] as String?; + } else if (data is Map) { + return data.get('provider_url'); + } + return null; + } + + String? _imgResultToStr(dynamic result) { + if (result is List && result.isNotEmpty) result = result.first; + if (result is String) return result; + return null; + } + + @override + String toString() => parse().toString(); +} diff --git a/lib/src/views/camera/share_image_editor/layers/link_preview/utils.dart b/lib/src/views/camera/share_image_editor/layers/link_preview/utils.dart new file mode 100644 index 0000000..4729f17 --- /dev/null +++ b/lib/src/views/camera/share_image_editor/layers/link_preview/utils.dart @@ -0,0 +1,62 @@ +import 'package:http/http.dart' as http; + +Future<http.Response> fetchWithRedirects( + Uri uri, { + int maxRedirects = 7, + Map<String, String> headers = const {}, + String? userAgent, +}) async { + const userAgentFallback = 'WhatsApp/2.21.12.21 A'; + final allHeaders = <String, String>{ + ...headers, + 'User-Agent': userAgent ?? userAgentFallback, + }; + var response = await http.get(uri, headers: allHeaders); + var redirectCount = 0; + + while (_isRedirect(response) && redirectCount < maxRedirects) { + final location = response.headers['location']; + if (location == null) { + throw Exception('HTTP redirect without Location header'); + } + + response = await http.get(Uri.parse(location), headers: allHeaders); + redirectCount++; + } + + if (redirectCount >= maxRedirects) { + throw Exception('Maximum redirect limit reached'); + } + + return response; +} + +bool _isRedirect(http.Response response) { + return [301, 302, 303, 307, 308].contains(response.statusCode); +} + +Future<http.Response> getYoutubeData(String videoId, String userAgent) async { + final response = await http.get( + Uri.parse( + 'https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=$videoId&format=json', + ), + headers: { + 'User-Agent': userAgent, + }, + ); + return response; +} + +String? getYouTubeVideoId(String url) { + // Regular expression pattern to detect YouTube URLs + // with or without a proxy prefix + final regExp = RegExp( + r'(?:https?:\/\/)?(?:[^\/]+\.)?(?:youtube\.com\/(?:watch\?v=|embed\/|v\/|v\/|.+\?v=)|youtu\.be\/)([a-zA-Z0-9_-]{11})', + ); + + // Apply the regex to the URL + final match = regExp.firstMatch(url); + + // If a match is found, return the first capture group, which is the video ID + return match?.group(1); +} diff --git a/lib/src/views/camera/share_image_editor/layers/text.layer.dart b/lib/src/views/camera/share_image_editor/layers/text.layer.dart index 3e1da6a..7294b00 100755 --- a/lib/src/views/camera/share_image_editor/layers/text.layer.dart +++ b/lib/src/views/camera/share_image_editor/layers/text.layer.dart @@ -6,7 +6,7 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:provider/provider.dart'; import 'package:twonly/src/providers/image_editor.provider.dart'; import 'package:twonly/src/views/camera/share_image_editor/action_button.dart'; -import 'package:twonly/src/views/camera/share_image_editor/data/layer.dart'; +import 'package:twonly/src/views/camera/share_image_editor/layer_data.dart'; /// Text layer class TextLayer extends StatefulWidget { diff --git a/lib/src/views/camera/share_image_editor/layers_viewer.dart b/lib/src/views/camera/share_image_editor/layers_viewer.dart index 2cb51e7..e53e3a8 100644 --- a/lib/src/views/camera/share_image_editor/layers_viewer.dart +++ b/lib/src/views/camera/share_image_editor/layers_viewer.dart @@ -1,9 +1,10 @@ import 'package:flutter/material.dart'; -import 'package:twonly/src/views/camera/share_image_editor/data/layer.dart'; +import 'package:twonly/src/views/camera/share_image_editor/layer_data.dart'; import 'package:twonly/src/views/camera/share_image_editor/layers/background.layer.dart'; import 'package:twonly/src/views/camera/share_image_editor/layers/draw.layer.dart'; import 'package:twonly/src/views/camera/share_image_editor/layers/emoji.layer.dart'; import 'package:twonly/src/views/camera/share_image_editor/layers/filter.layer.dart'; +import 'package:twonly/src/views/camera/share_image_editor/layers/link_preview.layer.dart'; import 'package:twonly/src/views/camera/share_image_editor/layers/text.layer.dart'; /// View stacked layers (unbounded height, width) @@ -39,6 +40,7 @@ class LayersViewer extends StatelessWidget { (layerItem) => layerItem is EmojiLayerData || layerItem is DrawLayerData || + layerItem is LinkPreviewLayerData || layerItem is TextLayerData, ) .map((layerItem) { @@ -60,6 +62,12 @@ class LayersViewer extends StatelessWidget { layerData: layerItem, onUpdate: onUpdate, ); + } else if (layerItem is LinkPreviewLayerData) { + return LinkPreviewLayer( + key: layerItem.key, + layerData: layerItem, + onUpdate: onUpdate, + ); } return Container(); }), diff --git a/lib/src/views/chats/chat_messages_components/message_context_menu.dart b/lib/src/views/chats/chat_messages_components/message_context_menu.dart index 210c6ba..44af049 100644 --- a/lib/src/views/chats/chat_messages_components/message_context_menu.dart +++ b/lib/src/views/chats/chat_messages_components/message_context_menu.dart @@ -14,7 +14,7 @@ import 'package:twonly/src/model/protobuf/client/generated/messages.pbserver.dar import 'package:twonly/src/services/api/messages.dart'; import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; import 'package:twonly/src/utils/misc.dart'; -import 'package:twonly/src/views/camera/share_image_editor/data/layer.dart'; +import 'package:twonly/src/views/camera/share_image_editor/layer_data.dart'; import 'package:twonly/src/views/components/emoji_picker.bottom.dart'; import 'package:twonly/src/views/chats/message_info.view.dart'; import 'package:twonly/src/views/components/alert_dialog.dart'; diff --git a/lib/src/views/chats/media_viewer_components/reaction_buttons.component.dart b/lib/src/views/chats/media_viewer_components/reaction_buttons.component.dart index 827c954..53b12d5 100644 --- a/lib/src/views/chats/media_viewer_components/reaction_buttons.component.dart +++ b/lib/src/views/chats/media_viewer_components/reaction_buttons.component.dart @@ -5,7 +5,7 @@ import 'package:flutter/scheduler.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/src/utils/misc.dart'; -import 'package:twonly/src/views/camera/share_image_editor/data/layer.dart'; +import 'package:twonly/src/views/camera/share_image_editor/layer_data.dart'; import 'package:twonly/src/views/components/emoji_picker.bottom.dart'; import 'package:twonly/src/views/chats/media_viewer_components/emoji_reactions_row.component.dart'; import 'package:twonly/src/views/components/animate_icon.dart'; diff --git a/lib/src/views/components/emoji_picker.bottom.dart b/lib/src/views/components/emoji_picker.bottom.dart index cbca1a4..6139966 100755 --- a/lib/src/views/components/emoji_picker.bottom.dart +++ b/lib/src/views/components/emoji_picker.bottom.dart @@ -2,7 +2,7 @@ import 'dart:io'; import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; import 'package:flutter/material.dart'; import 'package:twonly/src/utils/misc.dart'; -import 'package:twonly/src/views/camera/share_image_editor/data/layer.dart'; +import 'package:twonly/src/views/camera/share_image_editor/layer_data.dart'; class EmojiPickerBottom extends StatelessWidget { const EmojiPickerBottom({super.key}); diff --git a/lib/src/views/home.view.dart b/lib/src/views/home.view.dart index 16018e1..355d38e 100644 --- a/lib/src/views/home.view.dart +++ b/lib/src/views/home.view.dart @@ -120,7 +120,13 @@ class HomeViewState extends State<HomeView> { _intentStreamSub = FlutterSharingIntent.instance.getMediaStream().listen( (f) { - if (mounted) handleIntentSharedFile(context, f); + if (mounted) { + handleIntentSharedFile( + context, + f, + _mainCameraController.setSharedLinkForPreview, + ); + } }, // ignore: inference_failure_on_untyped_parameter onError: (err) { @@ -129,7 +135,13 @@ class HomeViewState extends State<HomeView> { ); FlutterSharingIntent.instance.getInitialSharing().then((f) { - if (mounted) handleIntentSharedFile(context, f); + if (mounted) { + handleIntentSharedFile( + context, + f, + _mainCameraController.setSharedLinkForPreview, + ); + } }); } diff --git a/pubspec.lock b/pubspec.lock index cc26ee5..6b30fbb 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -914,7 +914,7 @@ packages: source: path version: "3.0.1" html: - dependency: transitive + dependency: "direct main" description: name: html sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" diff --git a/pubspec.yaml b/pubspec.yaml index a0d05a5..0cb22ee 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -26,6 +26,7 @@ dependencies: convert: ^3.1.2 crypto: ^3.0.7 clock: ^1.1.2 + html: ^0.15.6 # Trusted publisher flutter.dev diff --git a/test/features/link_parser_test.dart b/test/features/link_parser_test.dart new file mode 100644 index 0000000..97f1cef --- /dev/null +++ b/test/features/link_parser_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:twonly/src/views/camera/share_image_editor/layers/link_preview/parse_link.dart'; + +class LinkParserTest { + LinkParserTest({ + required this.url, + required this.title, + required this.siteName, + required this.desc, + this.image, + }); + + final String url; + final String title; + final String siteName; + final String desc; + final String? image; +} + +void main() { + test('testing different urls', () async { + final testCases = [ + LinkParserTest( + url: 'https://mastodon.social/@islieb/115883317936171927', + title: 'islieb? (@islieb@mastodon.social)', + siteName: 'Mastodon', + desc: 'Attached: 1 image', + image: + 'https://files.mastodon.social/media_attachments/files/115/883/317/526/523/824/original/6fa7ef90ec68f1f1.jpg', + ), + LinkParserTest( + url: 'https://chaos.social/@netzpolitik_feed/115921534467938262', + title: 'netzpolitik.org (@netzpolitik_feed@chaos.social)', + siteName: 'chaos.social', + desc: + 'Die EU-Kommission erkennt Open Source als entscheidend für die digitale Souveränität an und wünscht sich mehr Kommerzialisierung. Bis April will Brüssel eine neue Strategie veröffentlichen. In einer laufenden Konsultation bekräftigen Stimmen aus ganz Europa, welche Vorteile sie in offenem Quellcode sehen.\n' + '\n' + 'https://netzpolitik.org/2026/konsultation-eu-kommission-arbeitet-an-neuer-open-source-strategie/', + ), + ]; + + for (final testCase in testCases) { + final metadata = (await getMetadata(testCase.url))!; + expect(metadata.title, testCase.title); + expect(metadata.siteName, testCase.siteName); + expect(metadata.desc, testCase.desc); + expect(metadata.image, testCase.image); + } + }); +}