.NET MAUI (Android): Location-Tracking im Hintergrund

In .NET MAUI ist es sehr einach, während der Laufzeit der Anwendung auf die Positionsdaten des Benutzers zuzugreifen. Hierfür reicht ein Aufruf von

await Geolocation.GetLocationAsync(geolocationRequest, cancellationToken);

Die Abfrage der Positionsdaten, wenn die App nicht im Vordergrund ist, ist deutlich weniger trivial, da dafür via MAUI keine direkte Methodik zur Verfügung steht. Dieser Artikel zeigt, wie man trotzdem an die Positionsdaten kommt, wenn die App minimiert oder sogar geschlossen oder das Smartphone-Display ausgeschaltet ist.

Im GitHub-Repository der blecon GmbH steht ein Beispielprojekt zur Verfügung, dass das hier gezeigte Vorgehen implementiert.

LocationService

Zunächst wird ein Service erstellt, der für die tatsächliche Ermittlung der Positionsdaten zuständig ist.

public sealed class LocationService : ILocationService
{
    private readonly GeolocationRequest _geolocationRequest = new(GeolocationAccuracy.Best);

    public async Task Run(CancellationToken cancellationToken)
        => await Task.Run(async () =>
        {
            while (!cancellationToken.IsCancellationRequested)
            {
                await Task.Delay(5000);

                var location = await Geolocation.GetLocationAsync(_geolocationRequest, cancellationToken);
                if (location is null)
                {
                    return;
                }

                WeakReferenceMessenger.Default.Send(new LocationMessage(location.Latitude, location.Longitude));
            }
        }, cancellationToken);
}

Er bietet nur eine Methode, in der in einer while-Schleife zyklisch die aktuellen Positionsdaten abgerufen werden. Diese werden über den WeakReferenceMessenger, der mit dem MVVM Toolkit kommt, an interessierte Stellen weitergeleitet.

LocationForegroundService

Um den erstellten LocationService zum Einsatz zu bringen, muss ein Android ForegroundService implementiert werden. Diese Klasse ist android-spezifisch und muss daher unter /Platforms/Android erstellt werden. Sie muss von Android.App.Service ableiten und das folgende Attribut aufweisen.

[Service(Name = "de.blecon.bglocationtracking.LocationForegroundService")]

Es müssen drei Methoden überschrieben werden:

  • OnBind
  • OnStartCommand
  • OnStopCommand

OnBind

public override IBinder? OnBind(Intent? intent)
    => null;

Für unsere Zwecke ist OnBind irrelevant, weshalb einfach null zurückgegeben wird.

OnStartCommand

[return: GeneratedEnum]
public override StartCommandResult OnStartCommand(
    Intent? intent,
    [GeneratedEnum] StartCommandFlags flags,
    int startId)
{
    _cancellationTokenSource = new CancellationTokenSource();

    var notification = new NotificationHelper().GetServiceStartedNotification();

    if (Build.VERSION.SdkInt < BuildVersionCodes.Tiramisu)
    {
        StartForeground(SERVICE_RUNNING_NOTIFICATION_ID, notification);
    }
    else
    {
        StartForeground(SERVICE_RUNNING_NOTIFICATION_ID, notification, ForegroundService.TypeLocation);
    }

    Task.Run(() =>
    {
        try
        {
            var locationService = IPlatformApplication.Current?.Services.GetService<ILocationService>();
            if (locationService is null)
            {
                throw new InvalidOperationException($"Could not resolve required service of type {nameof(ILocationService)}");
            }

            locationService.Run(_cancellationTokenSource.Token).Wait();
        }
        catch (System.OperationCanceledException)
        {
            // ignore expected exception when the task is cancelled
        }
        finally
        {
            if (_cancellationTokenSource.IsCancellationRequested)
            {
                WeakReferenceMessenger.Default.Send(new StopForegroundServiceRequestedMessage());
            }
        }
    }, _cancellationTokenSource.Token);

    return StartCommandResult.Sticky;
}

