add m.location support
Needs cross-platform testing. Currently only tested on Android.
This commit is contained in:
parent
979068fc3a
commit
3a8d977f11
24 changed files with 810 additions and 12 deletions
|
|
@ -1,5 +1,7 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||||
android:maxSdkVersion="32" />
|
android:maxSdkVersion="32" />
|
||||||
|
|
|
||||||
|
|
@ -76,5 +76,7 @@
|
||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
|
<string>Nexus uses your location only when you choose to share it in a message.</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import "package:nexus/models/direction.dart";
|
||||||
import "package:nexus/models/event.dart";
|
import "package:nexus/models/event.dart";
|
||||||
import "package:nexus/models/requests/redact_event.dart";
|
import "package:nexus/models/requests/redact_event.dart";
|
||||||
import "package:nexus/models/relation_type.dart";
|
import "package:nexus/models/relation_type.dart";
|
||||||
|
import "package:nexus/models/content/message.dart";
|
||||||
import "package:nexus/models/requests/send_message.dart";
|
import "package:nexus/models/requests/send_message.dart";
|
||||||
import "package:nexus/models/room.dart";
|
import "package:nexus/models/room.dart";
|
||||||
import "package:nexus/models/room_chat.dart";
|
import "package:nexus/models/room_chat.dart";
|
||||||
|
|
@ -415,6 +416,17 @@ class RoomChatController(final ChatParams info)
|
||||||
repliedEventRowId: repliedEventRowId,
|
repliedEventRowId: repliedEventRowId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Future<void> sendLocation(
|
||||||
|
LocationMessageContent location, {
|
||||||
|
bool shouldMention = true,
|
||||||
|
int? repliedEventRowId,
|
||||||
|
}) => sendWithReply(
|
||||||
|
.message,
|
||||||
|
location,
|
||||||
|
shouldMention: shouldMention,
|
||||||
|
repliedEventRowId: repliedEventRowId,
|
||||||
|
);
|
||||||
|
|
||||||
/// Completes once the event has come down sync.
|
/// Completes once the event has come down sync.
|
||||||
Future<void> sendPollReference(
|
Future<void> sendPollReference(
|
||||||
Event poll,
|
Event poll,
|
||||||
|
|
|
||||||
82
lib/helpers/current_location_helper.dart
Normal file
82
lib/helpers/current_location_helper.dart
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import "dart:async";
|
||||||
|
import "dart:io";
|
||||||
|
|
||||||
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
|
import "package:geolocator/geolocator.dart";
|
||||||
|
import "package:latlong2/latlong.dart";
|
||||||
|
import "package:nexus/controllers/portal.dart";
|
||||||
|
import "package:nexus/models/location_exception.dart";
|
||||||
|
|
||||||
|
class CurrentLocationHelper(final Ref ref) {
|
||||||
|
static const _timeLimit = Duration(seconds: 10);
|
||||||
|
|
||||||
|
/// The user's location and its accuracy in meters, or null if [askPermission]
|
||||||
|
/// is off and they haven't allowed it.
|
||||||
|
Future<(LatLng, double?)?> get({required bool askPermission}) async {
|
||||||
|
// geolocator talks to GeoClue directly, which Flatpak's sandbox blocks, so
|
||||||
|
// Linux uses the location portal, which also asks the user for permission
|
||||||
|
if (Platform.isLinux) {
|
||||||
|
// The portal can't say whether it's allowed without asking
|
||||||
|
if (!askPermission) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final portal = await ref.read(PortalController.provider.future);
|
||||||
|
final location = await portal.location
|
||||||
|
.createSession(accuracy: .exact)
|
||||||
|
.first
|
||||||
|
.timeout(_timeLimit);
|
||||||
|
|
||||||
|
return switch ((location.latitude, location.longitude)) {
|
||||||
|
(final latitude?, final longitude?) => (
|
||||||
|
LatLng(latitude, longitude),
|
||||||
|
location.accuracy,
|
||||||
|
),
|
||||||
|
_ => throw LocationException.failed,
|
||||||
|
};
|
||||||
|
} on TimeoutException {
|
||||||
|
throw LocationException.timedOut;
|
||||||
|
} catch (_) {
|
||||||
|
throw LocationException.failed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await Geolocator.isLocationServiceEnabled()) {
|
||||||
|
if (!askPermission) return null;
|
||||||
|
throw LocationException.servicesOff;
|
||||||
|
}
|
||||||
|
|
||||||
|
var permission = await Geolocator.checkPermission();
|
||||||
|
if (permission == .denied && askPermission) {
|
||||||
|
permission = await Geolocator.requestPermission();
|
||||||
|
}
|
||||||
|
if (permission == .denied || permission == .deniedForever) {
|
||||||
|
if (!askPermission) return null;
|
||||||
|
throw LocationException.denied;
|
||||||
|
}
|
||||||
|
|
||||||
|
Position? position;
|
||||||
|
try {
|
||||||
|
position = await Geolocator.getCurrentPosition(
|
||||||
|
locationSettings: Platform.isAndroid
|
||||||
|
// Play services' location can hang without Google, like on microG,
|
||||||
|
// so this uses Android's own, as other apps do
|
||||||
|
? AndroidSettings(forceLocationManager: true, timeLimit: _timeLimit)
|
||||||
|
: const LocationSettings(timeLimit: _timeLimit),
|
||||||
|
);
|
||||||
|
} on TimeoutException {
|
||||||
|
// A fix can take long indoors, but another app may have a recent one
|
||||||
|
position = await Geolocator.getLastKnownPosition(
|
||||||
|
forceAndroidLocationManager: true,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
throw LocationException.failed;
|
||||||
|
}
|
||||||
|
if (position == null) throw LocationException.timedOut;
|
||||||
|
|
||||||
|
return (LatLng(position.latitude, position.longitude), position.accuracy);
|
||||||
|
}
|
||||||
|
|
||||||
|
static final provider = Provider<CurrentLocationHelper>(
|
||||||
|
CurrentLocationHelper.new,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -173,7 +173,9 @@ extension BuildEventOptions on Event {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
if (content is MessageContent && sender == userId)
|
if (content is MessageContent &&
|
||||||
|
content is! LocationMessageContent &&
|
||||||
|
sender == userId)
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
onTap: () => onRelation(rowId, .edit),
|
onTap: () => onRelation(rowId, .edit),
|
||||||
child: const ListTile(leading: Icon(Icons.edit), title: Text("Edit")),
|
child: const ListTile(leading: Icon(Icons.edit), title: Text("Edit")),
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,10 @@ import "package:nexus/helpers/extensions/is_desktop.dart";
|
||||||
extension ShowAdaptiveSheet on BuildContext {
|
extension ShowAdaptiveSheet on BuildContext {
|
||||||
/// Shows the widget from [builder] in a dialog on desktop, or a bottom sheet
|
/// Shows the widget from [builder] in a dialog on desktop, or a bottom sheet
|
||||||
/// on mobile. Use the context passed to [builder] to close it.
|
/// on mobile. Use the context passed to [builder] to close it.
|
||||||
Future<T?> showAdaptiveSheet<T>(WidgetBuilder builder) => isDesktop
|
Future<T?> showAdaptiveSheet<T>(
|
||||||
|
WidgetBuilder builder, {
|
||||||
|
bool enableDrag = true,
|
||||||
|
}) => isDesktop
|
||||||
? showDialog<T>(
|
? showDialog<T>(
|
||||||
context: this,
|
context: this,
|
||||||
builder: (context) => Dialog(
|
builder: (context) => Dialog(
|
||||||
|
|
@ -18,6 +21,7 @@ extension ShowAdaptiveSheet on BuildContext {
|
||||||
: showModalBottomSheet<T>(
|
: showModalBottomSheet<T>(
|
||||||
context: this,
|
context: this,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
|
enableDrag: enableDrag,
|
||||||
constraints: .new(maxHeight: MediaQuery.sizeOf(this).height - 80),
|
constraints: .new(maxHeight: MediaQuery.sizeOf(this).height - 80),
|
||||||
builder: (context) => SafeArea(child: builder(context)),
|
builder: (context) => SafeArea(child: builder(context)),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import "package:nexus/models/content/content.dart";
|
||||||
import "package:nexus/models/info/file.dart";
|
import "package:nexus/models/info/file.dart";
|
||||||
import "package:nexus/models/info/image.dart";
|
import "package:nexus/models/info/image.dart";
|
||||||
import "package:nexus/models/info/video.dart";
|
import "package:nexus/models/info/video.dart";
|
||||||
|
import "package:nexus/models/location.dart";
|
||||||
part "message.freezed.dart";
|
part "message.freezed.dart";
|
||||||
part "message.g.dart";
|
part "message.g.dart";
|
||||||
|
|
||||||
|
|
@ -90,8 +91,12 @@ sealed class MessageContent extends Content with _$MessageContent {
|
||||||
}) = VideoMessageContent;
|
}) = VideoMessageContent;
|
||||||
|
|
||||||
@FreezedUnionValue("m.location")
|
@FreezedUnionValue("m.location")
|
||||||
factory MessageContent.location({required String body, required Uri geoUri}) =
|
factory MessageContent.location({
|
||||||
LocationMessageContent;
|
required String body,
|
||||||
|
required Uri geoUri,
|
||||||
|
@JsonKey(name: "m.location", includeIfNull: false) LocationInfo? location,
|
||||||
|
@JsonKey(name: "m.asset", includeIfNull: false) LocationAsset? asset,
|
||||||
|
}) = LocationMessageContent;
|
||||||
|
|
||||||
factory MessageContent.fromJson(Map<String, Object?> json) =>
|
factory MessageContent.fromJson(Map<String, Object?> json) =>
|
||||||
_$MessageContentFromJson(json);
|
_$MessageContentFromJson(json);
|
||||||
|
|
|
||||||
84
lib/models/location.dart
Normal file
84
lib/models/location.dart
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import "package:freezed_annotation/freezed_annotation.dart";
|
||||||
|
import "package:latlong2/latlong.dart";
|
||||||
|
import "package:nexus/models/content/message.dart";
|
||||||
|
|
||||||
|
part "location.freezed.dart";
|
||||||
|
part "location.g.dart";
|
||||||
|
|
||||||
|
/// A location message's `m.location` block.
|
||||||
|
@Freezed(toJson: false, fromJson: false)
|
||||||
|
@JsonSerializable(includeIfNull: false)
|
||||||
|
class const LocationInfo({required final Uri uri, final String? description})
|
||||||
|
with _$LocationInfo {
|
||||||
|
Map<String, Object?> toJson() => _$LocationInfoToJson(this);
|
||||||
|
|
||||||
|
factory LocationInfo.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$LocationInfoFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A location message's `m.asset` block.
|
||||||
|
@Freezed(toJson: false, fromJson: false)
|
||||||
|
@JsonSerializable()
|
||||||
|
class const LocationAsset({
|
||||||
|
@JsonKey(unknownEnumValue: LocationAssetType.pin)
|
||||||
|
final LocationAssetType type = .self,
|
||||||
|
}) with _$LocationAsset {
|
||||||
|
Map<String, Object?> toJson() => _$LocationAssetToJson(this);
|
||||||
|
|
||||||
|
factory LocationAsset.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$LocationAssetFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LocationAssetType {
|
||||||
|
@JsonValue("m.self")
|
||||||
|
self,
|
||||||
|
|
||||||
|
@JsonValue("m.pin")
|
||||||
|
pin,
|
||||||
|
}
|
||||||
|
|
||||||
|
extension LocationMessagePoint on LocationMessageContent {
|
||||||
|
LatLng? get point => location?.uri.latLng ?? geoUri.latLng;
|
||||||
|
|
||||||
|
String? get description => switch (location?.description?.trim()) {
|
||||||
|
final description? when description.isNotEmpty => description,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
LocationAssetType get assetType => asset?.type ?? .self;
|
||||||
|
}
|
||||||
|
|
||||||
|
extension FormatLatLng on LatLng {
|
||||||
|
String get formatted =>
|
||||||
|
"${latitude.toStringAsFixed(5)}, ${longitude.toStringAsFixed(5)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
extension GeoUri on Uri {
|
||||||
|
/// The coordinates of a geo: URI, like `geo:51.5,-0.12;u=35`.
|
||||||
|
LatLng? get latLng {
|
||||||
|
if (scheme != "geo") return null;
|
||||||
|
final coordinates = path.split(";").first.split(",");
|
||||||
|
if (coordinates.length < 2) return null;
|
||||||
|
|
||||||
|
return switch ((
|
||||||
|
double.tryParse(coordinates[0]),
|
||||||
|
double.tryParse(coordinates[1]),
|
||||||
|
)) {
|
||||||
|
(final latitude?, final longitude?)
|
||||||
|
when latitude.abs() <= 90 && longitude.abs() <= 180 =>
|
||||||
|
LatLng(latitude, longitude),
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static Uri openStreetMap(LatLng point) => Uri.https(
|
||||||
|
"www.openstreetmap.org",
|
||||||
|
"/",
|
||||||
|
{"mlat": "${point.latitude}", "mlon": "${point.longitude}"},
|
||||||
|
).replace(fragment: "map=16/${point.latitude}/${point.longitude}");
|
||||||
|
|
||||||
|
static Uri fromLatLng(LatLng point, {double? uncertainty}) => Uri.parse(
|
||||||
|
"geo:${point.latitude},${point.longitude}"
|
||||||
|
"${uncertainty == null ? "" : ";u=${uncertainty.round()}"}",
|
||||||
|
);
|
||||||
|
}
|
||||||
11
lib/models/location_exception.dart
Normal file
11
lib/models/location_exception.dart
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
enum LocationException(final String message) implements Exception {
|
||||||
|
servicesOff("Location services are turned off."),
|
||||||
|
denied("Nexus doesn't have permission to see your location."),
|
||||||
|
timedOut("Couldn't get your location in time."),
|
||||||
|
failed(
|
||||||
|
"Couldn't get your location. Check that location services are on, and that Nexus is allowed to use them.",
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => message;
|
||||||
|
}
|
||||||
|
|
@ -25,6 +25,8 @@ import "package:nexus/models/image_pack.dart";
|
||||||
import "package:nexus/widgets/mxc_emoji.dart";
|
import "package:nexus/widgets/mxc_emoji.dart";
|
||||||
import "package:nexus/widgets/sticker_picker.dart";
|
import "package:nexus/widgets/sticker_picker.dart";
|
||||||
import "package:nexus/widgets/create_poll_sheet.dart";
|
import "package:nexus/widgets/create_poll_sheet.dart";
|
||||||
|
import "package:nexus/widgets/location_picker_sheet.dart";
|
||||||
|
import "package:nexus/models/location.dart";
|
||||||
import "package:nexus/models/relation_type.dart";
|
import "package:nexus/models/relation_type.dart";
|
||||||
import "package:nexus/widgets/composer/overlays/tagger_overlay.dart";
|
import "package:nexus/widgets/composer/overlays/tagger_overlay.dart";
|
||||||
import "package:nexus/widgets/composer/paste_files_action.dart";
|
import "package:nexus/widgets/composer/paste_files_action.dart";
|
||||||
|
|
@ -57,6 +59,11 @@ class const Composer(
|
||||||
required bool shouldMention,
|
required bool shouldMention,
|
||||||
})
|
})
|
||||||
onSendPoll,
|
onSendPoll,
|
||||||
|
required final Future<void> Function(
|
||||||
|
LocationMessageContent location, {
|
||||||
|
required bool shouldMention,
|
||||||
|
})
|
||||||
|
onSendLocation,
|
||||||
final FocusNode? node,
|
final FocusNode? node,
|
||||||
super.key,
|
super.key,
|
||||||
}) extends HookConsumerWidget {
|
}) extends HookConsumerWidget {
|
||||||
|
|
@ -110,13 +117,16 @@ class const Composer(
|
||||||
final sticker = useState<(String, PackImage)?>(null);
|
final sticker = useState<(String, PackImage)?>(null);
|
||||||
|
|
||||||
final poll = useState<PollStart?>(null);
|
final poll = useState<PollStart?>(null);
|
||||||
|
final location = useState<LocationMessageContent?>(null);
|
||||||
useEffect(() {
|
useEffect(() {
|
||||||
sticker.value = null;
|
sticker.value = null;
|
||||||
poll.value = null;
|
poll.value = null;
|
||||||
|
location.value = null;
|
||||||
return null;
|
return null;
|
||||||
}, [roomId]);
|
}, [roomId]);
|
||||||
|
|
||||||
final hasReplacement = sticker.value != null || poll.value != null;
|
final hasReplacement =
|
||||||
|
sticker.value != null || poll.value != null || location.value != null;
|
||||||
|
|
||||||
// The relation type stays as edit after cancelling an edit
|
// The relation type stays as edit after cancelling an edit
|
||||||
final isEditing = relationType == .edit && relatedEvent != null;
|
final isEditing = relationType == .edit && relatedEvent != null;
|
||||||
|
|
@ -126,6 +136,7 @@ class const Composer(
|
||||||
textBefore.value ??= controller.text;
|
textBefore.value ??= controller.text;
|
||||||
sticker.value = null;
|
sticker.value = null;
|
||||||
poll.value = null;
|
poll.value = null;
|
||||||
|
location.value = null;
|
||||||
|
|
||||||
final text =
|
final text =
|
||||||
relatedEvent.localContent?.editSource ??
|
relatedEvent.localContent?.editSource ??
|
||||||
|
|
@ -243,11 +254,14 @@ class const Composer(
|
||||||
onSendSticker(shortcode, image, shouldMention: shouldMention.value);
|
onSendSticker(shortcode, image, shouldMention: shouldMention.value);
|
||||||
} else if (poll.value case final draft?) {
|
} else if (poll.value case final draft?) {
|
||||||
onSendPoll(draft, shouldMention: shouldMention.value);
|
onSendPoll(draft, shouldMention: shouldMention.value);
|
||||||
|
} else if (location.value case final draft?) {
|
||||||
|
onSendLocation(draft, shouldMention: shouldMention.value);
|
||||||
}
|
}
|
||||||
onDismiss();
|
onDismiss();
|
||||||
// The draft is kept, so it can still be sent after the replacement
|
// The draft is kept, so it can still be sent after the replacement
|
||||||
sticker.value = null;
|
sticker.value = null;
|
||||||
poll.value = null;
|
poll.value = null;
|
||||||
|
location.value = null;
|
||||||
node?.requestFocus();
|
node?.requestFocus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -443,6 +457,22 @@ class const Composer(
|
||||||
leading: Icon(Icons.poll),
|
leading: Icon(Icons.poll),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
enabled: !isEditing,
|
||||||
|
onTap: () => context.showAdaptiveSheet(
|
||||||
|
enableDrag: false,
|
||||||
|
(context) => LocationPickerSheet(
|
||||||
|
onPick: (picked) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
location.value = picked;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: ListTile(
|
||||||
|
title: Text("Location"),
|
||||||
|
leading: Icon(Icons.location_on),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
icon: Icon(Icons.add),
|
icon: Icon(Icons.add),
|
||||||
),
|
),
|
||||||
|
|
@ -451,8 +481,12 @@ class const Composer(
|
||||||
child: AnimatedSize(
|
child: AnimatedSize(
|
||||||
duration: Durations.short4,
|
duration: Durations.short4,
|
||||||
alignment: .bottomCenter,
|
alignment: .bottomCenter,
|
||||||
child: switch ((sticker.value, poll.value)) {
|
child: switch ((
|
||||||
(_, final draft?) => ComposerReplacement(
|
sticker.value,
|
||||||
|
poll.value,
|
||||||
|
location.value,
|
||||||
|
)) {
|
||||||
|
(_, final draft?, _) => ComposerReplacement(
|
||||||
removeTooltip: "Remove poll",
|
removeTooltip: "Remove poll",
|
||||||
onRemove: () {
|
onRemove: () {
|
||||||
poll.value = null;
|
poll.value = null;
|
||||||
|
|
@ -474,7 +508,7 @@ class const Composer(
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
((final shortcode, final image)?, _) =>
|
((final shortcode, final image)?, _, _) =>
|
||||||
ComposerReplacement(
|
ComposerReplacement(
|
||||||
removeTooltip: "Remove sticker",
|
removeTooltip: "Remove sticker",
|
||||||
onRemove: () {
|
onRemove: () {
|
||||||
|
|
@ -493,7 +527,35 @@ class const Composer(
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
(null, null) => FlutterTagger(
|
(_, _, final draft?) => ComposerReplacement(
|
||||||
|
removeTooltip: "Remove location",
|
||||||
|
onRemove: () {
|
||||||
|
location.value = null;
|
||||||
|
node?.requestFocus();
|
||||||
|
},
|
||||||
|
onKeyEvent: sendOnEnter,
|
||||||
|
ListTile(
|
||||||
|
leading: Icon(
|
||||||
|
draft.assetType == .pin
|
||||||
|
? Icons.location_pin
|
||||||
|
: Icons.my_location,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
draft.description ??
|
||||||
|
(draft.assetType == .pin
|
||||||
|
? "Location"
|
||||||
|
: "My location"),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
draft.point?.formatted ??
|
||||||
|
draft.geoUri.toString(),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: .ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(null, null, null) => FlutterTagger(
|
||||||
triggerStrategy: .eager,
|
triggerStrategy: .eager,
|
||||||
// Characters that continue a search, for
|
// Characters that continue a search, for
|
||||||
// e.g. :+1, :custom_emoji or @user:server
|
// e.g. :+1, :custom_emoji or @user:server
|
||||||
|
|
|
||||||
83
lib/widgets/location_map.dart
Normal file
83
lib/widgets/location_map.dart
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import "package:flutter_map/flutter_map.dart";
|
||||||
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
|
import "package:latlong2/latlong.dart";
|
||||||
|
import "package:material_ui/material_ui.dart";
|
||||||
|
import "package:nexus/helpers/launch_helper.dart";
|
||||||
|
|
||||||
|
class const LocationMap(
|
||||||
|
final LatLng center, {
|
||||||
|
final bool showMarker = true,
|
||||||
|
final bool interactive = false,
|
||||||
|
final double zoom = 15,
|
||||||
|
final MapController? controller,
|
||||||
|
final PositionCallback? onPositionChanged,
|
||||||
|
super.key,
|
||||||
|
}) extends ConsumerWidget {
|
||||||
|
/// The marker's icon, which points at the location from its bottom.
|
||||||
|
static Widget markerOf(BuildContext context) => Icon(
|
||||||
|
Icons.location_pin,
|
||||||
|
size: 40,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) => FlutterMap(
|
||||||
|
mapController: controller,
|
||||||
|
options: MapOptions(
|
||||||
|
initialCenter: center,
|
||||||
|
initialZoom: zoom,
|
||||||
|
minZoom: 2,
|
||||||
|
maxZoom: 19,
|
||||||
|
onPositionChanged: onPositionChanged,
|
||||||
|
interactionOptions: InteractionOptions(
|
||||||
|
flags: interactive
|
||||||
|
? InteractiveFlag.all & ~InteractiveFlag.rotate
|
||||||
|
: InteractiveFlag.none,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
TileLayer(
|
||||||
|
urlTemplate: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||||
|
// Required by OpenStreetMap's tile usage policy
|
||||||
|
userAgentPackageName: "nexus.federated.nexus",
|
||||||
|
),
|
||||||
|
if (showMarker)
|
||||||
|
MarkerLayer(
|
||||||
|
markers: [
|
||||||
|
Marker(
|
||||||
|
point: center,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
alignment: .topCenter,
|
||||||
|
child: markerOf(context),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
// Required by OpenStreetMap
|
||||||
|
Align(
|
||||||
|
alignment: .bottomRight,
|
||||||
|
child: Material(
|
||||||
|
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.8),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: interactive
|
||||||
|
? () => ref
|
||||||
|
.read(LaunchHelper.provider)
|
||||||
|
.launchUrl(
|
||||||
|
Uri.https("www.openstreetmap.org", "/copyright"),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
child: Padding(
|
||||||
|
padding: const .symmetric(horizontal: 4, vertical: 2),
|
||||||
|
child: Text(
|
||||||
|
"© OpenStreetMap",
|
||||||
|
style: Theme.of(context).textTheme.labelSmall,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: .ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
68
lib/widgets/location_message.dart
Normal file
68
lib/widgets/location_message.dart
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
import "package:material_ui/material_ui.dart";
|
||||||
|
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
|
||||||
|
import "package:nexus/models/content/message.dart";
|
||||||
|
import "package:nexus/models/location.dart";
|
||||||
|
import "package:nexus/widgets/location_map.dart";
|
||||||
|
import "package:nexus/widgets/location_viewer_sheet.dart";
|
||||||
|
|
||||||
|
class const LocationMessage(final LocationMessageContent content, {super.key})
|
||||||
|
extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final point = content.point;
|
||||||
|
final description = content.description;
|
||||||
|
|
||||||
|
if (point == null) {
|
||||||
|
return Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
"Invalid location:",
|
||||||
|
style: TextStyle(color: theme.colorScheme.error),
|
||||||
|
),
|
||||||
|
Text(content.body),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: .start,
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
if (description != null)
|
||||||
|
Text(description, style: theme.textTheme.titleSmall),
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: const .new(maxWidth: 400),
|
||||||
|
child: AspectRatio(
|
||||||
|
aspectRatio: 3 / 2,
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: .circular(12),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
LocationMap(point),
|
||||||
|
Positioned.fill(
|
||||||
|
child: Material(
|
||||||
|
type: .transparency,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => context.showAdaptiveSheet(
|
||||||
|
enableDrag: false,
|
||||||
|
(_) => LocationViewerSheet(
|
||||||
|
point,
|
||||||
|
type: content.assetType,
|
||||||
|
description: description,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(point.formatted, style: theme.textTheme.labelSmall),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
157
lib/widgets/location_picker_sheet.dart
Normal file
157
lib/widgets/location_picker_sheet.dart
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
import "package:flutter_hooks/flutter_hooks.dart";
|
||||||
|
import "package:flutter_map/flutter_map.dart";
|
||||||
|
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||||
|
import "package:latlong2/latlong.dart";
|
||||||
|
import "package:material_ui/material_ui.dart";
|
||||||
|
import "package:nexus/helpers/current_location_helper.dart";
|
||||||
|
import "package:nexus/models/content/message.dart";
|
||||||
|
import "package:nexus/models/location.dart";
|
||||||
|
import "package:nexus/widgets/location_map.dart";
|
||||||
|
import "package:nexus/widgets/sheet_layout.dart";
|
||||||
|
|
||||||
|
/// Chooses a location to share, either the user's own or a pin dropped on the
|
||||||
|
/// map.
|
||||||
|
class const LocationPickerSheet({
|
||||||
|
required final void Function(LocationMessageContent location) onPick,
|
||||||
|
super.key,
|
||||||
|
}) extends HookConsumerWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final mapController = useMemoized(MapController.new);
|
||||||
|
useEffect(() => mapController.dispose, [mapController]);
|
||||||
|
|
||||||
|
// Not state, as it changes every frame while the map moves
|
||||||
|
final center = useRef(const LatLng(0, 0));
|
||||||
|
|
||||||
|
// Until the map is moved or the user is located, it's an arbitrary spot
|
||||||
|
final placed = useState(false);
|
||||||
|
// Whether the map is on the user's location, and its accuracy in meters
|
||||||
|
final own = useState<({double? accuracy})?>(null);
|
||||||
|
final locating = useState(false);
|
||||||
|
final error = useState<String?>(null);
|
||||||
|
final descriptionController = useTextEditingController();
|
||||||
|
|
||||||
|
Future<void> locate({bool askPermission = true}) async {
|
||||||
|
locating.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
final found = await ref
|
||||||
|
.read(CurrentLocationHelper.provider)
|
||||||
|
.get(askPermission: askPermission);
|
||||||
|
// Doesn't move away from a spot the user chose while it was finding them
|
||||||
|
if (found == null ||
|
||||||
|
!context.mounted ||
|
||||||
|
(!askPermission && placed.value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final (point, accuracy) = found;
|
||||||
|
center.value = point;
|
||||||
|
mapController.move(point, 16);
|
||||||
|
own.value = (accuracy: accuracy);
|
||||||
|
placed.value = true;
|
||||||
|
} catch (caught) {
|
||||||
|
if (context.mounted && askPermission) error.value = caught.toString();
|
||||||
|
} finally {
|
||||||
|
if (context.mounted) locating.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Starts at the user's location if they've already allowed it, except on
|
||||||
|
// Linux, where checking would ask
|
||||||
|
useEffect(() {
|
||||||
|
locate(askPermission: false);
|
||||||
|
return null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
void share() {
|
||||||
|
final description = descriptionController.text.trim();
|
||||||
|
final self = own.value != null;
|
||||||
|
final geoUri = GeoUri.fromLatLng(
|
||||||
|
center.value,
|
||||||
|
uncertainty: own.value?.accuracy,
|
||||||
|
);
|
||||||
|
|
||||||
|
onPick(
|
||||||
|
.new(
|
||||||
|
body:
|
||||||
|
"${description.isEmpty ? (self ? "My location" : "Location") : description}: $geoUri",
|
||||||
|
geoUri: geoUri,
|
||||||
|
location: .new(
|
||||||
|
uri: geoUri,
|
||||||
|
description: description.isEmpty ? null : description,
|
||||||
|
),
|
||||||
|
asset: .new(type: self ? .self : .pin),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SheetLayout(
|
||||||
|
title: const Text("Share Location"),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: Navigator.of(context).pop,
|
||||||
|
child: const Text("Cancel"),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: placed.value ? share : null,
|
||||||
|
child: const Text("Share"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
height: 300,
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: .circular(12),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
LocationMap(
|
||||||
|
center.value,
|
||||||
|
zoom: 2,
|
||||||
|
interactive: true,
|
||||||
|
showMarker: false,
|
||||||
|
controller: mapController,
|
||||||
|
onPositionChanged: (camera, hasGesture) {
|
||||||
|
center.value = camera.center;
|
||||||
|
if (hasGesture) {
|
||||||
|
own.value = null;
|
||||||
|
placed.value = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IgnorePointer(
|
||||||
|
child: Center(
|
||||||
|
child: Transform.translate(
|
||||||
|
offset: const .new(0, -20),
|
||||||
|
child: LocationMap.markerOf(context),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: locating.value ? null : locate,
|
||||||
|
icon: locating.value
|
||||||
|
? const SizedBox.square(
|
||||||
|
dimension: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.my_location),
|
||||||
|
label: const Text("Use current location"),
|
||||||
|
),
|
||||||
|
if (error.value case final error?)
|
||||||
|
Text(
|
||||||
|
error,
|
||||||
|
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||||
|
),
|
||||||
|
TextField(
|
||||||
|
controller: descriptionController,
|
||||||
|
textCapitalization: .sentences,
|
||||||
|
decoration: const .new(labelText: "Description (optional)"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
lib/widgets/location_viewer_sheet.dart
Normal file
43
lib/widgets/location_viewer_sheet.dart
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
|
import "package:latlong2/latlong.dart";
|
||||||
|
import "package:material_ui/material_ui.dart";
|
||||||
|
import "package:nexus/helpers/launch_helper.dart";
|
||||||
|
import "package:nexus/models/location.dart";
|
||||||
|
import "package:nexus/widgets/location_map.dart";
|
||||||
|
import "package:nexus/widgets/sheet_layout.dart";
|
||||||
|
|
||||||
|
class const LocationViewerSheet(
|
||||||
|
final LatLng point, {
|
||||||
|
final LocationAssetType type = .self,
|
||||||
|
final String? description,
|
||||||
|
super.key,
|
||||||
|
}) extends ConsumerWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) => SheetLayout(
|
||||||
|
title: Text(
|
||||||
|
description ?? (type == .self ? "Shared Location" : "Location"),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => ref
|
||||||
|
.read(LaunchHelper.provider)
|
||||||
|
.launchUrl(GeoUri.openStreetMap(point)),
|
||||||
|
child: const Text("Open in OpenStreetMap"),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: Navigator.of(context).pop,
|
||||||
|
child: const Text("Close"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
height: 400,
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: .circular(12),
|
||||||
|
child: LocationMap(point, interactive: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SelectableText(point.formatted),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ import "package:nexus/helpers/extensions/get_filename.dart";
|
||||||
import "package:nexus/helpers/extensions/linkify_html.dart";
|
import "package:nexus/helpers/extensions/linkify_html.dart";
|
||||||
import "package:nexus/models/content/encrypted.dart";
|
import "package:nexus/models/content/encrypted.dart";
|
||||||
import "package:nexus/models/content/message.dart";
|
import "package:nexus/models/content/message.dart";
|
||||||
|
import "package:nexus/models/location.dart";
|
||||||
import "package:nexus/models/content/poll_start.dart";
|
import "package:nexus/models/content/poll_start.dart";
|
||||||
import "package:nexus/models/content/sticker.dart";
|
import "package:nexus/models/content/sticker.dart";
|
||||||
import "package:nexus/models/event.dart";
|
import "package:nexus/models/event.dart";
|
||||||
|
|
@ -16,6 +17,7 @@ import "package:nexus/widgets/html/html.dart";
|
||||||
import "package:nexus/widgets/lazy_loading/message_avatar.dart";
|
import "package:nexus/widgets/lazy_loading/message_avatar.dart";
|
||||||
import "package:nexus/widgets/lazy_loading/message_displayname.dart";
|
import "package:nexus/widgets/lazy_loading/message_displayname.dart";
|
||||||
import "package:nexus/widgets/linkified_text.dart";
|
import "package:nexus/widgets/linkified_text.dart";
|
||||||
|
import "package:nexus/widgets/location_message.dart";
|
||||||
import "package:nexus/widgets/loading.dart";
|
import "package:nexus/widgets/loading.dart";
|
||||||
import "package:nexus/widgets/message_image.dart";
|
import "package:nexus/widgets/message_image.dart";
|
||||||
import "package:nexus/widgets/reaction_row.dart";
|
import "package:nexus/widgets/reaction_row.dart";
|
||||||
|
|
@ -64,9 +66,18 @@ class const MessageRenderer(
|
||||||
overflow: .ellipsis,
|
overflow: .ellipsis,
|
||||||
)
|
)
|
||||||
: PollRenderer(event, poll),
|
: PollRenderer(event, poll),
|
||||||
// TODO: Handle locations
|
|
||||||
// LocationMessageContent(:final body , :final geoUri) =>
|
|
||||||
MessageContent content => switch (content) {
|
MessageContent content => switch (content) {
|
||||||
|
final LocationMessageContent location =>
|
||||||
|
textOnly
|
||||||
|
? Text(
|
||||||
|
switch (location.description) {
|
||||||
|
final description? => "Location: $description",
|
||||||
|
null => "Location",
|
||||||
|
},
|
||||||
|
maxLines: maxLines,
|
||||||
|
overflow: .ellipsis,
|
||||||
|
)
|
||||||
|
: LocationMessage(location),
|
||||||
TextMessageContent(:final body, :final formattedBody, :final format) ||
|
TextMessageContent(:final body, :final formattedBody, :final format) ||
|
||||||
NoticeMessageContent(
|
NoticeMessageContent(
|
||||||
:final body,
|
:final body,
|
||||||
|
|
@ -167,7 +178,6 @@ class const MessageRenderer(
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
LocationMessageContent(:final String? body) ||
|
|
||||||
UnknownMessageContent(:final body) =>
|
UnknownMessageContent(:final body) =>
|
||||||
body == null
|
body == null
|
||||||
? Text("This message is redacted", style: errorStyle)
|
? Text("This message is redacted", style: errorStyle)
|
||||||
|
|
|
||||||
|
|
@ -350,6 +350,16 @@ final class const RoomChat({
|
||||||
repliedEventRowId,
|
repliedEventRowId,
|
||||||
)
|
)
|
||||||
.onError(showError),
|
.onError(showError),
|
||||||
|
onSendLocation:
|
||||||
|
(location, {required shouldMention}) =>
|
||||||
|
notifier
|
||||||
|
.sendLocation(
|
||||||
|
location,
|
||||||
|
shouldMention: shouldMention,
|
||||||
|
repliedEventRowId:
|
||||||
|
repliedEventRowId,
|
||||||
|
)
|
||||||
|
.onError(showError),
|
||||||
relationType: relationType.value,
|
relationType: relationType.value,
|
||||||
relatedEvent: relatedEvent.value,
|
relatedEvent: relatedEvent.value,
|
||||||
onDismiss: () {
|
onDismiss: () {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import app_links
|
||||||
import dynamic_color
|
import dynamic_color
|
||||||
import file_selector_macos
|
import file_selector_macos
|
||||||
import flutter_local_notifications
|
import flutter_local_notifications
|
||||||
|
import geolocator_apple
|
||||||
import media_kit_libs_macos_video
|
import media_kit_libs_macos_video
|
||||||
import media_kit_video
|
import media_kit_video
|
||||||
import package_info_plus
|
import package_info_plus
|
||||||
|
|
@ -22,6 +23,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin"))
|
DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin"))
|
||||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||||
|
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
|
||||||
MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin"))
|
MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin"))
|
||||||
MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin"))
|
MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin"))
|
||||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||||
|
|
|
||||||
|
|
@ -12,5 +12,7 @@
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.files.user-selected.read-only</key>
|
<key>com.apple.security.files.user-selected.read-only</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.personal-information.location</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|
|
||||||
|
|
@ -42,5 +42,9 @@
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
|
<key>NSLocationUsageDescription</key>
|
||||||
|
<string>Nexus uses your location only when you choose to share it in a message.</string>
|
||||||
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
|
<string>Nexus uses your location only when you choose to share it in a message.</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|
|
||||||
|
|
@ -8,5 +8,7 @@
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.files.user-selected.read-only</key>
|
<key>com.apple.security.files.user-selected.read-only</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.personal-information.location</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue