Skip to content

Commit

Permalink
[Essentials] Geolocation foreground listening (dotnet#9572)
Browse files Browse the repository at this point in the history
* added geolocation foreground listening public API without implementation

* added section on geolocation sample page for foreground listening feature

* implemented geolocation foreground listening for Android, iOS, MacOS and UWP

* moved common code for determining DesiredAccuracy on UWP to extension method

* renamed class ListeningRequest to GeolocationListeningRequest

* renamed property IsListening to IsListeningForeground

* added xml documentation for all new public methods and properties

* implemented event LocationError, using enum GeolocationError and class GeolocationErrorEventArgs

* fixed potential leak where ContinuousLocationListener keeps the reference to the GeolocationImplementation on iOS

* changed StopListeningForegroundAsync() to StopListeningForeground() and return void

* fixed error in Essentials samples where async keyword is not necessary anymore

* enabled nullable checks for GeolocationListeningRequest class

* renamed ListeningRequest.ios.macos.cs to match class name; no source code changes

* call StopListeningForeground() on Android, iOS and macOS before signalling LocationError event, to make behavior consistent with Windows

* replaced throwing ArgumentNullException with ArgumentNullException.ThrowIfNull()

* added xml documentation for all newly added public geolocaion foreground listening APIs

* removed duplicated code for determining GeolocationAccuracy on iOS and macOS

* renamed event LocationError to ListeningFailed and GeolocationErrorEventArgs to GeolocationListeningFailedEventArgs

* fixed IsListeningForeground property on Windows

* Fixed naming

---------

Co-authored-by: Gerald Versluis <[email protected]>
  • Loading branch information
2 people authored and TJ Lambert committed Feb 21, 2023
1 parent 1811f30 commit cc57ba4
Show file tree
Hide file tree
Showing 25 changed files with 1,007 additions and 57 deletions.
10 changes: 10 additions & 0 deletions src/Essentials/samples/Samples/View/GeolocationPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@
IsEnabled="{Binding IsNotBusy}"
HorizontalOptions="FillAndExpand" />
<Button Text="Refresh" Command="{Binding GetCurrentLocationCommand}" IsEnabled="{Binding IsNotBusy}" />

<Label Text="Foreground listener for Location:" FontAttributes="Bold" Margin="0,6,0,0" />
<StackLayout Orientation="Horizontal" Spacing="6">
<Button Text="Start Listening" HorizontalOptions="FillAndExpand"
Command="{Binding StartListeningCommand}" IsEnabled="{Binding IsNotListening}" />
<Button Text="Stop listening" HorizontalOptions="FillAndExpand"
Command="{Binding StopListeningCommand}" IsEnabled="{Binding IsListening}" />
</StackLayout>
<Label Text="{Binding ListeningLocationStatus}" />
<Label Text="{Binding ListeningLocation}" />
</StackLayout>
</ScrollView>
</Grid>
Expand Down
74 changes: 74 additions & 0 deletions src/Essentials/samples/Samples/ViewModel/GeolocationViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,25 @@ public class GeolocationViewModel : BaseViewModel
string currentLocation;
int accuracy = (int)GeolocationAccuracy.Default;
CancellationTokenSource cts;
string listeningLocation;
string listeningLocationStatus;

public GeolocationViewModel()
{
GetLastLocationCommand = new Command(OnGetLastLocation);
GetCurrentLocationCommand = new Command(OnGetCurrentLocation);
StartListeningCommand = new Command(OnStartListening);
StopListeningCommand = new Command(OnStopListening);
}

public ICommand GetLastLocationCommand { get; }

public ICommand GetCurrentLocationCommand { get; }

public ICommand StartListeningCommand { get; }

public ICommand StopListeningCommand { get; }

public string LastLocation
{
get => lastLocation;
Expand All @@ -45,6 +53,22 @@ public int Accuracy
set => SetProperty(ref accuracy, value);
}

public bool IsListening => Geolocation.IsListeningForeground;

public bool IsNotListening => !IsListening;

public string ListeningLocation
{
get => listeningLocation;
set => SetProperty(ref listeningLocation, value);
}

public string ListeningLocationStatus
{
get => listeningLocationStatus;
set => SetProperty(ref listeningLocationStatus, value);
}

async void OnGetLastLocation()
{
if (IsBusy)
Expand Down Expand Up @@ -88,6 +112,53 @@ async void OnGetCurrentLocation()
IsBusy = false;
}

async void OnStartListening()
{
try
{
Geolocation.LocationChanged += Geolocation_LocationChanged;

var request = new GeolocationListeningRequest((GeolocationAccuracy)Accuracy);

var success = await Geolocation.StartListeningForegroundAsync(request);

ListeningLocationStatus = success
? "Started listening for foreground location updates"
: "Couldn't start listening";
}
catch (Exception ex)
{
ListeningLocationStatus = FormatLocation(null, ex);
}

OnPropertyChanged(nameof(IsListening));
OnPropertyChanged(nameof(IsNotListening));
}

void Geolocation_LocationChanged(object sender, GeolocationLocationChangedEventArgs e)
{
ListeningLocation = FormatLocation(e.Location);
}

void OnStopListening()
{
try
{
Geolocation.LocationChanged -= Geolocation_LocationChanged;

Geolocation.StopListeningForeground();

ListeningLocationStatus = "Stopped listening for foreground location updates";
}
catch (Exception ex)
{
ListeningLocationStatus = FormatLocation(null, ex);
}

OnPropertyChanged(nameof(IsListening));
OnPropertyChanged(nameof(IsNotListening));
}

string FormatLocation(Location location, Exception ex = null)
{
if (location == null)
Expand Down Expand Up @@ -116,6 +187,9 @@ public override void OnDisappearing()
if (cts != null && !cts.IsCancellationRequested)
cts.Cancel();
}

OnStopListening();

base.OnDisappearing();
}
}
Expand Down
169 changes: 168 additions & 1 deletion src/Essentials/src/Geolocation/Geolocation.android.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,19 @@ partial class GeolocationImplementation : IGeolocation
const long twoMinutes = 120000;
static readonly string[] ignoredProviders = new string[] { LocationManager.PassiveProvider, "local_database" };