In OnStartCommand wird eine Notification erzeugt (hierzu wird der ebenfalls selbst erstellte NotificationHelper verwendet), die dem Benutzer dauerhaft angezeigt wird, so lange der ForegroundService läuft. Anschließend wird der ForegroundService gestartet, was zur Anzeige der erwähnten Benachrichtigung führt. Nun wird die registrierte Instanz des zuvor erstellten und im DI-Container registrierten LocationService ermittelt und die Positionsermittlung gestartet.

OnDestroy

public override void OnDestroy()
{
    if (_cancellationTokenSource is not null)
    {
        _cancellationTokenSource.Token.ThrowIfCancellationRequested();
        _cancellationTokenSource.Cancel();
    }

    base.OnDestroy();
}

OnDestroy schließlich wird benötigt, um den LocationService zu stoppen, indem auf der klasseninternen CancellationTokenSource deren Token der Run-Methode des LocationService übergeben wurde, Cancel aufgerufen wird.

MainActivity

In der Klasse MainActivity, ebenfalls zu finden in /Platforms/Android muss die Methode OnCreate überschrieben werden. Zusätzlich werden die beiden Methoden SetServiceMethods und IsServiceRunning hinzugefügt.

OnCreate

protected override void OnCreate(Bundle? savedInstanceState)
{
    base.OnCreate(savedInstanceState);
    Platform.Init(this, savedInstanceState);

    _serviceIntent = new Intent(this, typeof(LocationForegroundService));
    SetServiceMethods();

    if (Build.VERSION.SdkInt >= BuildVersionCodes.M && !Android.Provider.Settings.CanDrawOverlays(this))
    {
        var intent = new Intent(Android.Provider.Settings.ActionManageOverlayPermission);
        intent.SetFlags(ActivityFlags.NewTask);
        StartActivity(intent);
    }
}

Hier werden das Starten und Stoppen des ForegroundService vorbereitet. Der klasseninterne Intent wird instanziiert und anschließend die Methode SetServiceMethods aufgerufen.

Der Inhalt der if-Abfrage ist für ältere Android-Versionen bzw. Systeme, auf denen keine Overlays gezeichnet werden können. Dies ist außerhalb des Scopes dieser Kurzanleitung.

SetServiceMethods

private void SetServiceMethods()
{
    WeakReferenceMessenger.Default.Register<StartForegroundServiceRequestedMessage>(this, (_, _) =>
    {
        if (!IsServiceRunning(typeof(LocationForegroundService)))
        {
            if (Build.VERSION.SdkInt >= BuildVersionCodes.O)
            {
                StartForegroundService(_serviceIntent);
            }
            else
            {
                StartService(_serviceIntent);
            }
        }
    });

    WeakReferenceMessenger.Default.Register<StopForegroundServiceRequestedMessage>(this, (_, _) =>
    {
        if (IsServiceRunning(typeof(LocationForegroundService)))
        {
            StopService(_serviceIntent);
        }
    });
}

Hier wird erneut der WeakReferenceMessenger eingesetzt, diesmal, um Benachrichtigungen zum Starten und Stoppen des ForegroundService zu erhalten. Geht eine entsprechende Meldung ein, wird er gestartet bzw. gestoppt, allerdings jeweils nur dann, wenn die IsServiceRunning-Prüfung zuvor nicht ergibt, dass er bereits im gewünschten Zustand ist.

IsServiceRunning

private bool IsServiceRunning(Type serviceType)
{
    if (GetSystemService(ActivityService) is not ActivityManager activityManager)
    {
        return false;
    }

    // GetRunningServices is marked as deprecated but still works. May break in the future. Be aware!
    foreach (var service in activityManager.GetRunningServices(int.MaxValue)!)
    {
        if (service.Service!.ClassName == Java.Lang.Class.FromType(serviceType).CanonicalName)
        {
            return true;
        }
    }

    return false;
}

Hier wird geprüft, ob der ForegroundService aktuell läuft. Dazu wird die Methode GetRunningServices verwendet. Diese ist zwar als obsolete markiert, funktioniert allerdings noch. Zudem gibt es zur Zeit noch keinen alternativen Weg, um die erforderliche Prüfung zu machen.

AndroidManifest.xml

Unter /Platforms/Android findet sich die AndroidManifest.xml. Hier sind einige Einstellungen vorzunehmen.

<application android:allowBackup="true" android:icon="@mipmap/appicon" android:supportsRtl="true">
    <service android:name="de.blecon.bglocationtracking.LocationForegroundService"
             android:foregroundServiceType="location"
             android:exported="false" />
</application>

Damit der ForegroundService korrekt angesprochen wird, muss er im Manifest definiert werden. Hierzu wird im application-Knoten ein service-Knoten hinzugefügt. Sein Name muss dem entsprechen, der auch im Service-Attribut des LocationForegroundService angegeben wurde. Also foregroundServiceType ist in unserem Fall location zu wählen.

permissions

<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />

Um auf die Positionsdaten zugreifen zu können, sind einige Permissions nötig, die ebenfalls im Manifest hinterlegt werden müssen. Die FOREGROUND_SERVICE und FOREGROUND_SERVICE_LOCATION-Permissions werden benötigt, um den ForegroundService starten zu können. Die SYSTEM_ALERT_WINDOW-Permission schließlich wird benötigt, damit unsere App im Display over other apps-Dialog von Android erscheint und ihr das entsprechende Recht gegeben werden kann. Ohne dieses wird der ForegroundService nicht laufen.

PermissionService

Damit Positionsdaten ermittelt werden dürfen, muss der Benutzer dies explizit erlauben. Um die benötigten Berechtigungen anzufordern, wird der PermissionService verwendet.

public async Task<PermissionStatus> CheckAndRequestLocationAlwaysPermission(bool showRationale = false)
{
    var status = await Permissions.CheckStatusAsync<Permissions.LocationAlways>();
    if (status == PermissionStatus.Granted)
    {
        return status;
    }

    await Application.Current.MainPage.DisplayAlert(
        "Standortberechtigung",
        "Diese App benötigt während der Laufzeit dauerhaften Zugriff auf Ihren Standort.",
        "OK");

    return await Permissions.RequestAsync<Permissions.LocationAlways>();
}

Da für die Hintergrund-Abfrage der Positionsdaten die höchstmögliche Berechtigung aus dem Bereich Location nötig ist, ist es ausreichend, diese anzufragen. Das passiert in der Methode CheckAndRequestLocationAlwaysPermission.

Konsumieren der Positionsdaten

Da die ermittelten Positionsdaten vom LocationService über den WeakReferenceMessenger als LocationMessage versendet werden, können die Positionsdaten an jeder beliebigen Stelle in der Anwendung abgefangen werden, indem ein entsprechender Handler eingerichtet wird. Im Demo-Projekt findet sich z.B. im MainViewModel die folgende Implementierung.

private void HandleLocationMessage(object recipient, LocationMessage locationMessage)
{
    var locationDetails = $"[{DateTime.Now:HH:mm:ss}] Latitude: {locationMessage.Latitude} - Longitude: {locationMessage.Longitude}";

    LocationChanges.Add(locationDetails);
    Debug.WriteLine(locationDetails);
}

Diese sorgt dafür, dass bei jedem Eingang einer LocationMessage eine ObservableCollection<string>, die eine CollectionView speist, um die neuen Daten ergänzt wird. Zusätzlich erfolgt eine Ausgabe via Debug.WriteLine, um auch während die App minimiert oder geschlossen ist, im Debug-Output von Visual Studio sehen zu können, dass der ForegroundService noch läuft.

WARNUNG

Durch den ForegroundService werden die Positionsdaten auch dann abgefragt, wenn die App geschlossen wurde. Ist das explizit nicht erwünscht, sollte vor Beenden der Anwendung eine StopForegroundServiceRequestedMessage gesendet werden, die von der MainActivity aufgegriffen wird und den Service beendet.

0 Kommentare

Hinterlasse einen Kommentar

An der Diskussion beteiligen?
Hinterlasse uns deinen Kommentar!

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert