diff --git a/PRIVACY.md b/PRIVACY.md index c7797bc6..966f10a3 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -14,7 +14,7 @@ FluffyChat uses the Matrix protocol. This means that FluffyChat is just a client For convenience, one or more servers are set as default that the FluffyChat developers consider trustworthy. The developers of FluffyChat do not guarantee their trustworthiness. Before the first communication, users are informed which server they are connecting to. -FluffyChat only communicates with the selected server and with sentry.io if enabled. +FluffyChat only communicates with the selected server, with sentry.io if enabled and with [OpenStreetMap](https://openstreetmap.org) to display maps. More information is available at: [https://matrix.org](https://matrix.org) @@ -53,6 +53,9 @@ The user is able to save received files and therefore app needs this permission. #### Read External Storage The user is able to send files from the device's file system. +#### Location +FluffyChat makes it possible to share the current location via the chat. When the user shares their location, FluffyChat uses the device location service and sends the geo-data via Matrix. + ## Push Notifications FluffyChat uses the Firebase Cloud Messaging service for push notifications on Android and iOS. This takes place in the following steps: 1. The matrix server sends the push notification to the FluffyChat Push Gateway diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index fc5ccdbc..f95bdb22 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -10,6 +10,8 @@ + + { ); } + void sendLocationAction() async { + await showDialog( + context: context, + useRootNavigator: false, + builder: (c) => SendLocationDialog(room: room), + ); + } + String _getSelectedEventString() { var copyString = ''; if (selectedEvents.length == 1) { @@ -678,6 +687,9 @@ class ChatController extends State { if (choice == 'voice') { voiceMessageAction(); } + if (choice == 'location') { + sendLocationAction(); + } } void onInputBarChanged(String text) { diff --git a/lib/pages/send_location_dialog.dart b/lib/pages/send_location_dialog.dart new file mode 100644 index 00000000..4b83df01 --- /dev/null +++ b/lib/pages/send_location_dialog.dart @@ -0,0 +1,145 @@ +import 'dart:async'; + +import 'package:fluffychat/utils/platform_infos.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:matrix/matrix.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:future_loading_dialog/future_loading_dialog.dart'; + +import '../widgets/event_content/map_bubble.dart'; + +class SendLocationDialog extends StatefulWidget { + final Room room; + + const SendLocationDialog({ + this.room, + Key key, + }) : super(key: key); + + @override + _SendLocationDialogState createState() => _SendLocationDialogState(); +} + +class _SendLocationDialogState extends State { + bool disabled = false; + bool denied = false; + bool isSending = false; + Position position; + Error error; + + @override + void initState() { + super.initState(); + requestLocation(); + } + + Future requestLocation() async { + if (!(await Geolocator.isLocationServiceEnabled())) { + setState(() => disabled = true); + return; + } + var permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + if (permission == LocationPermission.denied) { + setState(() => denied = true); + return; + } + } + if (permission == LocationPermission.deniedForever) { + setState(() => denied = true); + return; + } + try { + Position _position; + try { + _position = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.best, + timeLimit: Duration(seconds: 30), + ); + } on TimeoutException { + _position = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.medium, + timeLimit: Duration(seconds: 30), + ); + } + setState(() => position = _position); + } catch (e) { + setState(() => error = e); + } + } + + void sendAction() async { + setState(() => isSending = true); + final body = + 'https://www.openstreetmap.org/?mlat=${position.latitude}&mlon=${position.longitude}#map=16/${position.latitude}/${position.longitude}'; + final uri = + 'geo:${position.latitude},${position.longitude};u=${position.accuracy}'; + await showFutureLoadingDialog( + context: context, + future: () => widget.room.sendLocation(body, uri), + ); + Navigator.of(context, rootNavigator: false).pop(); + } + + @override + Widget build(BuildContext context) { + Widget contentWidget; + if (position != null) { + contentWidget = MapBubble( + latitude: position.latitude, + longitude: position.longitude, + ); + } else if (disabled) { + contentWidget = Text(L10n.of(context).locationDisabledNotice); + } else if (denied) { + contentWidget = Text(L10n.of(context).locationPermissionDeniedNotice); + } else if (error != null) { + contentWidget = + Text(L10n.of(context).errorObtainingLocation(error.toString())); + } else { + contentWidget = Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CupertinoActivityIndicator(), + SizedBox(width: 12), + Text(L10n.of(context).obtainingLocation), + ], + ); + } + if (PlatformInfos.isCupertinoStyle) { + return CupertinoAlertDialog( + title: Text(L10n.of(context).shareLocation), + content: contentWidget, + actions: [ + CupertinoDialogAction( + onPressed: Navigator.of(context, rootNavigator: false).pop, + child: Text(L10n.of(context).cancel), + ), + CupertinoDialogAction( + onPressed: isSending ? null : sendAction, + child: Text(L10n.of(context).send), + ), + ], + ); + } + return AlertDialog( + title: Text(L10n.of(context).shareLocation), + content: contentWidget, + actions: [ + TextButton( + onPressed: Navigator.of(context, rootNavigator: false).pop, + child: Text(L10n.of(context).cancel), + ), + if (position != null) + TextButton( + onPressed: isSending ? null : sendAction, + child: Text(L10n.of(context).send), + ), + ], + ); + } +} diff --git a/lib/pages/views/chat_view.dart b/lib/pages/views/chat_view.dart index e60f8e2d..3b718dbf 100644 --- a/lib/pages/views/chat_view.dart +++ b/lib/pages/views/chat_view.dart @@ -665,6 +665,21 @@ class ChatView extends StatelessWidget { contentPadding: EdgeInsets.all(0), ), ), + if (PlatformInfos.isMobile) + PopupMenuItem( + value: 'location', + child: ListTile( + leading: CircleAvatar( + backgroundColor: Colors.brown, + foregroundColor: Colors.white, + child: Icon( + Icons.gps_fixed_outlined), + ), + title: Text(L10n.of(context) + .shareLocation), + contentPadding: EdgeInsets.all(0), + ), + ), ], ), ), diff --git a/lib/utils/url_launcher.dart b/lib/utils/url_launcher.dart index 7e282380..da69fcde 100644 --- a/lib/utils/url_launcher.dart +++ b/lib/utils/url_launcher.dart @@ -10,6 +10,8 @@ import 'package:vrouter/vrouter.dart'; import 'package:punycode/punycode.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'platform_infos.dart'; + class UrlLauncher { final String url; final BuildContext context; @@ -30,6 +32,24 @@ class UrlLauncher { } if (!{'https', 'http'}.contains(uri.scheme)) { // just launch non-https / non-http uris directly + + // transmute geo URIs on desktop to openstreetmap links, as those usually can't hanlde + // geo URIs + if (!PlatformInfos.isMobile && uri.scheme == 'geo' && uri.path != null) { + final latlong = uri.path + .split(';') + .first + .split(',') + .map((s) => double.tryParse(s)) + .toList(); + if (latlong.length == 2 && + latlong.first != null && + latlong.last != null) { + launch( + 'https://www.openstreetmap.org/?mlat=${latlong.first}&mlon=${latlong.last}#map=16/${latlong.first}/${latlong.last}'); + return; + } + } launch(url); return; } diff --git a/lib/widgets/event_content/map_bubble.dart b/lib/widgets/event_content/map_bubble.dart new file mode 100644 index 00000000..2c520386 --- /dev/null +++ b/lib/widgets/event_content/map_bubble.dart @@ -0,0 +1,58 @@ +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:flutter/material.dart'; + +class MapBubble extends StatelessWidget { + final double latitude; + final double longitude; + final double zoom; + final double width; + final double height; + final double radius; + const MapBubble({ + this.latitude, + this.longitude, + this.zoom = 14.0, + this.width = 400, + this.height = 400, + this.radius = 10.0, + Key key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: BorderRadius.circular(radius), + child: Container( + constraints: BoxConstraints.loose(Size(width, height)), + child: AspectRatio( + aspectRatio: width / height, + child: FlutterMap( + options: MapOptions( + center: LatLng(latitude, longitude), + zoom: zoom, + ), + layers: [ + TileLayerOptions( + urlTemplate: + 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', + subdomains: ['a', 'b', 'c'], + ), + MarkerLayerOptions( + markers: [ + Marker( + point: LatLng(latitude, longitude), + builder: (context) => Icon( + Icons.location_pin, + color: Colors.red, + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/event_content/message_content.dart b/lib/widgets/event_content/message_content.dart index a0f2db3a..a3d1d9f6 100644 --- a/lib/widgets/event_content/message_content.dart +++ b/lib/widgets/event_content/message_content.dart @@ -18,6 +18,7 @@ import '../../config/app_config.dart'; import 'html_message.dart'; import '../matrix.dart'; import 'message_download_content.dart'; +import 'map_bubble.dart'; class MessageContent extends StatelessWidget { final Event event; @@ -164,6 +165,42 @@ class MessageContent extends StatelessWidget { label: Text(L10n.of(context).encrypted), ); case MessageTypes.Location: + final geoUri = + Uri.tryParse(event.content.tryGet('geo_uri')); + if (geoUri != null && + geoUri.scheme == 'geo' && + geoUri.path != null) { + final latlong = geoUri.path + .split(';') + .first + .split(',') + .map((s) => double.tryParse(s)) + .toList(); + if (latlong.length == 2 && + latlong.first != null && + latlong.last != null) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + MapBubble( + latitude: latlong.first, + longitude: latlong.last, + ), + SizedBox(height: 6), + OutlinedButton.icon( + icon: Icon(Icons.location_on_outlined, color: textColor), + onPressed: + UrlLauncher(context, geoUri.toString()).launchUrl, + label: Text( + L10n.of(context).openInMaps, + style: TextStyle(color: textColor), + ), + ), + ], + ); + } + } + continue textmessage; case MessageTypes.None: textmessage: default: diff --git a/pubspec.lock b/pubspec.lock index 72445202..d641eac1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -426,6 +426,13 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_map: + dependency: "direct main" + description: + name: flutter_map + url: "https://pub.dartlang.org" + source: hosted + version: "0.13.1" flutter_math_fork: dependency: transitive description: @@ -506,6 +513,41 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.2.1" + geolocator: + dependency: "direct main" + description: + name: geolocator + url: "https://pub.dartlang.org" + source: hosted + version: "7.4.0" + geolocator_android: + dependency: transitive + description: + name: geolocator_android + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + geolocator_apple: + dependency: transitive + description: + name: geolocator_apple + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + geolocator_platform_interface: + dependency: transitive + description: + name: geolocator_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.3.2" + geolocator_web: + dependency: transitive + description: + name: geolocator_web + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.6" glob: dependency: transitive description: @@ -620,6 +662,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.6.3" + latlong2: + dependency: transitive + description: + name: latlong2 + url: "https://pub.dartlang.org" + source: hosted + version: "0.8.0" + lists: + dependency: transitive + description: + name: lists + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" localstorage: dependency: "direct main" description: @@ -683,6 +739,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.3.0" + mgrs_dart: + dependency: transitive + description: + name: mgrs_dart + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" mime: dependency: transitive description: @@ -916,6 +979,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.5.0" + positioned_tap_detector_2: + dependency: transitive + description: + name: positioned_tap_detector_2 + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" process: dependency: transitive description: @@ -923,6 +993,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "4.1.0" + proj4dart: + dependency: transitive + description: + name: proj4dart + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" provider: dependency: "direct main" description: @@ -1250,6 +1327,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.7.0" + transparent_image: + dependency: transitive + description: + name: transparent_image + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" tuple: dependency: transitive description: @@ -1285,6 +1369,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.1.0" + unicode: + dependency: transitive + description: + name: unicode + url: "https://pub.dartlang.org" + source: hosted + version: "0.3.1" unifiedpush: dependency: "direct main" description: @@ -1418,6 +1509,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.5" + wkt_parser: + dependency: transitive + description: + name: wkt_parser + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 8fff87df..28606fed 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -29,6 +29,7 @@ dependencies: flutter_local_notifications: ^6.0.0 flutter_localizations: sdk: flutter + flutter_map: ^0.13.1 flutter_matrix_html: ^0.3.0 flutter_olm: ^1.1.2 flutter_openssl_crypto: ^0.0.1 @@ -37,6 +38,7 @@ dependencies: flutter_svg: ^0.22.0 flutter_typeahead: ^3.2.0 future_loading_dialog: ^0.2.1 + geolocator: ^7.4.0 hive_flutter: ^1.1.0 image_picker: git: