diff --git a/src/Essentials/src/Types/Location.shared.cs b/src/Essentials/src/Types/Location.shared.cs index 892cf582d4f3..2268f477cb77 100644 --- a/src/Essentials/src/Types/Location.shared.cs +++ b/src/Essentials/src/Types/Location.shared.cs @@ -103,6 +103,7 @@ public Location(Location point) Longitude = point.Longitude; Timestamp = DateTime.UtcNow; Altitude = point.Altitude; + AltitudeReferenceSystem = point.AltitudeReferenceSystem; Accuracy = point.Accuracy; VerticalAccuracy = point.VerticalAccuracy; ReducedAccuracy = point.ReducedAccuracy; diff --git a/src/Essentials/src/Types/LocationExtensions.android.cs b/src/Essentials/src/Types/LocationExtensions.android.cs index 7c3d664b9449..ceb72cc9a9b9 100644 --- a/src/Essentials/src/Types/LocationExtensions.android.cs +++ b/src/Essentials/src/Types/LocationExtensions.android.cs @@ -19,18 +19,18 @@ internal static Location ToLocation(this AndroidAddress address) => internal static IEnumerable ToLocations(this IEnumerable addresses) => addresses?.Select(a => a.ToLocation()); - internal static Location ToLocation(this AndroidLocation location) => - new Location + internal static Location ToLocation(this AndroidLocation location) + { + var (altitude, altitudeReference, verticalAccuracy) = GetAltitude(location); + + return new Location { Latitude = location.Latitude, Longitude = location.Longitude, - Altitude = location.HasAltitude ? location.Altitude : default(double?), + Altitude = altitude, Timestamp = location.GetTimestamp().ToUniversalTime(), Accuracy = location.HasAccuracy ? location.Accuracy : default(float?), - VerticalAccuracy = - OperatingSystem.IsAndroidVersionAtLeast(26) && location.HasVerticalAccuracy - ? location.VerticalAccuracyMeters - : null, + VerticalAccuracy = verticalAccuracy, ReducedAccuracy = false, Course = location.HasBearing ? location.Bearing : default(double?), Speed = location.HasSpeed ? location.Speed : default(double?), @@ -40,8 +40,35 @@ internal static Location ToLocation(this AndroidLocation location) => #pragma warning disable CS0618 // Type or member is obsolete : location.IsFromMockProvider, #pragma warning restore CS0618 // Type or member is obsolete - AltitudeReferenceSystem = AltitudeReferenceSystem.Ellipsoid + AltitudeReferenceSystem = altitudeReference }; + } + + // Prefer mean sea level altitude (Android API 34+) when available so altitude + // values are consistent across platforms without manual geoid correction. + // Altitude and its accuracy are selected together so they always come from + // the same reference system (MSL/geoid vs WGS84/ellipsoid). + static (double? Altitude, AltitudeReferenceSystem Reference, double? VerticalAccuracy) GetAltitude(AndroidLocation location) + { + if (OperatingSystem.IsAndroidVersionAtLeast(34) && location.HasMslAltitude) + { + double? mslAccuracy = location.HasMslAltitudeAccuracy + ? location.MslAltitudeAccuracyMeters + : null; + return (location.MslAltitudeMeters, AltitudeReferenceSystem.Geoid, mslAccuracy); + } + + if (location.HasAltitude) + { + double? ellipsoidAccuracy = + OperatingSystem.IsAndroidVersionAtLeast(26) && location.HasVerticalAccuracy + ? location.VerticalAccuracyMeters + : null; + return (location.Altitude, AltitudeReferenceSystem.Ellipsoid, ellipsoidAccuracy); + } + + return (null, AltitudeReferenceSystem.Unspecified, null); + } static readonly DateTime epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); diff --git a/src/Essentials/test/DeviceTests/Tests/Android/Geolocation_Tests.cs b/src/Essentials/test/DeviceTests/Tests/Android/Geolocation_Tests.cs new file mode 100644 index 000000000000..40d921831ad6 --- /dev/null +++ b/src/Essentials/test/DeviceTests/Tests/Android/Geolocation_Tests.cs @@ -0,0 +1,124 @@ +using System; +using Microsoft.Maui.Devices.Sensors; +using Xunit; +using AndroidLocation = Android.Locations.Location; + +namespace Microsoft.Maui.Essentials.DeviceTests +{ + [Category("Geolocation")] + public class Android_Geolocation_Tests + { + [Fact] + public void ToLocation_NoAltitude_UsesUnspecifiedReferenceSystem() + { + var androidLocation = new AndroidLocation("test"); + + var location = androidLocation.ToLocation(); + + Assert.Null(location.Altitude); + Assert.Equal(AltitudeReferenceSystem.Unspecified, location.AltitudeReferenceSystem); + Assert.Null(location.VerticalAccuracy); + } + + [Fact] + public void ToLocation_EllipsoidalAltitude_UsesEllipsoidReferenceSystem() + { + var androidLocation = new AndroidLocation("test") + { + Altitude = 123.45, + }; + + if (OperatingSystem.IsAndroidVersionAtLeast(26)) + androidLocation.VerticalAccuracyMeters = 5.0f; + + var location = androidLocation.ToLocation(); + + Assert.Equal(123.45, location.Altitude); + Assert.Equal(AltitudeReferenceSystem.Ellipsoid, location.AltitudeReferenceSystem); + + if (OperatingSystem.IsAndroidVersionAtLeast(26)) + Assert.Equal(5.0, location.VerticalAccuracy); + else + Assert.Null(location.VerticalAccuracy); + } + + [Fact] + public void ToLocation_MslAltitude_UsesGeoidReferenceSystem() + { + var androidLocation = new AndroidLocation("test") + { + Altitude = 123.45, + }; + + // Baseline: without MSL altitude set, we must get Ellipsoid on any API level. + // This guarantees an assertion runs even on pre-34 devices so the test cannot + // silently pass if the API-34 branch is accidentally taken or the fallback + // regresses. + var baseline = androidLocation.ToLocation(); + Assert.Equal(123.45, baseline.Altitude); + Assert.Equal(AltitudeReferenceSystem.Ellipsoid, baseline.AltitudeReferenceSystem); + + // The remaining assertions exercise the API 34+ MSL path. MslAltitudeMeters is + // only meaningful on API 34+, so below that we stop after validating the + // fallback above. + if (!OperatingSystem.IsAndroidVersionAtLeast(34)) + return; + + androidLocation.MslAltitudeMeters = 100.0; + androidLocation.MslAltitudeAccuracyMeters = 2.5f; + androidLocation.VerticalAccuracyMeters = 5.0f; // ellipsoidal accuracy, must NOT be surfaced + + var location = androidLocation.ToLocation(); + + Assert.Equal(100.0, location.Altitude); + Assert.Equal(AltitudeReferenceSystem.Geoid, location.AltitudeReferenceSystem); + // VerticalAccuracy must be paired with the chosen altitude reference system, + // so the MSL accuracy is used rather than the ellipsoidal one. + Assert.Equal(2.5, location.VerticalAccuracy); + } + + [Fact] + public void ToLocation_MslAltitudeWithoutMslAccuracy_ReportsNullVerticalAccuracy() + { + var androidLocation = new AndroidLocation("test"); + + // Baseline: a location with no altitude reports Unspecified on any API level. + // This keeps the test meaningful on pre-34 devices instead of silently passing. + var baseline = androidLocation.ToLocation(); + Assert.Null(baseline.Altitude); + Assert.Equal(AltitudeReferenceSystem.Unspecified, baseline.AltitudeReferenceSystem); + Assert.Null(baseline.VerticalAccuracy); + + if (!OperatingSystem.IsAndroidVersionAtLeast(34)) + return; + + // On API 34+, an MSL altitude without an MSL accuracy must NOT surface the + // ellipsoidal VerticalAccuracyMeters — they describe a different reference system. + androidLocation.MslAltitudeMeters = 100.0; + androidLocation.VerticalAccuracyMeters = 5.0f; + + var location = androidLocation.ToLocation(); + + Assert.Equal(100.0, location.Altitude); + Assert.Equal(AltitudeReferenceSystem.Geoid, location.AltitudeReferenceSystem); + Assert.Null(location.VerticalAccuracy); + } + + [Fact] + public void LocationCopyConstructor_PreservesAltitudeReferenceSystem() + { + var original = new Location(51.5, -0.1) + { + Altitude = 100.0, + AltitudeReferenceSystem = AltitudeReferenceSystem.Geoid, + VerticalAccuracy = 2.5 + }; + + var copy = new Location(original); + + Assert.Equal(original.Altitude, copy.Altitude); + Assert.Equal(original.AltitudeReferenceSystem, copy.AltitudeReferenceSystem); + Assert.Equal(original.VerticalAccuracy, copy.VerticalAccuracy); + } + } +}