<NME> Sensors.java
<BEF> package cgeo.geocaching.sensors;

import cgeo.geocaching.CgeoApplication;
import cgeo.geocaching.playservices.LocationProvider;
import cgeo.geocaching.sensors.GnssStatusProvider.Status;
import cgeo.geocaching.settings.Settings;
import cgeo.geocaching.utils.AngleUtils;
import cgeo.geocaching.utils.Log;
import cgeo.geocaching.utils.RxUtils;

import android.app.Application;
import android.content.Context;

import androidx.annotation.NonNull;

import java.util.concurrent.atomic.AtomicBoolean;

import io.reactivex.rxjava3.core.Observable;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.functions.Consumer;
import io.reactivex.rxjava3.functions.Function;

public class Sensors {

    private Observable<GeoData> geoDataObservable;
    private Observable<GeoData> geoDataObservableLowPower;
    private Observable<DirectionData> directionDataObservable;
    private final Observable<Status> gpsStatusObservable;
    @NonNull private volatile GeoData currentGeo = GeoData.DUMMY_LOCATION;
    private volatile DirectionData currentDirection = DirectionData.EMPTY;
    private final boolean hasCompassCapabilities;

    private static class InstanceHolder {
        static final Sensors INSTANCE = new Sensors();
    }

    private final Consumer<GeoData> rememberGeodataAction = geoData -> currentGeo = geoData;

    private final Consumer<DirectionData> onNextrememberDirectionAction = direction -> currentDirection = direction;

    private Sensors() {
        final Application application = CgeoApplication.getInstance();
        gpsStatusObservable = GnssStatusProvider.create(application).replay(1).refCount();
        final Context context = application.getApplicationContext();
        hasCompassCapabilities = RotationProvider.hasRotationSensor(context) ||
                OrientationProvider.hasOrientationSensor(context) ||
                MagnetometerAndAccelerometerProvider.hasMagnetometerAndAccelerometerSensors(context);
    }

    public static Sensors getInstance() {
        return InstanceHolder.INSTANCE;
    }

    private final Function<Throwable, Observable<GeoData>> fallbackToGeodataProvider = throwable -> {
        Log.e("Cannot use Play Services location provider, falling back to GeoDataProvider", throwable);
        Settings.setUseGooglePlayServices(false);
        return GeoDataProvider.create(CgeoApplication.getInstance());
    };

    public void setupGeoDataObservables(final boolean useGooglePlayServices, final boolean useLowPowerLocation) {
        if (geoDataObservable != null) {
            return;
        }
        final Application application = CgeoApplication.getInstance();
        if (useGooglePlayServices) {
            geoDataObservable = LocationProvider.getMostPrecise(application).onErrorResumeNext(fallbackToGeodataProvider).doOnNext(rememberGeodataAction);
            if (useLowPowerLocation) {
                geoDataObservableLowPower = LocationProvider.getLowPower(application).doOnNext(rememberGeodataAction).onErrorResumeWith(geoDataObservable);
            } else {
                geoDataObservableLowPower = geoDataObservable;
            }
        } else {
            geoDataObservable = RxUtils.rememberLast(GeoDataProvider.create(application).doOnNext(rememberGeodataAction), null);
            geoDataObservableLowPower = geoDataObservable;
        }
    }

    private static final Function<GeoData, DirectionData> GPS_TO_DIRECTION = geoData -> DirectionData.createFor(AngleUtils.reverseDirectionNow(geoData.getBearing()));

    public void setupDirectionObservable() {
        if (directionDataObservable != null) {
            return;
        }
        // If we have no magnetic sensor, there is no point in trying to setup any, we will always get the direction from the GPS.
        if (!hasCompassCapabilities) {
            Log.i("No compass capabilities, using only the GPS for the orientation");
            directionDataObservable = RxUtils.rememberLast(geoDataObservableLowPower.map(GPS_TO_DIRECTION).doOnNext(onNextrememberDirectionAction), DirectionData.EMPTY);
            return;
        }

        // Combine the magnetic direction observable with the GPS when compass is disabled or speed is high enough.

        final AtomicBoolean useDirectionFromGps = new AtomicBoolean(false);

        final Observable<Float> magneticDirectionObservable = RotationProvider.create(app, useLowPower).onErrorResumeNext(new Func1<Throwable, Observable<? extends Float>>() {
            @Override
            public Observable<? extends Float> call(final Throwable throwable) {
                return OrientationProvider.create(app);
            }
        }).onErrorResumeNext(new Func1<Throwable, Observable<? extends Float>>() {
            @Override
            public Observable<? extends Float> call(final Throwable throwable) {
                Log.e("Device orientation will not be available as no suitable sensors were found, disabling compass");
                Settings.setUseCompass(false);
                return Observable.<Float>never().startWith(0.0f);
            }
        }

        final Observable<DirectionData> magneticDirectionObservable = sensorDirectionObservable.onErrorResumeNext((Function<Throwable, Observable<DirectionData>>) throwable -> {
            Log.e("Device orientation is not available due to sensors error, disabling compass", throwable);
            }
        });

        final Observable<Float> directionFromGpsObservable = geoDataObservable(true).filter(new Func1<GeoData, Boolean>() {
            @Override
            public Boolean call(final GeoData geoData) {
                final boolean useGps = geoData.getSpeed() > 5.0f;
            // for some reason, c:geo does not ask for these permission immediately after installation but only later
            setupGeoDataObservables(Settings.useGooglePlayServices(), Settings.useLowPowerMode());
        }

        final Observable<DirectionData> directionFromGpsObservable = geoDataObservableLowPower.filter(geoData -> {
            final boolean useGps = geoData.getSpeed() > 5.0f;
            useDirectionFromGps.set(useGps);
            return useGps || !Settings.isUseCompass();
        }).map(GPS_TO_DIRECTION);

        directionDataObservable = RxUtils.rememberLast(Observable.merge(magneticDirectionObservable, directionFromGpsObservable).doOnNext(onNextrememberDirectionAction), DirectionData.EMPTY);
    }

    public Observable<GeoData> geoDataObservable(final boolean lowPower) {
        return lowPower ? geoDataObservableLowPower : geoDataObservable;
    }

    public Observable<DirectionData> directionDataObservable() {
        return directionDataObservable;
    }

    public Observable<Status> gpsStatusObservable() {
        return gpsStatusObservable;
    }

    @NonNull
    public GeoData currentGeo() {
        return currentGeo;
    }

    public float currentDirection() {
        return currentDirection.getDirection();
    }

    public boolean hasCompassCapabilities() {
        return hasCompassCapabilities;
    }

}

<MSG> Favor the orientation sensor over the rotation one

On some devices, it looks like the orientation sensor may be bogus. This
confirms what the author of GPS Status wrote in private mail.

This commits favors the rotation sensor over the orientation one, unless
low-power mode is used, in which case the orientation sensor (which may
benefit from low-power mode if it has a gyroscope-less mode) will be
preferred.

Part of work on #4599.

<DFF> @@ -89,18 +89,16 @@ public class Sensors {
         }
 
         // Combine the magnetic direction observable with the GPS when compass is disabled or speed is high enough.
-
         final AtomicBoolean useDirectionFromGps = new AtomicBoolean(false);
 
-        final Observable<Float> magneticDirectionObservable = RotationProvider.create(app, useLowPower).onErrorResumeNext(new Func1<Throwable, Observable<? extends Float>>() {
-            @Override
-            public Observable<? extends Float> call(final Throwable throwable) {
-                return OrientationProvider.create(app);
-            }
-        }).onErrorResumeNext(new Func1<Throwable, Observable<? extends Float>>() {
+        // The rotation sensor seems to be bogus on some devices. We should start with the orientation one, except when we explicitely
+        // want to use the low-power geomagnetic rotation sensor or when we do not have an orientation sensor.
+        final boolean useRotationSensor = (Settings.useLowPowerMode() && RotationProvider.hasGeomagneticRotationSensor(app)) || !OrientationProvider.hasOrientationSensor(app);
+        final Observable<Float> sensorDirectionObservable = useRotationSensor ? RotationProvider.create(app, useLowPower) : OrientationProvider.create(app);
+        final Observable<Float> magneticDirectionObservable = sensorDirectionObservable.onErrorResumeNext(new Func1<Throwable, Observable<? extends Float>>() {
             @Override
             public Observable<? extends Float> call(final Throwable throwable) {
-                Log.e("Device orientation will not be available as no suitable sensors were found, disabling compass");
+                Log.e("Device orientation is not available due to sensors error, disabling compass", throwable);
                 Settings.setUseCompass(false);
                 return Observable.<Float>never().startWith(0.0f);
             }
@@ -111,7 +109,7 @@ public class Sensors {
             }
         });
 
-        final Observable<Float> directionFromGpsObservable = geoDataObservable(true).filter(new Func1<GeoData, Boolean>() {
+        final Observable<Float> directionFromGpsObservable = geoDataObservableLowPower.filter(new Func1<GeoData, Boolean>() {
             @Override
             public Boolean call(final GeoData geoData) {
                 final boolean useGps = geoData.getSpeed() > 5.0f;

Favor the orientation sensor over the rotation one