static ContinuousLocationListener continuousListener;
static List<string> listeningProviders;

static LocationManager locationManager;

static LocationManager LocationManager =>
locationManager ??= Application.Context.GetSystemService(Context.LocationService) as LocationManager;

/// <summary>
/// Indicates if currently listening to location updates while the app is in foreground.
/// </summary>
public bool IsListeningForeground { get => continuousListener != null; }

public async Task<Location> GetLastKnownLocationAsync()
{
await Permissions.EnsureGrantedOrRestrictedAsync<Permissions.LocationWhenInUse>();
Expand All @@ -42,7 +50,7 @@ public async Task<Location> GetLastKnownLocationAsync()

public async Task<Location> GetLocationAsync(GeolocationRequest request, CancellationToken cancellationToken)
{
_ = request ?? throw new ArgumentNullException(nameof(request));
ArgumentNullException.ThrowIfNull(request);

await Permissions.EnsureGrantedOrRestrictedAsync<Permissions.LocationWhenInUse>();

Expand Down Expand Up @@ -112,6 +120,101 @@ void RemoveUpdates()
}
}

/// <summary>
/// Starts listening to location updates using the <see cref="Geolocation.LocationChanged"/>
/// event or the <see cref="Geolocation.ListeningFailed"/> event. Events may only sent when
/// the app is in the foreground. Requests <see cref="Permissions.LocationWhenInUse"/>
/// from the user.
/// </summary>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="request"/> is <see langword="null"/>.</exception>
/// <exception cref="FeatureNotSupportedException">Thrown if listening is not supported on this platform.</exception>
/// <exception cref="InvalidOperationException">Thrown if already listening and <see cref="IsListeningForeground"/> returns <see langword="true"/>.</exception>
/// <param name="request">The listening request parameters to use.</param>
/// <returns><see langword="true"/> when listening was started, or <see langword="false"/> when listening couldn't be started.</returns>
public async Task<bool> StartListeningForegroundAsync(GeolocationListeningRequest request)
{
ArgumentNullException.ThrowIfNull(request);

if (IsListeningForeground)
throw new InvalidOperationException("Already listening to location changes.");

await Permissions.EnsureGrantedOrRestrictedAsync<Permissions.LocationWhenInUse>();

var enabledProviders = LocationManager.GetProviders(true);
var hasProviders = enabledProviders.Any(p => !ignoredProviders.Contains(p));

if (!hasProviders)
throw new FeatureNotEnabledException("Location services are not enabled on device.");

// get the best possible provider for the requested accuracy
var providerInfo = GetBestProvider(LocationManager, request.DesiredAccuracy);

// if no providers exist, we can't listen for locations
if (string.IsNullOrEmpty(providerInfo.Provider))
return false;

var allProviders = LocationManager.GetProviders(false);

listeningProviders = new List<string>();
if (allProviders.Contains(Android.Locations.LocationManager.GpsProvider))
listeningProviders.Add(Android.Locations.LocationManager.GpsProvider);
if (allProviders.Contains(Android.Locations.LocationManager.NetworkProvider))
listeningProviders.Add(Android.Locations.LocationManager.NetworkProvider);

if (listeningProviders.Count == 0)
listeningProviders.Add(providerInfo.Provider);

var continuousListener = new ContinuousLocationListener(LocationManager, providerInfo.Accuracy, listeningProviders);
continuousListener.LocationHandler = HandleLocation;
continuousListener.ErrorHandler = HandleError;

// start getting location updates
// make sure to use a thread with a looper
var looper = Looper.MyLooper() ?? Looper.MainLooper;

var minTimeMilliseconds = (long)request.MinimumTime.TotalMilliseconds;

foreach (var provider in listeningProviders)
LocationManager.RequestLocationUpdates(provider, minTimeMilliseconds, providerInfo.Accuracy, continuousListener, looper);

return true;

void HandleLocation(AndroidLocation location)
{
OnLocationChanged(location.ToLocation());
}

void HandleError(GeolocationError geolocationError)
{
StopListeningForeground();
OnLocationError(geolocationError);
}
}

