fluffychat/lib/utils/url_launcher.dart
2025-03-02 14:36:39 +01:00

189 lines
6.7 KiB
Dart

import 'package:flutter/material.dart';
import 'package:collection/collection.dart' show IterableExtension;
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:go_router/go_router.dart';
import 'package:matrix/matrix.dart';
import 'package:punycode/punycode.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pages/user_bottom_sheet/user_bottom_sheet.dart';
import 'package:fluffychat/utils/adaptive_bottom_sheet.dart';
import 'package:fluffychat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'platform_infos.dart';
class UrlLauncher {
/// The url to open.
final String? url;
/// The visible name in the GUI. For example the name of a markdown link
/// which may differ from the actual url to open.
final String? name;
final BuildContext context;
const UrlLauncher(this.context, this.url, [this.name]);
void launchUrl() async {
if (url!.toLowerCase().startsWith(AppConfig.deepLinkPrefix) ||
url!.toLowerCase().startsWith(AppConfig.inviteLinkPrefix) ||
{'#', '@', '!', '+', '\$'}.contains(url![0]) ||
url!.toLowerCase().startsWith(AppConfig.schemePrefix)) {
return openMatrixToUrl();
}
final uri = Uri.tryParse(url!);
if (uri == null) {
// we can't open this thing
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(L10n.of(context).cantOpenUri(url!))),
);
return;
}
if (name != null && url != name) {
// If there is a name which differs from the url, we need to make sure
// that the user can see the actual url before opening the browser.
final consent = await showOkCancelAlertDialog(
context: context,
title: L10n.of(context).openLinkInBrowser,
message: url,
okLabel: L10n.of(context).open,
cancelLabel: L10n.of(context).cancel,
);
if (consent != OkCancelResult.ok) return;
}
if (!{'https', 'http'}.contains(uri.scheme)) {
// just launch non-https / non-http uris directly
// we need to transmute geo URIs on desktop and on iOS
if ((!PlatformInfos.isMobile || PlatformInfos.isIOS) &&
uri.scheme == 'geo') {
final latlong = uri.path
.split(';')
.first
.split(',')
.map((s) => double.tryParse(s))
.toList();
if (latlong.length == 2 &&
latlong.first != null &&
latlong.last != null) {
if (PlatformInfos.isIOS) {
// iOS is great at not following standards, so we need to transmute the geo URI
// to an apple maps thingy
// https://developer.apple.com/library/archive/featuredarticles/iPhoneURLScheme_Reference/MapLinks/MapLinks.html
final ll = '${latlong.first},${latlong.last}';
launchUrlString('https://maps.apple.com/?q=$ll&sll=$ll');
} else {
// transmute geo URIs on desktop to openstreetmap links, as those usually can't handle
// geo URIs
launchUrlString(
'https://www.openstreetmap.org/?mlat=${latlong.first}&mlon=${latlong.last}#map=16/${latlong.first}/${latlong.last}',
);
}
return;
}
}
launchUrlString(url!);
return;
}
if (uri.host.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(L10n.of(context).cantOpenUri(url!))),
);
return;
}
// okay, we have either an http or an https URI.
// As some platforms have issues with opening unicode URLs, we are going to help
// them out by punycode-encoding them for them ourself.
final newHost = uri.host.split('.').map((hostPartEncoded) {
final hostPart = Uri.decodeComponent(hostPartEncoded);
final hostPartPunycode = punycodeEncode(hostPart);
return hostPartPunycode != '$hostPart-'
? 'xn--$hostPartPunycode'
: hostPart;
}).join('.');
// Force LaunchMode.externalApplication, otherwise url_launcher will default
// to opening links in a webview on mobile platforms.
launchUrlString(
uri.replace(host: newHost).toString(),
mode: LaunchMode.externalApplication,
);
}
void openMatrixToUrl() async {
final matrix = Matrix.of(context);
final url = this.url!.replaceFirst(
AppConfig.deepLinkPrefix,
AppConfig.inviteLinkPrefix,
);
// The identifier might be a matrix.to url and needs escaping. Or, it might have multiple
// identifiers (room id & event id), or it might also have a query part.
// All this needs parsing.
final identityParts = url.parseIdentifierIntoParts() ??
Uri.tryParse(url)?.host.parseIdentifierIntoParts() ??
Uri.tryParse(url)
?.pathSegments
.lastWhereOrNull((_) => true)
?.parseIdentifierIntoParts();
if (identityParts == null) {
return; // no match, nothing to do
}
if (identityParts.primaryIdentifier.sigil == '!') {
final event = identityParts.secondaryIdentifier;
context.go(
'/${Uri(
pathSegments: ['rooms', identityParts.primaryIdentifier],
queryParameters: event != null ? {'event': event} : null,
)}',
);
}
if (identityParts.primaryIdentifier.sigil == '#') {
// we got a room! Let's open that one
final roomIdOrAlias = identityParts.primaryIdentifier;
final event = identityParts.secondaryIdentifier;
var roomId = matrix.client.getRoomByAlias(roomIdOrAlias)?.id;
if (roomId == null && roomIdOrAlias.sigil == '#') {
// we were unable to find the room locally...so resolve it
final response = await showFutureLoadingDialog(
context: context,
future: () => matrix.client.getRoomIdByAlias(roomIdOrAlias),
);
roomId = response.result?.roomId;
}
if (roomId == null) {
await showOkAlertDialog(
context: context,
message: L10n.of(context)
.noRoomFoundForAlias(identityParts.primaryIdentifier),
title: L10n.of(context).nothingFound,
);
return;
}
context.go(
'/${Uri(
pathSegments: ['rooms', roomId],
queryParameters: event != null ? {'event': event} : null,
)}',
);
return;
} else if (identityParts.primaryIdentifier.sigil == '@') {
await showAdaptiveBottomSheet(
context: context,
builder: (c) => LoadProfileBottomSheet(
userId: identityParts.primaryIdentifier,
outerContext: context,
),
);
}
}
}