/// <summary>
/// Stop listening for location updates when the app is in the foreground.
/// Has no effect when not listening and <see cref="Geolocation.IsListeningForeground"/>
/// is currently <see langword="false"/>.
/// </summary>
public void StopListeningForeground()
{
if (continuousListener == null)
return;

continuousListener.LocationHandler = null;
continuousListener.ErrorHandler = null;

if (listeningProviders == null)
return;

for (var i = 0; i < listeningProviders.Count; i++)
{
LocationManager.RemoveUpdates(continuousListener);
}

continuousListener = null;
}

static (string Provider, float Accuracy) GetBestProvider(LocationManager locationManager, GeolocationAccuracy accuracy)
{
// Criteria: https://developer.android.com/reference/android/location/Criteria
Expand Down Expand Up @@ -276,4 +379,68 @@ void ILocationListener.OnStatusChanged(string provider, Availability status, Bun
}
}
}

class ContinuousLocationListener : Java.Lang.Object, ILocationListener
{
readonly LocationManager manager;

float desiredAccuracy;

HashSet<string> activeProviders = new HashSet<string>();

internal Action<AndroidLocation> LocationHandler { get; set; }

internal Action<GeolocationError> ErrorHandler { get; set; }

internal ContinuousLocationListener(LocationManager manager, float desiredAccuracy, IEnumerable<string> providers)
{
this.manager = manager;
this.desiredAccuracy = desiredAccuracy;

foreach (var provider in providers)
{
if (manager.IsProviderEnabled(provider))
activeProviders.Add(provider);
}
}

void ILocationListener.OnLocationChanged(AndroidLocation location)
{
if (location.Accuracy <= desiredAccuracy)
{
LocationHandler?.Invoke(location);
return;
}
}

void ILocationListener.OnProviderDisabled(string provider)
{
lock (activeProviders)
{
if (activeProviders.Remove(provider) &&
activeProviders.Count == 0)
ErrorHandler?.Invoke(GeolocationError.PositionUnavailable);
}
}

void ILocationListener.OnProviderEnabled(string provider)
{
lock (activeProviders)
activeProviders.Add(provider);
}

void ILocationListener.OnStatusChanged(string provider, Availability status, Bundle extras)
{
switch (status)
{
case Availability.Available:
((ILocationListener)this).OnProviderEnabled(provider);
break;

case Availability.OutOfService:
((ILocationListener)this).OnProviderDisabled(provider);
break;
}
}
}
}
Loading

0 comments on commit cc57ba4

Please sign in to comment.