diff --git a/.gemini/skills/android-maps-utils/SKILL.md b/.gemini/skills/android-maps-utils/SKILL.md
index 38981aed1..9c072b47f 100644
--- a/.gemini/skills/android-maps-utils/SKILL.md
+++ b/.gemini/skills/android-maps-utils/SKILL.md
@@ -1,6 +1,6 @@
---
name: maps-utils-android
-description: Guide for integrating the Google Maps Utility Library for Android (android-maps-utils) into an application. Use when users ask to add features like Marker Clustering, Heatmaps, GeoJSON, KML, or Polyline encoding/decoding.
+description: Guide for integrating the Google Maps Utility Library for Android (android-maps-utils) into an application. Use when users ask to add features like Marker Clustering, Heatmaps, GeoJSON, KML, Polyline encoding/decoding, or Units (distance and elevation conversion and formatting).
---
# Google Maps Utility Library for Android Integration
@@ -45,6 +45,128 @@ Used for server-client coordinate compression and distance calculations.
* **Decoding:** `PolyUtil.decode(encodedPathString)`
* **Distance:** `SphericalUtil.computeDistanceBetween(latLng1, latLng2)`
+### Units & Logical Distance/Elevation Formatting
+Used to represent measurements with the type-safe `Meters` inline value class and format distances and elevations in Metric (`m`, `km`) or Imperial (`ft`, `mi`) units based on the device's `Locale` or `Resources` (`US`, `LR`, and `MM` default to `IMPERIAL` for both; `GB` defaults to `IMPERIAL` road distances + `METRIC` elevations; all others default to `METRIC`), or an explicit `UnitSystem` / formatter override.
+
+* **Logical Precision Rules:**
+ * **Small distances** (`< 1 km` metric or `< 0.25 mi` imperial) and **all elevations** format as whole integers with locale thousands grouping (`"450 m"`, `"528 ft"`, `"14,440 ft"`).
+ * **Medium distances** (`1.0` to `< 10` km, or `0.25` to `< 10` mi) format with 1 decimal digit, keeping trailing `.0` (`"0.3 mi"`, `"1.0 km"`, `"2.5 mi"`).
+ * **Large distances** (`>= 10` km or `>= 10` mi) round to whole integers (`"10 km"`, `"12 mi"`).
+
+* **Kotlin Example:**
+```kotlin
+import com.google.maps.android.ImperialUnitsConverter
+import com.google.maps.android.Meters
+import com.google.maps.android.MetricUnitsConverter
+import com.google.maps.android.UnitSystem
+import com.google.maps.android.UnitsConverter
+import com.google.maps.android.feet
+import com.google.maps.android.getUnitsConverter
+import com.google.maps.android.inFeet
+import com.google.maps.android.inKilometers
+import com.google.maps.android.kilometers
+import com.google.maps.android.meters
+import com.google.maps.android.miles
+import com.google.maps.android.sphericalDistance
+import com.google.maps.android.toDistanceString
+import com.google.maps.android.toElevationString
+import java.util.Locale
+
+// Construct type-safe Meters and perform arithmetic or unit conversions
+val summitElevation: Meters = 4401.2.meters
+val elevationInFeet: Double = summitElevation.inFeet // 14439.6325...
+val routeDistance: Meters = startLatLng.sphericalDistance(endLatLng).meters + 500.feet
+
+// 1. Default formatting using device Locale, explicit Locale, or Android Resources
+val defaultDistance: String = routeDistance.toDistanceString()
+val localizedDistance: String = routeDistance.toDistanceString(context.resources)
+val ukDistance: String = 1.miles.toDistanceString(Locale.UK) // "1.0 mi"
+val ukElevation: String = 1345.meters.toElevationString(Locale.UK) // "1,345 m"
+
+// 2. Override both distance and elevation to Metric or Imperial
+val metric = getUnitsConverter(UnitSystem.METRIC)
+val imperial = getUnitsConverter(UnitSystem.IMPERIAL)
+
+val shortWalk = 450.meters.toDistanceString(metric) // "450 m"
+val quarterMile = 0.25.miles.toDistanceString(imperial) // "0.3 mi"
+val mediumDrive = 2.0.kilometers.toDistanceString(metric) // "2.0 km"
+val longTrip = 12.4.kilometers.toDistanceString(metric) // "12 km"
+val summit = summitElevation.toElevationString(imperial) // "14,440 ft"
+
+// 3. Override only distance or only elevation
+val usMetricElevation = getUnitsConverter(Locale.US, elevationUnits = UnitSystem.METRIC)
+val ukImperialElevation = getUnitsConverter(Locale.UK).withElevationUnits(UnitSystem.IMPERIAL)
+val hybridBySystem = UnitsConverter(
+ distanceUnits = UnitSystem.IMPERIAL,
+ elevationUnits = UnitSystem.METRIC,
+)
+
+// 4. Mix & match converters or supply custom MeasurementFormatter lambdas
+val customConverter = UnitsConverter(
+ distanceFormatter = { meters, symbols ->
+ "${symbols.formatFixedDecimal(meters.inKilometers, decimals = 2)} km"
+ },
+ elevationConverter = MetricUnitsConverter,
+)
+val customElevation = getUnitsConverter().withElevationFormatter { meters, symbols ->
+ "${symbols.formatInteger(meters.value)} MASL"
+}
+```
+
+* **Java Example:**
+```java
+import static com.google.maps.android.Units.feet;
+import static com.google.maps.android.Units.getUnitsConverter;
+import static com.google.maps.android.Units.inFeet;
+import static com.google.maps.android.Units.inKilometers;
+import static com.google.maps.android.Units.kilometers;
+import static com.google.maps.android.Units.meters;
+import static com.google.maps.android.Units.miles;
+import static com.google.maps.android.Units.toDistanceString;
+import static com.google.maps.android.Units.toElevationString;
+
+import com.google.maps.android.ImperialUnitsConverter;
+import com.google.maps.android.MetricUnitsConverter;
+import com.google.maps.android.SphericalUtil;
+import com.google.maps.android.UnitSystem;
+import com.google.maps.android.UnitsConverter;
+import java.util.Locale;
+
+// Express measurements in meters and convert between units
+double twelveFeetInMeters = feet(12); // 3.6576 m
+double elevationInFeet = inFeet(meters(4401.2)); // 14439.63 ft
+
+// 1. Default formatting (Locale or Android Resources)
+double distanceMeters = SphericalUtil.computeDistanceBetween(pointA, pointB);
+String defaultFormatted = toDistanceString(distanceMeters);
+String localizedFormatted = toDistanceString(distanceMeters, context.getResources());
+String ukElevation = toElevationString(meters(1345), Locale.UK); // "1,345 m"
+
+// 2. Override both distance and elevation
+UnitsConverter imperial = getUnitsConverter(UnitSystem.IMPERIAL);
+String quarterMile = imperial.toDistanceString(miles(0.25)); // "0.3 mi"
+String mediumDrive = imperial.toDistanceString(kilometers(2.0)); // "1.2 mi"
+String summit = imperial.toElevationString(meters(4401.2)); // "14,440 ft"
+
+// 3. Override only distance or only elevation
+UnitsConverter usMetricElevation = getUnitsConverter(Locale.US)
+ .withElevationUnits(UnitSystem.METRIC);
+UnitsConverter hybridBySystem = UnitsConverter.create(
+ UnitSystem.IMPERIAL,
+ UnitSystem.METRIC
+);
+
+// 4. Mix & match converters or supply custom MeasurementFormatter lambdas
+UnitsConverter ukConverter = UnitsConverter.create(
+ ImperialUnitsConverter.INSTANCE,
+ MetricUnitsConverter.INSTANCE
+);
+UnitsConverter customConverter = UnitsConverter.create(
+ (meters, symbols) -> symbols.formatFixedDecimal(inKilometers(meters), 2) + " km",
+ MetricUnitsConverter.INSTANCE::toElevationString
+);
+```
+
## 3. Best Practices
1. **Performance:** For massive datasets (e.g., parsing huge GeoJSON files), do not block the main thread. Parse on a background dispatcher and only call `layer.addLayerToMap()` on the UI thread.
2. **Custom Renderers:** If the user wants custom cluster icons, extend `DefaultClusterRenderer` and override `onBeforeClusterItemRendered` and `onBeforeClusterRendered`.
diff --git a/README.md b/README.md
index 4508fef6d..124531c38 100644
--- a/README.md
+++ b/README.md
@@ -29,6 +29,7 @@ upgrading from 4.x, see the [migration guide](MIGRATION.md).
interoperability with Maps API web services
- **Spherical geometry** — for example: computeDistance, computeHeading,
computeArea
+- **Units & logical distance/elevation formatting** — type-safe `Meters` value class, framework-agnostic `UnitSystem` / `UnitsConverter` selection (`MetricUnitsConverter` and `ImperialUnitsConverter`) defaulting to the device's locale, and order-of-magnitude distance formatting
- **Street View metadata** — checks if a Street View panorama exists at a given location
- **Reactive Kotlin Extensions & Builders** — coroutine suspensions (`awaitMap()`), reactive `Flow` observers (`mapClickEvents()`), and option builder DSLs (`addMarker { ... }`) consolidated directly into `com.google.maps.android.*`.
@@ -164,6 +165,128 @@ Full guides for using the utilities are published in
- Polyline encoding and decoding [source](https://github.com/googlemaps/android-maps-utils/blob/main/library/src/main/java/com/google/maps/android/PolyUtil.kt), [encoding sample](https://github.com/googlemaps/android-maps-utils/blob/main/demo/src/main/java/com/google/maps/android/utils/demo/PolySimplifyDemoActivity.java), [decoding sample](https://github.com/googlemaps/android-maps-utils/blob/main/demo/src/main/java/com/google/maps/android/utils/demo/PolyDecodeDemoActivity.java)
- Spherical geometry [source](https://github.com/googlemaps/android-maps-utils/blob/main/library/src/main/java/com/google/maps/android/SphericalUtil.kt), [compute distance sample](https://github.com/googlemaps/android-maps-utils/blob/main/demo/src/main/java/com/google/maps/android/utils/demo/DistanceDemoActivity.java)
+- Units & distance/elevation formatting [source](https://github.com/googlemaps/android-maps-utils/blob/main/library/src/main/java/com/google/maps/android/Units.kt), [sample code](https://github.com/googlemaps/android-maps-utils/blob/main/demo/src/main/java/com/google/maps/android/utils/demo/UnitsDemoActivity.kt)
+
+
+
+
+ Units & Logical Distance/Elevation Formatting
+
+### Units & Logical Distance/Elevation Formatting
+
+The `Units` API (`com.google.maps.android`) provides a type-safe `Meters` value class, locale- and `Resources`-aware `UnitSystem` resolution, and logical significant-digit formatting for distances and elevations:
+
+- **Locale Defaults**:
+ - `US`, `LR`, and `MM` default to **Imperial** (`ft`, `mi`) for both road distances and elevations.
+ - `GB` (`Locale.UK`) defaults out-of-the-box to **Imperial road distances** (`ft`, `mi`) and **Metric elevations** (`m`).
+ - All other locales default to **Metric** (`m`, `km`) for both road distances and elevations.
+- **Logical Precision Rules**:
+ - **Small distances** (`< 1 km` metric or `< 0.25 mi` imperial) and **all elevations** format as whole integers (`450 m`, `528 ft`, `14,440 ft`).
+ - **Medium distances** (`1.0` to `< 10` km, or `0.25` to `< 10` mi) format with 1 decimal digit, keeping trailing `.0` (`0.3 mi`, `1.0 km`, `2.5 mi`).
+ - **Large distances** (`>= 10` km or `>= 10` mi) round to whole numbers (`10 km`, `12 mi`).
+
+```kotlin
+import com.google.maps.android.ImperialUnitsConverter
+import com.google.maps.android.MetricUnitsConverter
+import com.google.maps.android.UnitSystem
+import com.google.maps.android.UnitsConverter
+import com.google.maps.android.getUnitsConverter
+import com.google.maps.android.inFeet
+import com.google.maps.android.inKilometers
+import com.google.maps.android.kilometers
+import com.google.maps.android.meters
+import com.google.maps.android.miles
+import com.google.maps.android.toDistanceString
+import com.google.maps.android.toElevationString
+import java.util.Locale
+
+// 1. Default formatting using device Locale, explicit Locale, or Android Resources (for localized unit templates)
+val distanceText = 450.meters.toDistanceString() // Uses Locale.getDefault()
+val localizedText = 450.meters.toDistanceString(context.resources) // Uses localized R.string.amu_in_* templates
+val ukDistance = 1.miles.toDistanceString(Locale.UK) // "1.0 mi"
+val ukElevation = 1345.meters.toElevationString(Locale.UK) // "1,345 m" (GB defaults to miles/ft + metres)
+
+// 2. Override both distance and elevation to Metric or Imperial
+val imperial = getUnitsConverter(UnitSystem.IMPERIAL)
+val metric = getUnitsConverter(UnitSystem.METRIC)
+
+val summitFeet = 4401.2.meters.inFeet // 14439.6325...
+val shortWalk = 450.meters.toDistanceString(metric) // "450 m"
+val quarterMile = 0.25.miles.toDistanceString(imperial) // "0.3 mi"
+val mediumDrive = 2.0.kilometers.toDistanceString(metric) // "2.0 km"
+val longTrip = 12.4.kilometers.toDistanceString(metric) // "12 km"
+val summit = 4401.2.meters.toElevationString(imperial) // "14,440 ft"
+
+// 3. Override only distance or only elevation (via factory parameters, fluent copy methods, or UnitSystem pair)
+val usMetricElevation = getUnitsConverter(Locale.US, elevationUnits = UnitSystem.METRIC)
+val ukImperialElevation = getUnitsConverter(Locale.UK).withElevationUnits(UnitSystem.IMPERIAL)
+val hybridBySystem = UnitsConverter(
+ distanceUnits = UnitSystem.IMPERIAL,
+ elevationUnits = UnitSystem.METRIC,
+)
+
+// 4. Mix & match converters or supply custom MeasurementFormatter lambdas
+val customConverter = UnitsConverter(
+ distanceFormatter = { meters, symbols ->
+ "${symbols.formatFixedDecimal(meters.inKilometers, decimals = 2)} km"
+ },
+ elevationConverter = MetricUnitsConverter,
+)
+val customElevation = getUnitsConverter().withElevationFormatter { meters, symbols ->
+ "${symbols.formatInteger(meters.value)} MASL"
+}
+```
+
+In Java, `Units` exposes static conversion and formatting methods (usable with `import static com.google.maps.android.Units.*;`):
+
+```java
+import static com.google.maps.android.Units.feet;
+import static com.google.maps.android.Units.getUnitsConverter;
+import static com.google.maps.android.Units.inFeet;
+import static com.google.maps.android.Units.inKilometers;
+import static com.google.maps.android.Units.kilometers;
+import static com.google.maps.android.Units.meters;
+import static com.google.maps.android.Units.miles;
+import static com.google.maps.android.Units.toDistanceString;
+import static com.google.maps.android.Units.toElevationString;
+
+import com.google.maps.android.ImperialUnitsConverter;
+import com.google.maps.android.MetricUnitsConverter;
+import com.google.maps.android.UnitSystem;
+import com.google.maps.android.UnitsConverter;
+import java.util.Locale;
+
+double twelveFeetInMeters = feet(12); // 3.6576 m
+double elevationInFeet = inFeet(meters(4401.2)); // 14439.63 ft
+
+// 1. Default formatting (Locale or Android Resources)
+String defaultDistance = toDistanceString(miles(2.5)); // "2.5 mi" (in US/GB locale)
+String localizedDistance = toDistanceString(miles(2.5), context.getResources());
+String ukElevation = toElevationString(meters(1345), Locale.UK); // "1,345 m"
+
+// 2. Override both distance and elevation
+UnitsConverter imperial = getUnitsConverter(UnitSystem.IMPERIAL);
+String distance = imperial.toDistanceString(miles(2.5)); // "2.5 mi"
+String summit = imperial.toElevationString(meters(4401.2)); // "14,440 ft"
+
+// 3. Override only distance or only elevation
+UnitsConverter usMetricElevation = getUnitsConverter(Locale.US)
+ .withElevationUnits(UnitSystem.METRIC);
+UnitsConverter hybridBySystem = UnitsConverter.create(
+ UnitSystem.IMPERIAL,
+ UnitSystem.METRIC
+);
+
+// 4. Mix & match converters or supply custom MeasurementFormatter lambdas
+UnitsConverter ukConverter = UnitsConverter.create(
+ ImperialUnitsConverter.INSTANCE,
+ MetricUnitsConverter.INSTANCE
+);
+UnitsConverter customConverter = UnitsConverter.create(
+ (meters, symbols) -> symbols.formatFixedDecimal(inKilometers(meters), 2) + " km",
+ MetricUnitsConverter.INSTANCE::toElevationString
+);
+```
diff --git a/demo/src/main/AndroidManifest.xml b/demo/src/main/AndroidManifest.xml
index 90301be60..c76c1898e 100644
--- a/demo/src/main/AndroidManifest.xml
+++ b/demo/src/main/AndroidManifest.xml
@@ -151,6 +151,9 @@
+
diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/DistanceDemoActivity.java b/demo/src/main/java/com/google/maps/android/utils/demo/DistanceDemoActivity.java
index 7b4f22cea..190e66381 100644
--- a/demo/src/main/java/com/google/maps/android/utils/demo/DistanceDemoActivity.java
+++ b/demo/src/main/java/com/google/maps/android/utils/demo/DistanceDemoActivity.java
@@ -27,6 +27,8 @@
import com.google.android.gms.maps.model.Polyline;
import com.google.android.gms.maps.model.PolylineOptions;
import com.google.maps.android.SphericalUtil;
+import com.google.maps.android.Units;
+import com.google.maps.android.UnitsConverter;
import java.util.Arrays;
public class DistanceDemoActivity extends BaseDemoActivity
@@ -35,6 +37,7 @@ public class DistanceDemoActivity extends BaseDemoActivity
private Marker mMarkerA;
private Marker mMarkerB;
private Polyline mPolyline;
+ private UnitsConverter mUnitsConverter;
@Override
protected int getLayoutId() {
@@ -44,6 +47,7 @@ protected int getLayoutId() {
@Override
protected void startDemo(boolean isRestore) {
mTextView = findViewById(R.id.textView);
+ mUnitsConverter = Units.getUnitsConverter();
if (!isRestore) {
getMap().moveCamera(CameraUpdateFactory.newLatLngZoom(new LatLng(-33.8256, 151.2395), 10));
@@ -62,30 +66,21 @@ protected void startDemo(boolean isRestore) {
showDistance();
}
+ // [START maps_android_utils_units_java]
@SuppressLint("SetTextI18n")
private void showDistance() {
- double distance =
+ double distanceMeters =
SphericalUtil.computeDistanceBetween(mMarkerA.getPosition(), mMarkerB.getPosition());
- mTextView.setText(getString(R.string.distance_format, formatNumber(distance)));
+ String formattedDistance = mUnitsConverter.toDistanceString(distanceMeters);
+ mTextView.setText(getString(R.string.distance_format, formattedDistance));
}
+ // [END maps_android_utils_units_java]
+
private void updatePolyline() {
mPolyline.setPoints(Arrays.asList(mMarkerA.getPosition(), mMarkerB.getPosition()));
}
- private String formatNumber(double distance) {
- String unit = getString(R.string.unit_m);
- if (distance < 1) {
- distance *= 1000;
- unit = getString(R.string.unit_mm);
- } else if (distance > 1000) {
- distance /= 1000;
- unit = getString(R.string.unit_km);
- }
-
- return String.format("%4.3f%s", distance, unit);
- }
-
@Override
public void onMarkerDragEnd(@NonNull Marker marker) {
showDistance();
diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/MainActivity.kt b/demo/src/main/java/com/google/maps/android/utils/demo/MainActivity.kt
index baa92de1f..fd7298f5d 100644
--- a/demo/src/main/java/com/google/maps/android/utils/demo/MainActivity.kt
+++ b/demo/src/main/java/com/google/maps/android/utils/demo/MainActivity.kt
@@ -137,6 +137,7 @@ internal fun demoGroups(): List = listOf(
Demo(R.string.demo_title_tile_provider, TileProviderAndProjectionDemo::class.java),
Demo(R.string.demo_title_animation_util, AnimationUtilDemoActivity::class.java),
Demo(R.string.demo_title_reactive_extensions, KtxExtensionsDemoActivity::class.java),
+ Demo(R.string.demo_title_units, UnitsDemoActivity::class.java),
),
),
DemoGroup(
diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/UnitsDemoActivity.kt b/demo/src/main/java/com/google/maps/android/utils/demo/UnitsDemoActivity.kt
new file mode 100644
index 000000000..012a254d7
--- /dev/null
+++ b/demo/src/main/java/com/google/maps/android/utils/demo/UnitsDemoActivity.kt
@@ -0,0 +1,315 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.maps.android.utils.demo
+
+import android.view.View
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.LocalLocale
+import androidx.compose.ui.platform.LocalResources
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.core.graphics.toColorInt
+import androidx.core.view.doOnLayout
+import com.google.android.gms.maps.CameraUpdateFactory
+import com.google.android.gms.maps.model.LatLng
+import com.google.android.gms.maps.model.LatLngBounds
+import com.google.android.gms.maps.model.Marker
+import com.google.android.gms.maps.model.MarkerOptions
+import com.google.android.gms.maps.model.PolylineOptions
+import com.google.maps.android.Meters
+import com.google.maps.android.UnitSystem
+import com.google.maps.android.UnitsConverter
+import com.google.maps.android.getUnitsConverter
+import com.google.maps.android.kilometers
+import com.google.maps.android.meters
+import com.google.maps.android.miles
+import com.google.maps.android.sphericalDistance
+import com.google.maps.android.sphericalPathLength
+import com.google.maps.android.toDistanceString
+import com.google.maps.android.toElevationString
+import java.util.Locale
+
+// [START maps_android_utils_units_kotlin]
+/**
+ * Demonstrates how to use [Meters], [UnitSystem], [UnitsConverter], [getUnitsConverter],
+ * [toDistanceString], and [toElevationString] to format distances and elevations in either
+ * the device's default locale unit system or an explicit Metric / Imperial override.
+ */
+class UnitsDemoActivity : BaseDemoActivity() {
+
+ // [START maps_android_utils_units_demo_model]
+ /**
+ * Domain model for a mountain summit using the type-safe [Meters] inline value class.
+ */
+ data class MountainPeak(val name: String, val position: LatLng, val elevation: Meters)
+
+ /**
+ * User-selectable unit preference mode, allowing automatic locale detection or explicit
+ * Metric / Imperial selection.
+ */
+ enum class UnitPreference {
+ DEVICE_DEFAULT,
+ METRIC,
+ IMPERIAL,
+ ;
+
+ fun resolveDistanceUnits(locale: Locale = Locale.getDefault()): UnitSystem = when (this) {
+ DEVICE_DEFAULT -> UnitSystem.distanceFromLocale(locale)
+ METRIC -> UnitSystem.METRIC
+ IMPERIAL -> UnitSystem.IMPERIAL
+ }
+
+ fun resolveElevationUnits(locale: Locale = Locale.getDefault()): UnitSystem = when (this) {
+ DEVICE_DEFAULT -> UnitSystem.elevationFromLocale(locale)
+ METRIC -> UnitSystem.METRIC
+ IMPERIAL -> UnitSystem.IMPERIAL
+ }
+
+ fun resolveConverter(locale: Locale = Locale.getDefault()): UnitsConverter = when (this) {
+ DEVICE_DEFAULT -> getUnitsConverter(locale)
+ METRIC -> getUnitsConverter(UnitSystem.METRIC)
+ IMPERIAL -> getUnitsConverter(UnitSystem.IMPERIAL)
+ }
+ fun resolveSystemSummary(locale: Locale = Locale.getDefault()): String {
+ val distance = resolveDistanceUnits(locale)
+ val elevation = resolveElevationUnits(locale)
+ return if (distance == elevation) {
+ distance.name
+ } else {
+ "${distance.name} / ${elevation.name}"
+ }
+ }
+ }
+
+ private val peaks = listOf(
+ MountainPeak("Mount Elbert", LatLng(39.1178, -106.4454), 4401.2.meters),
+ MountainPeak("Mount Blue Sky", LatLng(39.5883, -105.6438), 4348.0.meters),
+ MountainPeak("Longs Peak", LatLng(40.2550, -105.6151), 4346.0.meters),
+ MountainPeak("Pikes Peak", LatLng(38.8409, -105.0423), 4302.3.meters),
+ )
+
+ private val sampleDistances = listOf(
+ "Short (< 0.25 mi)" to 150.meters,
+ "Quarter mile (0.25 mi)" to 0.25.miles,
+ "Medium (2.0 km)" to 2.0.kilometers,
+ "Multi-mile (2.5 mi)" to 2.5.miles,
+ "Long (>= 10 km)" to 12.4.kilometers,
+ )
+ // [END maps_android_utils_units_demo_model]
+
+ private val peakMarkers = mutableListOf>()
+ private var selectedPreference by mutableStateOf(UnitPreference.DEVICE_DEFAULT)
+
+ override fun getLayoutId(): Int = R.layout.activity_units_demo
+
+ override fun startDemo(isRestore: Boolean) {
+ val currentMap = map ?: return
+
+ val coordinates = peaks.map { it.position }
+ currentMap.addPolyline(
+ PolylineOptions()
+ .addAll(coordinates)
+ .color("#1A73E8".toColorInt())
+ .width(10f)
+ .geodesic(true),
+ )
+
+ peakMarkers.clear()
+ val converter = selectedPreference.resolveConverter()
+ for (peak in peaks) {
+ val marker = currentMap.addMarker(
+ MarkerOptions()
+ .position(peak.position)
+ .title(peak.name)
+ .snippet(converter.toElevationString(resources, peak.elevation)),
+ )
+ if (marker != null) {
+ peakMarkers.add(peak to marker)
+ }
+ }
+ peakMarkers.firstOrNull()?.second?.showInfoWindow()
+
+ val bounds = LatLngBounds.builder().apply {
+ coordinates.forEach { include(it) }
+ }.build()
+ var hasCenteredCamera = isRestore
+
+ val composeView = findViewById(R.id.units_control_panel)
+ composeView.setContent {
+ MaterialTheme {
+ UnitsControlPanel(
+ preference = selectedPreference,
+ onPreferenceSelected = { newPreference ->
+ selectedPreference = newPreference
+ refreshMarkerSnippets(newPreference.resolveConverter())
+ },
+ onPanelHeightChanged = { heightPx ->
+ if (heightPx > 0) {
+ currentMap.setPadding(0, 0, 0, heightPx)
+ if (!hasCenteredCamera) {
+ hasCenteredCamera = true
+ findViewById(R.id.map).doOnLayout {
+ currentMap.moveCamera(CameraUpdateFactory.newLatLngBounds(bounds, 140))
+ }
+ }
+ }
+ },
+ )
+ }
+ }
+ }
+
+ private fun refreshMarkerSnippets(converter: UnitsConverter) {
+ for ((peak, marker) in peakMarkers) {
+ marker.snippet = converter.toElevationString(resources, peak.elevation)
+ if (marker.isInfoWindowShown) {
+ marker.showInfoWindow()
+ }
+ }
+ }
+
+ @Composable
+ private fun UnitsControlPanel(
+ preference: UnitPreference,
+ onPreferenceSelected: (UnitPreference) -> Unit,
+ onPanelHeightChanged: (Int) -> Unit = {},
+ ) {
+ val currentLocale = LocalLocale.current.platformLocale
+ val currentResources = LocalResources.current
+ // [START maps_android_utils_units_demo_formatting]
+ val converter = preference.resolveConverter(currentLocale)
+ val firstLegDistance: Meters = peaks[0].position.sphericalDistance(peaks[1].position).meters
+ val totalRouteDistance: Meters = peaks.map { it.position }.sphericalPathLength().meters
+ val elevationDifference: Meters = peaks[0].elevation - peaks.last().elevation
+ // [END maps_android_utils_units_demo_formatting]
+
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(12.dp)
+ .onSizeChanged { size -> onPanelHeightChanged(size.height) },
+ elevation = CardDefaults.cardElevation(defaultElevation = 6.dp),
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.units_demo_title),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ )
+ Text(
+ text = stringResource(
+ R.string.units_demo_active_system,
+ preference.resolveSystemSummary(currentLocale),
+ currentLocale.country.ifEmpty { "default" },
+ ),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ FilterChip(
+ selected = preference == UnitPreference.DEVICE_DEFAULT,
+ onClick = { onPreferenceSelected(UnitPreference.DEVICE_DEFAULT) },
+ label = { Text(stringResource(R.string.units_pref_default)) },
+ )
+ FilterChip(
+ selected = preference == UnitPreference.METRIC,
+ onClick = { onPreferenceSelected(UnitPreference.METRIC) },
+ label = { Text(stringResource(R.string.units_pref_metric)) },
+ )
+ FilterChip(
+ selected = preference == UnitPreference.IMPERIAL,
+ onClick = { onPreferenceSelected(UnitPreference.IMPERIAL) },
+ label = { Text(stringResource(R.string.units_pref_imperial)) },
+ )
+ }
+
+ HorizontalDivider()
+
+ SummaryRow(
+ label = "${peaks[0].name} Elevation",
+ value = peaks[0].elevation.toElevationString(currentResources, converter),
+ )
+ SummaryRow(
+ label = "Elevation Delta (${peaks[0].name} → ${peaks.last().name})",
+ value = elevationDifference.toElevationString(currentResources, converter),
+ )
+ SummaryRow(
+ label = "Leg 1 (${peaks[0].name} → ${peaks[1].name})",
+ value = firstLegDistance.toDistanceString(currentResources, converter),
+ )
+ SummaryRow(
+ label = "Total Geodesic Route",
+ value = totalRouteDistance.toDistanceString(currentResources, converter),
+ )
+
+ HorizontalDivider()
+
+ Text(
+ text = stringResource(R.string.units_demo_logical_formatting_header),
+ style = MaterialTheme.typography.labelMedium,
+ fontWeight = FontWeight.SemiBold,
+ )
+ for ((label, distance) in sampleDistances) {
+ SummaryRow(
+ label = label,
+ value = distance.toDistanceString(currentResources, converter),
+ )
+ }
+ }
+ }
+ }
+
+ @Composable
+ private fun SummaryRow(label: String, value: String) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ Text(
+ text = value,
+ style = MaterialTheme.typography.bodySmall,
+ fontWeight = FontWeight.Medium,
+ )
+ }
+ }
+}
+// [END maps_android_utils_units_kotlin]
diff --git a/demo/src/main/res/layout/activity_units_demo.xml b/demo/src/main/res/layout/activity_units_demo.xml
new file mode 100644
index 000000000..73f8cc53d
--- /dev/null
+++ b/demo/src/main/res/layout/activity_units_demo.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
diff --git a/demo/src/main/res/values/strings.xml b/demo/src/main/res/values/strings.xml
index fab100c1e..e7a2b11f3 100644
--- a/demo/src/main/res/values/strings.xml
+++ b/demo/src/main/res/values/strings.xml
@@ -77,6 +77,15 @@
Generating tiles
AnimationUtil sample
Reactive Extensions & Builders
+ Units & Distance Formatting
+
+
+ Distance & Elevation Units
+ Active system: %1$s (Device locale: %2$s)
+ Device Default
+ Metric
+ Imperial
+ Logical Precision Examples
Street View Demo
@@ -101,9 +110,6 @@
Drag the markers!
The markers are %s apart.
- mm
- m
- km
%1$d (including %2$s)
diff --git a/demo/src/test/java/com/google/maps/android/utils/demo/UnitsDemoTest.kt b/demo/src/test/java/com/google/maps/android/utils/demo/UnitsDemoTest.kt
new file mode 100644
index 000000000..2e2cc164a
--- /dev/null
+++ b/demo/src/test/java/com/google/maps/android/utils/demo/UnitsDemoTest.kt
@@ -0,0 +1,50 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.maps.android.utils.demo
+
+import com.google.common.truth.Truth.assertThat
+import com.google.maps.android.ImperialUnitsConverter
+import com.google.maps.android.MetricUnitsConverter
+import com.google.maps.android.UnitSystem
+import com.google.maps.android.meters
+import com.google.maps.android.utils.demo.UnitsDemoActivity.UnitPreference
+import org.junit.Test
+import java.util.Locale
+
+class UnitsDemoTest {
+ @Test
+ fun unitPreference_resolvesExpectedConverter() {
+ assertThat(UnitPreference.DEVICE_DEFAULT.resolveConverter(Locale.US))
+ .isSameInstanceAs(ImperialUnitsConverter)
+
+ // UK (GB) defaults to Imperial road distances (ft/mi) + Metric elevations (m)
+ assertThat(UnitPreference.DEVICE_DEFAULT.resolveDistanceUnits(Locale.UK))
+ .isEqualTo(UnitSystem.IMPERIAL)
+ assertThat(UnitPreference.DEVICE_DEFAULT.resolveElevationUnits(Locale.UK))
+ .isEqualTo(UnitSystem.METRIC)
+ val ukConverter = UnitPreference.DEVICE_DEFAULT.resolveConverter(Locale.UK)
+ assertThat(ukConverter.toDistanceString(2_000.meters, Locale.UK)).isEqualTo("1.2 mi")
+ assertThat(ukConverter.toElevationString(1_345.meters, Locale.UK)).isEqualTo("1,345 m")
+
+ assertThat(UnitPreference.DEVICE_DEFAULT.resolveConverter(Locale.FRANCE))
+ .isSameInstanceAs(MetricUnitsConverter)
+
+ assertThat(UnitPreference.METRIC.resolveConverter(Locale.US))
+ .isSameInstanceAs(MetricUnitsConverter)
+ assertThat(UnitPreference.IMPERIAL.resolveConverter(Locale.FRANCE))
+ .isSameInstanceAs(ImperialUnitsConverter)
+ }
+}
diff --git a/library/api/library.api b/library/api/library.api
index ca48c5952..9cd30af43 100644
--- a/library/api/library.api
+++ b/library/api/library.api
@@ -110,6 +110,16 @@ public final class com/google/maps/android/GoogleMapKt {
public static final fun polylineClickEvents (Lcom/google/android/gms/maps/GoogleMap;)Lkotlinx/coroutines/flow/Flow;
}
+public final class com/google/maps/android/ImperialUnitsConverter : com/google/maps/android/UnitsConverter {
+ public static final field INSTANCE Lcom/google/maps/android/ImperialUnitsConverter;
+ public final fun getMilesThreshold ()D
+ public final fun getUnitSystem ()Lcom/google/maps/android/UnitSystem;
+ public fun toDistanceString-o-ZRCEM (Landroid/content/res/Resources;D)Ljava/lang/String;
+ public fun toDistanceString-s1HjgZ8 (DLcom/google/maps/android/NumberFormatSymbols;)Ljava/lang/String;
+ public fun toElevationString-o-ZRCEM (Landroid/content/res/Resources;D)Ljava/lang/String;
+ public fun toElevationString-s1HjgZ8 (DLcom/google/maps/android/NumberFormatSymbols;)Ljava/lang/String;
+}
+
public final class com/google/maps/android/IndoorBuildingFocusedEvent : com/google/maps/android/IndoorChangeEvent {
public static final field INSTANCE Lcom/google/maps/android/IndoorBuildingFocusedEvent;
}
@@ -215,9 +225,77 @@ public final class com/google/maps/android/MathUtil {
public static final fun wrap (DDD)D
}
+public abstract interface class com/google/maps/android/MeasurementFormatter {
+ public abstract fun format (DLcom/google/maps/android/NumberFormatSymbols;)Ljava/lang/String;
+}
+
+public final class com/google/maps/android/Meters : java/lang/Comparable {
+ public static final synthetic fun box-impl (D)Lcom/google/maps/android/Meters;
+ public synthetic fun compareTo (Ljava/lang/Object;)I
+ public fun compareTo-5-sh_3o (D)I
+ public static fun compareTo-5-sh_3o (DD)I
+ public static fun constructor-impl (D)D
+ public static final fun div-5-sh_3o (DD)D
+ public static final fun div-hMkcKeE (DLjava/lang/Number;)D
+ public fun equals (Ljava/lang/Object;)Z
+ public static fun equals-impl (DLjava/lang/Object;)Z
+ public static final fun equals-impl0 (DD)Z
+ public final fun getValue ()D
+ public fun hashCode ()I
+ public static fun hashCode-impl (D)I
+ public static final fun minus-ySF13Qw (DD)D
+ public static final fun plus-ySF13Qw (DD)D
+ public static final fun times-hMkcKeE (DLjava/lang/Number;)D
+ public fun toString ()Ljava/lang/String;
+ public static fun toString-impl (D)Ljava/lang/String;
+ public static final fun unaryMinus-NA-LpbA (D)D
+ public final synthetic fun unbox-impl ()D
+}
+
+public final class com/google/maps/android/MetricUnitsConverter : com/google/maps/android/UnitsConverter {
+ public static final field INSTANCE Lcom/google/maps/android/MetricUnitsConverter;
+ public final fun getKilometersThreshold ()D
+ public final fun getUnitSystem ()Lcom/google/maps/android/UnitSystem;
+ public fun toDistanceString-o-ZRCEM (Landroid/content/res/Resources;D)Ljava/lang/String;
+ public fun toDistanceString-s1HjgZ8 (DLcom/google/maps/android/NumberFormatSymbols;)Ljava/lang/String;
+ public fun toElevationString-o-ZRCEM (Landroid/content/res/Resources;D)Ljava/lang/String;
+ public fun toElevationString-s1HjgZ8 (DLcom/google/maps/android/NumberFormatSymbols;)Ljava/lang/String;
+}
+
public abstract interface annotation class com/google/maps/android/MoveStartedReason : java/lang/annotation/Annotation {
}
+public final class com/google/maps/android/NumberFormatSymbols {
+ public static final field Companion Lcom/google/maps/android/NumberFormatSymbols$Companion;
+ public static final field DEFAULT Lcom/google/maps/android/NumberFormatSymbols;
+ public fun ()V
+ public fun (CCI)V
+ public synthetic fun (CCIILkotlin/jvm/internal/DefaultConstructorMarker;)V
+ public final fun component1 ()C
+ public final fun component2 ()C
+ public final fun component3 ()I
+ public final fun copy (CCI)Lcom/google/maps/android/NumberFormatSymbols;
+ public static synthetic fun copy$default (Lcom/google/maps/android/NumberFormatSymbols;CCIILjava/lang/Object;)Lcom/google/maps/android/NumberFormatSymbols;
+ public fun equals (Ljava/lang/Object;)Z
+ public final fun formatDistanceValue (D)Ljava/lang/String;
+ public final fun formatFixedDecimal (D)Ljava/lang/String;
+ public final fun formatFixedDecimal (DI)Ljava/lang/String;
+ public static synthetic fun formatFixedDecimal$default (Lcom/google/maps/android/NumberFormatSymbols;DIILjava/lang/Object;)Ljava/lang/String;
+ public static final fun fromLocale ()Lcom/google/maps/android/NumberFormatSymbols;
+ public static final fun fromLocale (Ljava/util/Locale;)Lcom/google/maps/android/NumberFormatSymbols;
+ public final fun getDecimalSeparator ()C
+ public final fun getGroupingSeparator ()C
+ public final fun getGroupingSize ()I
+ public fun hashCode ()I
+ public fun toString ()Ljava/lang/String;
+}
+
+public final class com/google/maps/android/NumberFormatSymbols$Companion {
+ public final fun fromLocale ()Lcom/google/maps/android/NumberFormatSymbols;
+ public final fun fromLocale (Ljava/util/Locale;)Lcom/google/maps/android/NumberFormatSymbols;
+ public static synthetic fun fromLocale$default (Lcom/google/maps/android/NumberFormatSymbols$Companion;Ljava/util/Locale;ILjava/lang/Object;)Lcom/google/maps/android/NumberFormatSymbols;
+}
+
public abstract class com/google/maps/android/OnMarkerDragEvent {
public abstract fun getMarker ()Lcom/google/android/gms/maps/model/Marker;
}
@@ -258,6 +336,13 @@ public final class com/google/maps/android/PolylineKt {
public static final fun getSphericalPathLength (Lcom/google/android/gms/maps/model/Polyline;)D
}
+public final class com/google/maps/android/R$string {
+ public static field amu_in_feet I
+ public static field amu_in_kilometers I
+ public static field amu_in_meters I
+ public static field amu_in_miles I
+}
+
public final class com/google/maps/android/ResponseStreetView {
public fun (Lcom/google/maps/android/Status;)V
public final fun component1 ()Lcom/google/maps/android/Status;
@@ -363,6 +448,150 @@ public final class com/google/maps/android/ThirdDimension : java/lang/Enum {
public static fun values ()[Lcom/google/maps/android/ThirdDimension;
}
+public final class com/google/maps/android/UnitSystem : java/lang/Enum {
+ public static final field Companion Lcom/google/maps/android/UnitSystem$Companion;
+ public static final field IMPERIAL Lcom/google/maps/android/UnitSystem;
+ public static final field METRIC Lcom/google/maps/android/UnitSystem;
+ public static final fun distanceFromCountryCode (Ljava/lang/String;)Lcom/google/maps/android/UnitSystem;
+ public static final fun distanceFromLocale ()Lcom/google/maps/android/UnitSystem;
+ public static final fun distanceFromLocale (Ljava/util/Locale;)Lcom/google/maps/android/UnitSystem;
+ public static final fun distanceFromResources (Landroid/content/res/Resources;)Lcom/google/maps/android/UnitSystem;
+ public static final fun elevationFromCountryCode (Ljava/lang/String;)Lcom/google/maps/android/UnitSystem;
+ public static final fun elevationFromLocale ()Lcom/google/maps/android/UnitSystem;
+ public static final fun elevationFromLocale (Ljava/util/Locale;)Lcom/google/maps/android/UnitSystem;
+ public static final fun elevationFromResources (Landroid/content/res/Resources;)Lcom/google/maps/android/UnitSystem;
+ public static final fun fromCountryCode (Ljava/lang/String;)Lcom/google/maps/android/UnitSystem;
+ public static final fun fromLocale ()Lcom/google/maps/android/UnitSystem;
+ public static final fun fromLocale (Ljava/util/Locale;)Lcom/google/maps/android/UnitSystem;
+ public static final fun fromResources (Landroid/content/res/Resources;)Lcom/google/maps/android/UnitSystem;
+ public final fun getConverter ()Lcom/google/maps/android/UnitsConverter;
+ public static fun getEntries ()Lkotlin/enums/EnumEntries;
+ public static fun valueOf (Ljava/lang/String;)Lcom/google/maps/android/UnitSystem;
+ public static fun values ()[Lcom/google/maps/android/UnitSystem;
+}
+
+public final class com/google/maps/android/UnitSystem$Companion {
+ public final fun distanceFromCountryCode (Ljava/lang/String;)Lcom/google/maps/android/UnitSystem;
+ public final fun distanceFromLocale ()Lcom/google/maps/android/UnitSystem;
+ public final fun distanceFromLocale (Ljava/util/Locale;)Lcom/google/maps/android/UnitSystem;
+ public static synthetic fun distanceFromLocale$default (Lcom/google/maps/android/UnitSystem$Companion;Ljava/util/Locale;ILjava/lang/Object;)Lcom/google/maps/android/UnitSystem;
+ public final fun distanceFromResources (Landroid/content/res/Resources;)Lcom/google/maps/android/UnitSystem;
+ public final fun elevationFromCountryCode (Ljava/lang/String;)Lcom/google/maps/android/UnitSystem;
+ public final fun elevationFromLocale ()Lcom/google/maps/android/UnitSystem;
+ public final fun elevationFromLocale (Ljava/util/Locale;)Lcom/google/maps/android/UnitSystem;
+ public static synthetic fun elevationFromLocale$default (Lcom/google/maps/android/UnitSystem$Companion;Ljava/util/Locale;ILjava/lang/Object;)Lcom/google/maps/android/UnitSystem;
+ public final fun elevationFromResources (Landroid/content/res/Resources;)Lcom/google/maps/android/UnitSystem;
+ public final fun fromCountryCode (Ljava/lang/String;)Lcom/google/maps/android/UnitSystem;
+ public final fun fromLocale ()Lcom/google/maps/android/UnitSystem;
+ public final fun fromLocale (Ljava/util/Locale;)Lcom/google/maps/android/UnitSystem;
+ public static synthetic fun fromLocale$default (Lcom/google/maps/android/UnitSystem$Companion;Ljava/util/Locale;ILjava/lang/Object;)Lcom/google/maps/android/UnitSystem;
+ public final fun fromResources (Landroid/content/res/Resources;)Lcom/google/maps/android/UnitSystem;
+ public final fun getIMPERIAL_COUNTRY_CODES ()Ljava/util/Set;
+ public final fun getIMPERIAL_ELEVATION_COUNTRY_CODES ()Ljava/util/Set;
+}
+
+public final class com/google/maps/android/Units {
+ public static final field FEET_PER_METER D
+ public static final field FEET_PER_MILE I
+ public static final field METERS_PER_FOOT D
+ public static final field METERS_PER_KILOMETER I
+ public static final field METERS_PER_MILE D
+ public static final field MILES_PER_METER D
+ public static final fun UnitsConverter (Lcom/google/maps/android/UnitSystem;Lcom/google/maps/android/UnitSystem;)Lcom/google/maps/android/UnitsConverter;
+ public static final fun UnitsConverter (Lcom/google/maps/android/UnitsConverter;Lcom/google/maps/android/UnitsConverter;)Lcom/google/maps/android/UnitsConverter;
+ public static final synthetic fun UnitsConverter (Lcom/google/maps/android/UnitsConverter;Lkotlin/jvm/functions/Function2;)Lcom/google/maps/android/UnitsConverter;
+ public static final synthetic fun UnitsConverter (Lkotlin/jvm/functions/Function2;Lcom/google/maps/android/UnitsConverter;)Lcom/google/maps/android/UnitsConverter;
+ public static final synthetic fun UnitsConverter (Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;)Lcom/google/maps/android/UnitsConverter;
+ public static final fun feet (Ljava/lang/Number;)D
+ public static final fun formatDistanceValue (D)Ljava/lang/String;
+ public static final fun formatDistanceValue (DLcom/google/maps/android/NumberFormatSymbols;)Ljava/lang/String;
+ public static final fun formatDistanceValue (DLjava/util/Locale;)Ljava/lang/String;
+ public static synthetic fun formatDistanceValue$default (DLjava/util/Locale;ILjava/lang/Object;)Ljava/lang/String;
+ public static final fun getUnitsConverter ()Lcom/google/maps/android/UnitsConverter;
+ public static final fun getUnitsConverter (Landroid/content/res/Resources;)Lcom/google/maps/android/UnitsConverter;
+ public static final fun getUnitsConverter (Landroid/content/res/Resources;Lcom/google/maps/android/UnitSystem;)Lcom/google/maps/android/UnitsConverter;
+ public static final fun getUnitsConverter (Landroid/content/res/Resources;Lcom/google/maps/android/UnitSystem;Lcom/google/maps/android/UnitSystem;)Lcom/google/maps/android/UnitsConverter;
+ public static final fun getUnitsConverter (Lcom/google/maps/android/UnitSystem;)Lcom/google/maps/android/UnitsConverter;
+ public static final fun getUnitsConverter (Ljava/lang/String;)Lcom/google/maps/android/UnitsConverter;
+ public static final fun getUnitsConverter (Ljava/lang/String;Lcom/google/maps/android/UnitSystem;)Lcom/google/maps/android/UnitsConverter;
+ public static final fun getUnitsConverter (Ljava/lang/String;Lcom/google/maps/android/UnitSystem;Lcom/google/maps/android/UnitSystem;)Lcom/google/maps/android/UnitsConverter;
+ public static final fun getUnitsConverter (Ljava/util/Locale;)Lcom/google/maps/android/UnitsConverter;
+ public static final fun getUnitsConverter (Ljava/util/Locale;Lcom/google/maps/android/UnitSystem;)Lcom/google/maps/android/UnitsConverter;
+ public static final fun getUnitsConverter (Ljava/util/Locale;Lcom/google/maps/android/UnitSystem;Lcom/google/maps/android/UnitSystem;)Lcom/google/maps/android/UnitsConverter;
+ public static synthetic fun getUnitsConverter$default (Landroid/content/res/Resources;Lcom/google/maps/android/UnitSystem;Lcom/google/maps/android/UnitSystem;ILjava/lang/Object;)Lcom/google/maps/android/UnitsConverter;
+ public static synthetic fun getUnitsConverter$default (Ljava/lang/String;Lcom/google/maps/android/UnitSystem;Lcom/google/maps/android/UnitSystem;ILjava/lang/Object;)Lcom/google/maps/android/UnitsConverter;
+ public static synthetic fun getUnitsConverter$default (Ljava/util/Locale;Lcom/google/maps/android/UnitSystem;Lcom/google/maps/android/UnitSystem;ILjava/lang/Object;)Lcom/google/maps/android/UnitsConverter;
+ public static final fun inFeet (D)D
+ public static final fun inKilometers (D)D
+ public static final fun inMeters (D)D
+ public static final fun inMiles (D)D
+ public static final fun kilometers (Ljava/lang/Number;)D
+ public static final fun meters (Ljava/lang/Number;)D
+ public static final fun miles (Ljava/lang/Number;)D
+ public static final fun toDistanceString (D)Ljava/lang/String;
+ public static final fun toDistanceString (DLandroid/content/res/Resources;)Ljava/lang/String;
+ public static final fun toDistanceString (DLandroid/content/res/Resources;Lcom/google/maps/android/UnitsConverter;)Ljava/lang/String;
+ public static final fun toDistanceString (DLcom/google/maps/android/UnitsConverter;)Ljava/lang/String;
+ public static final fun toDistanceString (DLcom/google/maps/android/UnitsConverter;Lcom/google/maps/android/NumberFormatSymbols;)Ljava/lang/String;
+ public static final fun toDistanceString (DLjava/util/Locale;)Ljava/lang/String;
+ public static final fun toDistanceString (DLjava/util/Locale;Lcom/google/maps/android/UnitsConverter;)Ljava/lang/String;
+ public static synthetic fun toDistanceString$default (DLandroid/content/res/Resources;Lcom/google/maps/android/UnitsConverter;ILjava/lang/Object;)Ljava/lang/String;
+ public static synthetic fun toDistanceString$default (DLcom/google/maps/android/UnitsConverter;Lcom/google/maps/android/NumberFormatSymbols;ILjava/lang/Object;)Ljava/lang/String;
+ public static synthetic fun toDistanceString$default (DLjava/util/Locale;Lcom/google/maps/android/UnitsConverter;ILjava/lang/Object;)Ljava/lang/String;
+ public static final fun toElevationString (D)Ljava/lang/String;
+ public static final fun toElevationString (DLandroid/content/res/Resources;)Ljava/lang/String;
+ public static final fun toElevationString (DLandroid/content/res/Resources;Lcom/google/maps/android/UnitsConverter;)Ljava/lang/String;
+ public static final fun toElevationString (DLcom/google/maps/android/UnitsConverter;)Ljava/lang/String;
+ public static final fun toElevationString (DLcom/google/maps/android/UnitsConverter;Lcom/google/maps/android/NumberFormatSymbols;)Ljava/lang/String;
+ public static final fun toElevationString (DLjava/util/Locale;)Ljava/lang/String;
+ public static final fun toElevationString (DLjava/util/Locale;Lcom/google/maps/android/UnitsConverter;)Ljava/lang/String;
+ public static synthetic fun toElevationString$default (DLandroid/content/res/Resources;Lcom/google/maps/android/UnitsConverter;ILjava/lang/Object;)Ljava/lang/String;
+ public static synthetic fun toElevationString$default (DLcom/google/maps/android/UnitsConverter;Lcom/google/maps/android/NumberFormatSymbols;ILjava/lang/Object;)Ljava/lang/String;
+ public static synthetic fun toElevationString$default (DLjava/util/Locale;Lcom/google/maps/android/UnitsConverter;ILjava/lang/Object;)Ljava/lang/String;
+}
+
+public abstract class com/google/maps/android/UnitsConverter {
+ public static final field Companion Lcom/google/maps/android/UnitsConverter$Companion;
+ public fun ()V
+ public static final fun create (Lcom/google/maps/android/MeasurementFormatter;Lcom/google/maps/android/MeasurementFormatter;)Lcom/google/maps/android/UnitsConverter;
+ public static final fun create (Lcom/google/maps/android/MeasurementFormatter;Lcom/google/maps/android/UnitsConverter;)Lcom/google/maps/android/UnitsConverter;
+ public static final fun create (Lcom/google/maps/android/UnitSystem;Lcom/google/maps/android/UnitSystem;)Lcom/google/maps/android/UnitsConverter;
+ public static final fun create (Lcom/google/maps/android/UnitsConverter;Lcom/google/maps/android/MeasurementFormatter;)Lcom/google/maps/android/UnitsConverter;
+ public static final fun create (Lcom/google/maps/android/UnitsConverter;Lcom/google/maps/android/UnitsConverter;)Lcom/google/maps/android/UnitsConverter;
+ public final fun toDistanceString (D)Ljava/lang/String;
+ public final fun toDistanceString (DLcom/google/maps/android/NumberFormatSymbols;)Ljava/lang/String;
+ public final fun toDistanceString (DLjava/util/Locale;)Ljava/lang/String;
+ public final fun toDistanceString (Landroid/content/res/Resources;D)Ljava/lang/String;
+ public static synthetic fun toDistanceString$default (Lcom/google/maps/android/UnitsConverter;DLjava/util/Locale;ILjava/lang/Object;)Ljava/lang/String;
+ public fun toDistanceString-o-ZRCEM (Landroid/content/res/Resources;D)Ljava/lang/String;
+ public abstract fun toDistanceString-s1HjgZ8 (DLcom/google/maps/android/NumberFormatSymbols;)Ljava/lang/String;
+ public final fun toDistanceString-s1HjgZ8 (DLjava/util/Locale;)Ljava/lang/String;
+ public static synthetic fun toDistanceString-s1HjgZ8$default (Lcom/google/maps/android/UnitsConverter;DLjava/util/Locale;ILjava/lang/Object;)Ljava/lang/String;
+ public final fun toElevationString (D)Ljava/lang/String;
+ public final fun toElevationString (DLcom/google/maps/android/NumberFormatSymbols;)Ljava/lang/String;
+ public final fun toElevationString (DLjava/util/Locale;)Ljava/lang/String;
+ public final fun toElevationString (Landroid/content/res/Resources;D)Ljava/lang/String;
+ public static synthetic fun toElevationString$default (Lcom/google/maps/android/UnitsConverter;DLjava/util/Locale;ILjava/lang/Object;)Ljava/lang/String;
+ public fun toElevationString-o-ZRCEM (Landroid/content/res/Resources;D)Ljava/lang/String;
+ public abstract fun toElevationString-s1HjgZ8 (DLcom/google/maps/android/NumberFormatSymbols;)Ljava/lang/String;
+ public final fun toElevationString-s1HjgZ8 (DLjava/util/Locale;)Ljava/lang/String;
+ public static synthetic fun toElevationString-s1HjgZ8$default (Lcom/google/maps/android/UnitsConverter;DLjava/util/Locale;ILjava/lang/Object;)Ljava/lang/String;
+ public final fun withDistanceConverter (Lcom/google/maps/android/UnitsConverter;)Lcom/google/maps/android/UnitsConverter;
+ public final fun withDistanceFormatter (Lcom/google/maps/android/MeasurementFormatter;)Lcom/google/maps/android/UnitsConverter;
+ public final fun withDistanceUnits (Lcom/google/maps/android/UnitSystem;)Lcom/google/maps/android/UnitsConverter;
+ public final fun withElevationConverter (Lcom/google/maps/android/UnitsConverter;)Lcom/google/maps/android/UnitsConverter;
+ public final fun withElevationFormatter (Lcom/google/maps/android/MeasurementFormatter;)Lcom/google/maps/android/UnitsConverter;
+ public final fun withElevationUnits (Lcom/google/maps/android/UnitSystem;)Lcom/google/maps/android/UnitsConverter;
+}
+
+public final class com/google/maps/android/UnitsConverter$Companion {
+ public final fun create (Lcom/google/maps/android/MeasurementFormatter;Lcom/google/maps/android/MeasurementFormatter;)Lcom/google/maps/android/UnitsConverter;
+ public final fun create (Lcom/google/maps/android/MeasurementFormatter;Lcom/google/maps/android/UnitsConverter;)Lcom/google/maps/android/UnitsConverter;
+ public final fun create (Lcom/google/maps/android/UnitSystem;Lcom/google/maps/android/UnitSystem;)Lcom/google/maps/android/UnitsConverter;
+ public final fun create (Lcom/google/maps/android/UnitsConverter;Lcom/google/maps/android/MeasurementFormatter;)Lcom/google/maps/android/UnitsConverter;
+ public final fun create (Lcom/google/maps/android/UnitsConverter;Lcom/google/maps/android/UnitsConverter;)Lcom/google/maps/android/UnitsConverter;
+}
+
public class com/google/maps/android/collections/CircleManager : com/google/maps/android/collections/MapObjectManager, com/google/android/gms/maps/GoogleMap$OnCircleClickListener {
public fun (Lcom/google/android/gms/maps/GoogleMap;)V
public fun newCollection ()Lcom/google/maps/android/collections/CircleManager$Collection;
diff --git a/library/src/main/java/com/google/maps/android/Units.kt b/library/src/main/java/com/google/maps/android/Units.kt
new file mode 100644
index 000000000..509a8c66c
--- /dev/null
+++ b/library/src/main/java/com/google/maps/android/Units.kt
@@ -0,0 +1,1246 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+@file:JvmName("Units")
+
+package com.google.maps.android
+
+import android.content.res.Resources
+import androidx.core.os.ConfigurationCompat
+import java.text.DecimalFormat
+import java.text.DecimalFormatSymbols
+import java.text.NumberFormat
+import java.util.Locale
+import kotlin.math.abs
+import kotlin.math.roundToLong
+
+// =================================================================================================
+// § 1. Physical Conversion Constants
+// =================================================================================================
+//
+// By international agreement (1959 International Yard and Pound Agreement), one international foot
+// is defined as exactly 0.3048 meters (12 inches * 2.54 cm/inch / 100 cm/meter).
+// All derived imperial-to-metric and metric-to-imperial conversion constants below are grounded in
+// this exact definition so that round-trip conversions (e.g., `1.miles.inMiles`, `5280.feet.inMiles`)
+// remain mathematically consistent.
+
+// [START maps_android_utils_units_constants]
+/** Exact number of meters in one international foot (`0.3048`). */
+public const val METERS_PER_FOOT: Double = 0.3048
+
+/** Number of meters in one kilometer (`1,000`). */
+public const val METERS_PER_KILOMETER: Int = 1_000
+
+/** Number of international feet in one meter (`~3.280839895013123`). */
+public const val FEET_PER_METER: Double = 1.0 / METERS_PER_FOOT
+
+/** Number of feet in one statute mile (`5,280`). */
+public const val FEET_PER_MILE: Int = 5_280
+
+/** Exact number of meters in one statute mile (`1,609.344`). */
+public const val METERS_PER_MILE: Double = FEET_PER_MILE * METERS_PER_FOOT
+
+/** Number of statute miles in one meter (`~0.000621371192237334`). */
+public const val MILES_PER_METER: Double = 1.0 / METERS_PER_MILE
+// [END maps_android_utils_units_constants]
+
+// =================================================================================================
+// § 2. Type-Safe Length Representation: The `Meters` Value Class
+// =================================================================================================
+//
+// Geographical calculations in the Maps SDK and `SphericalUtil` produce raw `Double` values in
+// meters, whereas UI components and domain models frequently mix feet, miles, meters, and
+// kilometers. Wrapping scalar measurements in an inline `Meters` value class eliminates unit
+// confusion at compile time without incurring heap allocation overhead in Kotlin code.
+
+// [START maps_android_utils_units_meters]
+
+/**
+ * An inline value class representing a length, distance, or elevation measurement in meters.
+ *
+ * Construct instances using the [Number] extension properties ([meters], [kilometers], [feet],
+ * [miles]) and convert back to raw scalar units via [inMeters], [inKilometers], [inFeet], and
+ * [inMiles].
+ *
+ * @property value The underlying measurement in meters.
+ */
+@JvmInline
+public value class Meters(public val value: Double) : Comparable {
+ override fun compareTo(other: Meters): Int = value.compareTo(other.value)
+
+ /** Adds another [Meters] measurement to this measurement. */
+ public operator fun plus(other: Meters): Meters = Meters(value = this.value + other.value)
+
+ /** Subtracts another [Meters] measurement from this measurement. */
+ public operator fun minus(other: Meters): Meters = Meters(value = this.value - other.value)
+
+ /** Multiplies this measurement by a scalar [Number]. */
+ public operator fun times(scalar: Number): Meters = Meters(value = this.value * scalar.toDouble())
+
+ /** Divides this measurement by a scalar [Number]. */
+ public operator fun div(scalar: Number): Meters = Meters(value = this.value / scalar.toDouble())
+
+ /** Computes the dimensionless ratio between this measurement and [other]. */
+ public operator fun div(other: Meters): Double = this.value / other.value
+
+ /** Negates this measurement. */
+ public operator fun unaryMinus(): Meters = Meters(value = -this.value)
+}
+
+/** Creates a [Meters] measurement from a [Number] of meters. */
+@get:JvmName("meters")
+public inline val Number.meters: Meters
+ get() = Meters(value = this.toDouble())
+
+/** Creates a [Meters] measurement from a [Number] of kilometers. */
+@get:JvmName("kilometers")
+public inline val Number.kilometers: Meters
+ get() = Meters(value = this.toDouble() * METERS_PER_KILOMETER)
+
+/** Creates a [Meters] measurement from a [Number] of international feet. */
+@get:JvmName("feet")
+public inline val Number.feet: Meters
+ get() = Meters(value = this.toDouble() * METERS_PER_FOOT)
+
+/** Creates a [Meters] measurement from a [Number] of statute miles. */
+@get:JvmName("miles")
+public inline val Number.miles: Meters
+ get() = Meters(value = this.toDouble() * METERS_PER_MILE)
+
+/** Returns the value of this [Meters] measurement in meters as a [Double]. */
+@get:JvmName("inMeters")
+public inline val Meters.inMeters: Double
+ get() = value
+
+/** Returns the equivalent number of kilometers for this [Meters] measurement. */
+@get:JvmName("inKilometers")
+public inline val Meters.inKilometers: Double
+ get() = value / METERS_PER_KILOMETER
+
+/** Returns the equivalent number of international feet for this [Meters] measurement. */
+@get:JvmName("inFeet")
+public inline val Meters.inFeet: Double
+ get() = value * FEET_PER_METER
+
+/** Returns the equivalent number of statute miles for this [Meters] measurement. */
+@get:JvmName("inMiles")
+public inline val Meters.inMiles: Double
+ get() = value * MILES_PER_METER
+// [END maps_android_utils_units_meters]
+
+// =================================================================================================
+// § 3. Framework-Agnostic Unit System Selection
+// =================================================================================================
+//
+// Developers need a framework-agnostic way to determine whether to display measurements in Metric
+// or Imperial units without coupling business logic to Android `Context` or Jetpack Compose
+// `LocalConfiguration`. `UnitSystem` resolves the appropriate system from any `java.util.Locale`
+// (defaulting to `Locale.getDefault()`) or ISO 3166-1 alpha-2 country code, while allowing
+// applications to explicitly override the user's preference.
+
+// [START maps_android_utils_units_system]
+
+/**
+ * Represents a measurement system used to format distances and elevations.
+ */
+public enum class UnitSystem {
+ /**
+ * The Metric system (meters and kilometers for distances; meters for elevations).
+ */
+ METRIC,
+
+ /**
+ * The Imperial / US customary system (feet and miles for distances; feet for elevations).
+ */
+ IMPERIAL,
+ ;
+
+ /**
+ * Returns the [UnitsConverter] strategy corresponding to this [UnitSystem].
+ */
+ public val converter: UnitsConverter
+ get() = when (this) {
+ METRIC -> MetricUnitsConverter
+ IMPERIAL -> ImperialUnitsConverter
+ }
+
+ public companion object {
+ /**
+ * ISO 3166-1 alpha-2 country codes that default to [IMPERIAL] units for road distances:
+ * United States (`"US"`), Liberia (`"LR"`), Myanmar (`"MM"`), and United Kingdom (`"GB"`).
+ *
+ * Note: While the United Kingdom uses miles and feet for road distances (per the UK
+ * *Traffic Signs Regulations and General Directions*), UK topographic maps (Ordnance Survey)
+ * display elevations in meters. Therefore, `"GB"` resolves to [IMPERIAL] for distances
+ * ([distanceFromCountryCode]) and [METRIC] for elevations ([elevationFromCountryCode]), and
+ * [getUnitsConverter] for `"GB"` / [Locale.UK] automatically returns a converter that
+ * formats distances in feet/miles and elevations in meters.
+ */
+ public val IMPERIAL_COUNTRY_CODES: Set = setOf("US", "LR", "MM", "GB")
+
+ /**
+ * ISO 3166-1 alpha-2 country codes that default to [IMPERIAL] units for elevations:
+ * United States (`"US"`), Liberia (`"LR"`), and Myanmar (`"MM"`).
+ */
+ public val IMPERIAL_ELEVATION_COUNTRY_CODES: Set = setOf("US", "LR", "MM")
+
+ /**
+ * Returns the preferred distance [UnitSystem] for the given [locale].
+ *
+ * Note: For `"GB"` ([Locale.UK]), this returns [IMPERIAL] (the UK road-distance system),
+ * whereas [elevationFromLocale] returns [METRIC]. To obtain a [UnitsConverter] that
+ * automatically handles mixed distance/elevation locales like `"GB"`, use [getUnitsConverter].
+ *
+ * @param locale The [Locale] whose country code determines the unit system.
+ * @return [IMPERIAL] if the locale's country is in [IMPERIAL_COUNTRY_CODES], otherwise [METRIC].
+ */
+ @JvmStatic
+ @JvmOverloads
+ public fun fromLocale(locale: Locale = Locale.getDefault()): UnitSystem = distanceFromLocale(locale)
+
+ /**
+ * Returns the preferred distance [UnitSystem] for the given [locale] (defaulting to [Locale.getDefault]).
+ *
+ * @param locale The [Locale] whose country code determines the distance unit system.
+ * @return [IMPERIAL] for `"US"`, `"LR"`, `"MM"`, and `"GB"`, or [METRIC] otherwise.
+ */
+ @JvmStatic
+ @JvmOverloads
+ public fun distanceFromLocale(locale: Locale = Locale.getDefault()): UnitSystem =
+ distanceFromCountryCode(locale.country)
+
+ /**
+ * Returns the preferred elevation [UnitSystem] for the given [locale] (defaulting to [Locale.getDefault]).
+ *
+ * @param locale The [Locale] whose country code determines the elevation unit system.
+ * @return [IMPERIAL] for `"US"`, `"LR"`, and `"MM"`, or [METRIC] otherwise (including `"GB"`).
+ */
+ @JvmStatic
+ @JvmOverloads
+ public fun elevationFromLocale(locale: Locale = Locale.getDefault()): UnitSystem =
+ elevationFromCountryCode(locale.country)
+
+ /**
+ * Returns the preferred distance [UnitSystem] for the primary locale of the given Android [resources].
+ *
+ * @param resources The Android [Resources] whose configuration locale determines the unit system.
+ * @return [IMPERIAL] if the primary locale's country is in [IMPERIAL_COUNTRY_CODES], otherwise [METRIC].
+ */
+ @JvmStatic
+ public fun fromResources(resources: Resources): UnitSystem = distanceFromResources(resources)
+
+ /**
+ * Returns the preferred distance [UnitSystem] for the primary locale of the given Android [resources].
+ *
+ * @param resources The Android [Resources] whose configuration locale determines the distance unit system.
+ * @return [IMPERIAL] for `"US"`, `"LR"`, `"MM"`, and `"GB"`, or [METRIC] otherwise.
+ */
+ @JvmStatic
+ public fun distanceFromResources(resources: Resources): UnitSystem =
+ distanceFromLocale(primaryLocale(resources))
+
+ /**
+ * Returns the preferred elevation [UnitSystem] for the primary locale of the given Android [resources].
+ *
+ * @param resources The Android [Resources] whose configuration locale determines the elevation unit system.
+ * @return [IMPERIAL] for `"US"`, `"LR"`, and `"MM"`, or [METRIC] otherwise (including `"GB"`).
+ */
+ @JvmStatic
+ public fun elevationFromResources(resources: Resources): UnitSystem =
+ elevationFromLocale(primaryLocale(resources))
+
+ /**
+ * Returns the preferred distance [UnitSystem] for the given ISO 3166-1 alpha-2 [countryCode].
+ *
+ * Comparison is case-insensitive. If [countryCode] is `null`, blank, or not in
+ * [IMPERIAL_COUNTRY_CODES], [METRIC] is returned.
+ *
+ * @param countryCode The two-letter country code (e.g., `"US"`, `"GB"`, `"FR"`).
+ * @return The corresponding distance [UnitSystem].
+ */
+ @JvmStatic
+ public fun fromCountryCode(countryCode: String?): UnitSystem = distanceFromCountryCode(countryCode)
+
+ /**
+ * Returns the preferred distance [UnitSystem] for the given ISO 3166-1 alpha-2 [countryCode].
+ *
+ * @param countryCode The two-letter country code (e.g., `"US"`, `"GB"`, `"FR"`).
+ * @return [IMPERIAL] if [countryCode] is in [IMPERIAL_COUNTRY_CODES], otherwise [METRIC].
+ */
+ @JvmStatic
+ public fun distanceFromCountryCode(countryCode: String?): UnitSystem {
+ if (countryCode.isNullOrBlank()) return METRIC
+ return if (countryCode.uppercase(Locale.ROOT) in IMPERIAL_COUNTRY_CODES) {
+ IMPERIAL
+ } else {
+ METRIC
+ }
+ }
+
+ /**
+ * Returns the preferred elevation [UnitSystem] for the given ISO 3166-1 alpha-2 [countryCode].
+ *
+ * @param countryCode The two-letter country code (e.g., `"US"`, `"GB"`, `"FR"`).
+ * @return [IMPERIAL] if [countryCode] is in [IMPERIAL_ELEVATION_COUNTRY_CODES], otherwise [METRIC].
+ */
+ @JvmStatic
+ public fun elevationFromCountryCode(countryCode: String?): UnitSystem {
+ if (countryCode.isNullOrBlank()) return METRIC
+ return if (countryCode.uppercase(Locale.ROOT) in IMPERIAL_ELEVATION_COUNTRY_CODES) {
+ IMPERIAL
+ } else {
+ METRIC
+ }
+ }
+ }
+}
+// [END maps_android_utils_units_system]
+
+/**
+ * Platform-agnostic symbols and grouping rules for formatting numeric measurements.
+ *
+ * Separating locale symbol resolution from the core number formatting algorithm allows distance
+ * and elevation formatting to run in pure Kotlin (`kotlin.math` and `StringBuilder`) without
+ * relying on `java.util.Formatter` (`String.format`), keeping the formatting logic compatible
+ * with Kotlin Multiplatform (`commonMain`).
+ *
+ * @property groupingSeparator Character used to separate thousands groups (e.g., `','` in `en-US`, `'.'` in `de-DE`).
+ * @property decimalSeparator Character used to separate integer and fractional parts (e.g., `'.'` in `en-US`, `','` in `de-DE`).
+ * @property groupingSize Number of digits in each group to the left of the decimal point (typically `3`, or `<= 0` to disable grouping).
+ */
+public data class NumberFormatSymbols(
+ public val groupingSeparator: Char = ',',
+ public val decimalSeparator: Char = '.',
+ public val groupingSize: Int = 3,
+) {
+ /**
+ * Formats [value] rounded to [decimals] fractional digits with thousands grouping according to
+ * this [NumberFormatSymbols] instance using pure Kotlin integer arithmetic (`roundToLong`, `/`,
+ * `%`) and `buildString`, avoiding any dependency on `java.util.Formatter` (`String.format`).
+ *
+ * @param value The numeric value to format.
+ * @param decimals Number of digits after the decimal separator (must be `>= 0`; defaults to `0`).
+ * @return The formatted numeric string without a unit suffix (e.g., `"1,235"`, `"1,234.50"`).
+ * @throws IllegalArgumentException if [decimals] is negative.
+ */
+ @JvmOverloads
+ public fun formatFixedDecimal(value: Double, decimals: Int = 0): String =
+ formatFixedDecimal(value = value, decimals = decimals, symbols = this)
+
+ /**
+ * Formats a scalar distance [value] (in large units such as kilometers or miles) using logical
+ * precision based on order of magnitude and this [NumberFormatSymbols] instance:
+ * - Values `< 10` (when rounded to 1 decimal place) are formatted with `1` decimal digit,
+ * keeping the trailing `.0` (e.g., `"0.3"`, `"1.0"`, `"2.5"`, `"9.9"`).
+ * - Values `>= 10` are formatted as whole integers with `0` decimal digits and thousands
+ * grouping (e.g., `"10"`, `"1,250"`).
+ *
+ * @param value The numeric value in target units (e.g., kilometers or miles).
+ * @return The formatted numeric string without a unit suffix.
+ */
+ public fun formatDistanceValue(value: Double): String = formatDistanceValue(value = value, symbols = this)
+
+ public companion object {
+ /**
+ * Standard English number formatting symbols (`','` thousands separator, `'.'` decimal
+ * separator, group size `3`).
+ */
+ @JvmField
+ public val DEFAULT: NumberFormatSymbols = NumberFormatSymbols()
+
+ /**
+ * Resolves the [NumberFormatSymbols] for the given [locale] (defaulting to [Locale.getDefault]).
+ *
+ * @param locale The [Locale] whose grouping and decimal rules should be used.
+ * @return The corresponding [NumberFormatSymbols].
+ */
+ @JvmStatic
+ @JvmOverloads
+ public fun fromLocale(locale: Locale = Locale.getDefault()): NumberFormatSymbols {
+ val dfs = DecimalFormatSymbols.getInstance(locale)
+ val decimalFormat = NumberFormat.getNumberInstance(locale) as? DecimalFormat
+ val groupingSize = if (decimalFormat != null && decimalFormat.isGroupingUsed) {
+ decimalFormat.groupingSize
+ } else {
+ 0
+ }
+ return NumberFormatSymbols(
+ groupingSeparator = dfs.groupingSeparator,
+ decimalSeparator = dfs.decimalSeparator,
+ groupingSize = groupingSize,
+ )
+ }
+ }
+}
+
+// =================================================================================================
+// § 4. Logical Distance & Elevation Formatting
+// =================================================================================================
+//
+// When displaying distances in larger units (kilometers or miles), fixed-precision formatting
+// either loses detail at short range (e.g., truncating 1.5 km to "1 km") or introduces noisy
+// fractional digits at long range (e.g., "142.3 km").
+//
+// To maintain at least two significant digits across orders of magnitude:
+// 1. Small units (`m` below 1 km, `ft` below 0.25 mi, and all elevations) are formatted as whole
+// integers (`0` decimal places, e.g., `"450 m"`, `"528 ft"`, `"14,440 ft"`).
+// 2. Large units (`km` or `mi`) under 10 units are formatted to `1` decimal place, keeping the
+// trailing `.0` (e.g., `"0.3 mi"`, `"1.0 km"`, `"2.5 mi"`).
+// 3. Large units (`km` or `mi`) of 10 units or greater are rounded to whole integers (`0` decimal
+// places, e.g., `"10 km"`, `"12 mi"`).
+
+/**
+ * Functional (SAM) interface for formatting a measurement in [meters] into a localized string
+ * using the provided [symbols].
+ *
+ * Used by [UnitsConverter.create] to allow Java and Kotlin callers to supply custom distance or
+ * elevation formatters (or method references such as `MetricUnitsConverter.INSTANCE::toElevationString`).
+ */
+public fun interface MeasurementFormatter {
+ /**
+ * Formats a measurement of [meters] (in raw meters) using [symbols].
+ *
+ * @param meters The measurement value in meters.
+ * @param symbols The [NumberFormatSymbols] used for grouping and decimal separators.
+ * @return The formatted measurement string with units.
+ */
+ public fun format(meters: Double, symbols: NumberFormatSymbols): String
+}
+
+/**
+ * Strategy for converting and formatting [Meters] measurements into human-readable distance and
+ * elevation strings.
+ *
+ * Obtain a standard instance via [getUnitsConverter], [UnitSystem.converter], [MetricUnitsConverter],
+ * or [ImperialUnitsConverter], or construct a composite/custom converter via [UnitsConverter] (Kotlin)
+ * or [UnitsConverter.create] (Java/Kotlin).
+ */
+public abstract class UnitsConverter {
+ /**
+ * Formats a distance in [meters] into a human-readable string with units using pure Kotlin
+ * number formatting and the given [symbols].
+ *
+ * @param meters The distance to format.
+ * @param symbols The [NumberFormatSymbols] used for grouping and decimal separators.
+ * @return The formatted distance string (e.g., `"450 m"`, `"2.0 km"`, `"10 km"`, `"528 ft"`, `"2.5 mi"`).
+ */
+ public abstract fun toDistanceString(meters: Meters, symbols: NumberFormatSymbols): String
+
+ /**
+ * Formats a distance in [meters] (as a raw [Double]) using the given [symbols].
+ *
+ * @param meters The distance in meters to format.
+ * @param symbols The [NumberFormatSymbols] used for grouping and decimal separators.
+ * @return The formatted distance string.
+ */
+ public fun toDistanceString(meters: Double, symbols: NumberFormatSymbols): String =
+ toDistanceString(Meters(meters), symbols)
+
+ /**
+ * Formats a distance in [meters] into a localized string with units.
+ *
+ * @param meters The distance to format.
+ * @param locale The [Locale] used for number formatting rules (defaults to [Locale.getDefault]).
+ * @return The formatted distance string (e.g., `"450 m"`, `"2.0 km"`, `"10 km"`, `"528 ft"`, `"2.5 mi"`).
+ */
+ public fun toDistanceString(meters: Meters, locale: Locale = Locale.getDefault()): String =
+ toDistanceString(meters, NumberFormatSymbols.fromLocale(locale))
+
+ /**
+ * Formats a distance in [meters] (as a raw [Double]) into a localized string with units.
+ *
+ * @param meters The distance in meters to format.
+ * @param locale The [Locale] used for number formatting rules (defaults to [Locale.getDefault]).
+ * @return The formatted distance string.
+ */
+ @JvmOverloads
+ public fun toDistanceString(meters: Double, locale: Locale = Locale.getDefault()): String =
+ toDistanceString(Meters(meters), NumberFormatSymbols.fromLocale(locale))
+
+ /**
+ * Formats a distance in [meters] into a localized string using the translated unit templates
+ * (`R.string.amu_in_*`) and primary locale of the given Android [resources].
+ *
+ * @param resources The Android [Resources] used to resolve localized unit templates and number formatting.
+ * @param meters The distance to format.
+ * @return The formatted distance string (e.g., `"450 m"`, `"2.0 km"`, `"528 フィート"`, `"2,5 ми"`).
+ */
+ public open fun toDistanceString(resources: Resources, meters: Meters): String =
+ toDistanceString(meters, NumberFormatSymbols.fromLocale(primaryLocale(resources)))
+
+ /**
+ * Formats a distance in [meters] (as a raw [Double]) into a localized string using the
+ * translated unit templates (`R.string.amu_in_*`) and primary locale of [resources].
+ *
+ * @param resources The Android [Resources] used to resolve localized unit templates and number formatting.
+ * @param meters The distance in meters to format.
+ * @return The formatted distance string.
+ */
+ public fun toDistanceString(resources: Resources, meters: Double): String =
+ toDistanceString(resources, Meters(meters))
+
+ /**
+ * Formats an elevation in [meters] into a human-readable string with units using pure Kotlin
+ * number formatting and the given [symbols].
+ *
+ * @param meters The elevation to format.
+ * @param symbols The [NumberFormatSymbols] used for grouping and decimal separators.
+ * @return The formatted elevation string (e.g., `"4,401 m"` or `"14,440 ft"`).
+ */
+ public abstract fun toElevationString(meters: Meters, symbols: NumberFormatSymbols): String
+
+ /**
+ * Formats an elevation in [meters] (as a raw [Double]) using the given [symbols].
+ *
+ * @param meters The elevation in meters to format.
+ * @param symbols The [NumberFormatSymbols] used for grouping and decimal separators.
+ * @return The formatted elevation string.
+ */
+ public fun toElevationString(meters: Double, symbols: NumberFormatSymbols): String =
+ toElevationString(Meters(meters), symbols)
+
+ /**
+ * Formats an elevation in [meters] into a localized string with units.
+ *
+ * @param meters The elevation to format.
+ * @param locale The [Locale] used for number formatting rules (defaults to [Locale.getDefault]).
+ * @return The formatted elevation string (e.g., `"4,401 m"` or `"14,440 ft"`).
+ */
+ public fun toElevationString(meters: Meters, locale: Locale = Locale.getDefault()): String =
+ toElevationString(meters, NumberFormatSymbols.fromLocale(locale))
+
+ /**
+ * Formats an elevation in [meters] (as a raw [Double]) into a localized string with units.
+ *
+ * @param meters The elevation in meters to format.
+ * @param locale The [Locale] used for number formatting rules (defaults to [Locale.getDefault]).
+ * @return The formatted elevation string.
+ */
+ @JvmOverloads
+ public fun toElevationString(meters: Double, locale: Locale = Locale.getDefault()): String =
+ toElevationString(Meters(meters), NumberFormatSymbols.fromLocale(locale))
+
+ /**
+ * Formats an elevation in [meters] into a localized string using the translated unit templates
+ * (`R.string.amu_in_*`) and primary locale of the given Android [resources].
+ *
+ * @param resources The Android [Resources] used to resolve localized unit templates and number formatting.
+ * @param meters The elevation to format.
+ * @return The formatted elevation string (e.g., `"4,401 m"`, `"14,440 ft"`, `"14,440 フィート"`).
+ */
+ public open fun toElevationString(resources: Resources, meters: Meters): String =
+ toElevationString(meters, NumberFormatSymbols.fromLocale(primaryLocale(resources)))
+
+ /**
+ * Formats an elevation in [meters] (as a raw [Double]) into a localized string using the
+ * translated unit templates (`R.string.amu_in_*`) and primary locale of [resources].
+ *
+ * @param resources The Android [Resources] used to resolve localized unit templates and number formatting.
+ * @param meters The elevation in meters to format.
+ * @return The formatted elevation string.
+ */
+ public fun toElevationString(resources: Resources, meters: Double): String =
+ toElevationString(resources, Meters(meters))
+
+ /**
+ * Returns a [UnitsConverter] that formats distances using [distanceUnits] while preserving
+ * this converter's elevation formatting.
+ *
+ * @param distanceUnits The preferred [UnitSystem] for distances ([UnitSystem.METRIC] or [UnitSystem.IMPERIAL]).
+ * @return A [UnitsConverter] with the updated distance unit system.
+ */
+ public fun withDistanceUnits(distanceUnits: UnitSystem): UnitsConverter =
+ withDistanceConverter(distanceUnits.converter)
+
+ /**
+ * Returns a [UnitsConverter] that formats elevations using [elevationUnits] while preserving
+ * this converter's distance formatting.
+ *
+ * @param elevationUnits The preferred [UnitSystem] for elevations ([UnitSystem.METRIC] or [UnitSystem.IMPERIAL]).
+ * @return A [UnitsConverter] with the updated elevation unit system.
+ */
+ public fun withElevationUnits(elevationUnits: UnitSystem): UnitsConverter =
+ withElevationConverter(elevationUnits.converter)
+
+ /**
+ * Returns a [UnitsConverter] that delegates distance formatting to [distanceConverter] while
+ * preserving this converter's elevation formatting.
+ *
+ * @param distanceConverter The converter to use for [toDistanceString].
+ * @return A composite [UnitsConverter].
+ */
+ public fun withDistanceConverter(distanceConverter: UnitsConverter): UnitsConverter =
+ create(distanceConverter = distanceConverter, elevationConverter = this)
+
+ /**
+ * Returns a [UnitsConverter] that delegates elevation formatting to [elevationConverter] while
+ * preserving this converter's distance formatting.
+ *
+ * @param elevationConverter The converter to use for [toElevationString].
+ * @return A composite [UnitsConverter].
+ */
+ public fun withElevationConverter(elevationConverter: UnitsConverter): UnitsConverter =
+ create(distanceConverter = this, elevationConverter = elevationConverter)
+
+ /**
+ * Returns a [UnitsConverter] that uses [distanceFormatter] for distances while preserving
+ * this converter's elevation formatting.
+ *
+ * @param distanceFormatter Custom formatter for distances.
+ * @return A composite [UnitsConverter].
+ */
+ public fun withDistanceFormatter(distanceFormatter: MeasurementFormatter): UnitsConverter =
+ create(distanceFormatter = distanceFormatter, elevationConverter = this)
+
+ /**
+ * Returns a [UnitsConverter] that uses [elevationFormatter] for elevations while preserving
+ * this converter's distance formatting.
+ *
+ * @param elevationFormatter Custom formatter for elevations.
+ * @return A composite [UnitsConverter].
+ */
+ public fun withElevationFormatter(elevationFormatter: MeasurementFormatter): UnitsConverter =
+ create(distanceConverter = this, elevationFormatter = elevationFormatter)
+
+ public companion object {
+ private val IMPERIAL_DISTANCE_METRIC_ELEVATION_CONVERTER: UnitsConverter by lazy(
+ LazyThreadSafetyMode.PUBLICATION,
+ ) {
+ CompositeUnitsConverter(
+ distanceFormatter = ImperialUnitsConverter::toDistanceString,
+ elevationFormatter = MetricUnitsConverter::toElevationString,
+ distanceResourcesFormatter = ImperialUnitsConverter::toDistanceString,
+ elevationResourcesFormatter = MetricUnitsConverter::toElevationString,
+ distanceDelegate = ImperialUnitsConverter,
+ elevationDelegate = MetricUnitsConverter,
+ )
+ }
+
+ private val METRIC_DISTANCE_IMPERIAL_ELEVATION_CONVERTER: UnitsConverter by lazy(
+ LazyThreadSafetyMode.PUBLICATION,
+ ) {
+ CompositeUnitsConverter(
+ distanceFormatter = MetricUnitsConverter::toDistanceString,
+ elevationFormatter = ImperialUnitsConverter::toElevationString,
+ distanceResourcesFormatter = MetricUnitsConverter::toDistanceString,
+ elevationResourcesFormatter = ImperialUnitsConverter::toElevationString,
+ distanceDelegate = MetricUnitsConverter,
+ elevationDelegate = ImperialUnitsConverter,
+ )
+ }
+
+ /**
+ * Creates a [UnitsConverter] using the specified [distanceUnits] and [elevationUnits].
+ *
+ * @param distanceUnits The [UnitSystem] to use for distances ([UnitSystem.METRIC] or [UnitSystem.IMPERIAL]).
+ * @param elevationUnits The [UnitSystem] to use for elevations ([UnitSystem.METRIC] or [UnitSystem.IMPERIAL]).
+ * @return The corresponding [UnitsConverter].
+ */
+ @JvmStatic
+ public fun create(distanceUnits: UnitSystem, elevationUnits: UnitSystem): UnitsConverter =
+ create(distanceUnits.converter, elevationUnits.converter)
+
+ /**
+ * Creates a composite [UnitsConverter] that delegates distance formatting to
+ * [distanceConverter] and elevation formatting to [elevationConverter].
+ *
+ * For example, to format road distances in Imperial units (`ft` / `mi`) and elevations in
+ * Metric units (`m`) for the UK:
+ * ```java
+ * UnitsConverter ukConverter = UnitsConverter.create(
+ * ImperialUnitsConverter.INSTANCE,
+ * MetricUnitsConverter.INSTANCE
+ * );
+ * ```
+ */
+ @JvmStatic
+ public fun create(distanceConverter: UnitsConverter, elevationConverter: UnitsConverter): UnitsConverter {
+ val resolvedDistance =
+ (distanceConverter as? CompositeUnitsConverter)?.distanceDelegate ?: distanceConverter
+ val resolvedElevation =
+ (elevationConverter as? CompositeUnitsConverter)?.elevationDelegate ?: elevationConverter
+ return when {
+ resolvedDistance === MetricUnitsConverter && resolvedElevation === MetricUnitsConverter ->
+ MetricUnitsConverter
+
+ resolvedDistance === ImperialUnitsConverter && resolvedElevation === ImperialUnitsConverter ->
+ ImperialUnitsConverter
+
+ resolvedDistance === ImperialUnitsConverter && resolvedElevation === MetricUnitsConverter ->
+ IMPERIAL_DISTANCE_METRIC_ELEVATION_CONVERTER
+
+ resolvedDistance === MetricUnitsConverter && resolvedElevation === ImperialUnitsConverter ->
+ METRIC_DISTANCE_IMPERIAL_ELEVATION_CONVERTER
+
+ else ->
+ CompositeUnitsConverter(
+ distanceFormatter = resolvedDistance::toDistanceString,
+ elevationFormatter = resolvedElevation::toElevationString,
+ distanceResourcesFormatter = resolvedDistance::toDistanceString,
+ elevationResourcesFormatter = resolvedElevation::toElevationString,
+ distanceDelegate = resolvedDistance,
+ elevationDelegate = resolvedElevation,
+ )
+ }
+ }
+
+ /**
+ * Creates a custom [UnitsConverter] using the supplied [distanceFormatter] and
+ * [elevationFormatter].
+ */
+ @JvmStatic
+ public fun create(
+ distanceFormatter: MeasurementFormatter,
+ elevationFormatter: MeasurementFormatter,
+ ): UnitsConverter = CompositeUnitsConverter(
+ distanceFormatter = { meters, symbols -> distanceFormatter.format(meters.value, symbols) },
+ elevationFormatter = { meters, symbols -> elevationFormatter.format(meters.value, symbols) },
+ )
+
+ /**
+ * Creates a composite [UnitsConverter] that delegates distance formatting to
+ * [distanceConverter] and uses [elevationFormatter] for elevations.
+ */
+ @JvmStatic
+ public fun create(
+ distanceConverter: UnitsConverter,
+ elevationFormatter: MeasurementFormatter,
+ ): UnitsConverter {
+ val resolvedDistance =
+ (distanceConverter as? CompositeUnitsConverter)?.distanceDelegate ?: distanceConverter
+ return CompositeUnitsConverter(
+ distanceFormatter = resolvedDistance::toDistanceString,
+ elevationFormatter = { meters, symbols -> elevationFormatter.format(meters.value, symbols) },
+ distanceResourcesFormatter = resolvedDistance::toDistanceString,
+ distanceDelegate = resolvedDistance,
+ )
+ }
+
+ /**
+ * Creates a composite [UnitsConverter] that uses [distanceFormatter] for distances and
+ * delegates elevation formatting to [elevationConverter].
+ */
+ @JvmStatic
+ public fun create(
+ distanceFormatter: MeasurementFormatter,
+ elevationConverter: UnitsConverter,
+ ): UnitsConverter {
+ val resolvedElevation =
+ (elevationConverter as? CompositeUnitsConverter)?.elevationDelegate ?: elevationConverter
+ return CompositeUnitsConverter(
+ distanceFormatter = { meters, symbols -> distanceFormatter.format(meters.value, symbols) },
+ elevationFormatter = resolvedElevation::toElevationString,
+ elevationResourcesFormatter = resolvedElevation::toElevationString,
+ elevationDelegate = resolvedElevation,
+ )
+ }
+ }
+}
+
+private class CompositeUnitsConverter(
+ private val distanceFormatter: (Meters, NumberFormatSymbols) -> String,
+ private val elevationFormatter: (Meters, NumberFormatSymbols) -> String,
+ private val distanceResourcesFormatter: ((Resources, Meters) -> String)? = null,
+ private val elevationResourcesFormatter: ((Resources, Meters) -> String)? = null,
+ internal val distanceDelegate: UnitsConverter? = null,
+ internal val elevationDelegate: UnitsConverter? = null,
+) : UnitsConverter() {
+ override fun toDistanceString(meters: Meters, symbols: NumberFormatSymbols): String =
+ distanceFormatter(meters, symbols)
+
+ override fun toDistanceString(resources: Resources, meters: Meters): String =
+ distanceResourcesFormatter?.invoke(resources, meters) ?: super.toDistanceString(resources, meters)
+
+ override fun toElevationString(meters: Meters, symbols: NumberFormatSymbols): String =
+ elevationFormatter(meters, symbols)
+
+ override fun toElevationString(resources: Resources, meters: Meters): String =
+ elevationResourcesFormatter?.invoke(resources, meters) ?: super.toElevationString(resources, meters)
+}
+
+// [START maps_android_utils_units_converters]
+
+/**
+ * Renders distance and elevation measurements in Imperial units (`ft` and `mi`).
+ *
+ * - **Distances** rounding to below `0.25` miles (`1,320` feet) are displayed in whole feet (`ft`).
+ * - **Distances** from `0.25` miles up to `< 10` miles are displayed in miles (`mi`) with `1`
+ * decimal digit (keeping trailing `.0`, e.g., `"0.3 mi"`, `"1.0 mi"`, `"2.5 mi"`).
+ * - **Distances** of `10` miles or greater are displayed in whole miles (`mi`, e.g., `"10 mi"`).
+ * - **Elevations** are always displayed in whole feet (`ft`).
+ */
+public object ImperialUnitsConverter : UnitsConverter() {
+ private const val THRESHOLD_FEET: Long = 1_320L
+
+ /** Threshold (`0.25` miles = `1,320` feet) at which imperial distances switch from feet to miles. */
+ @get:JvmName("getMilesThreshold")
+ public val MILES_THRESHOLD: Meters = 0.25.miles
+
+ /** The [UnitSystem] implemented by this converter ([UnitSystem.IMPERIAL]). */
+ public val unitSystem: UnitSystem = UnitSystem.IMPERIAL
+
+ override fun toDistanceString(meters: Meters, symbols: NumberFormatSymbols): String = formatDistance(
+ meters = meters,
+ symbols = symbols,
+ formatFeet = { "$it ft" },
+ formatMiles = { "$it mi" },
+ )
+
+ override fun toDistanceString(resources: Resources, meters: Meters): String = formatDistance(
+ meters = meters,
+ symbols = NumberFormatSymbols.fromLocale(primaryLocale(resources)),
+ formatFeet = { resources.getString(R.string.amu_in_feet, it) },
+ formatMiles = { resources.getString(R.string.amu_in_miles, it) },
+ )
+
+ override fun toElevationString(meters: Meters, symbols: NumberFormatSymbols): String =
+ "${symbols.formatFixedDecimal(meters.inFeet, decimals = 0)} ft"
+
+ override fun toElevationString(resources: Resources, meters: Meters): String {
+ val symbols = NumberFormatSymbols.fromLocale(primaryLocale(resources))
+ return resources.getString(R.string.amu_in_feet, symbols.formatFixedDecimal(meters.inFeet, decimals = 0))
+ }
+
+ private inline fun formatDistance(
+ meters: Meters,
+ symbols: NumberFormatSymbols,
+ formatFeet: (String) -> String,
+ formatMiles: (String) -> String,
+ ): String {
+ val feet = meters.inFeet
+ return if (abs(feet).roundToLong() < THRESHOLD_FEET) {
+ formatFeet(symbols.formatFixedDecimal(feet, decimals = 0))
+ } else {
+ val rawMiles = meters.inMiles
+ val miles =
+ if (abs(rawMiles) < 0.25) {
+ if (rawMiles < 0.0) -0.25 else 0.25
+ } else {
+ rawMiles
+ }
+ formatMiles(symbols.formatDistanceValue(miles))
+ }
+ }
+}
+
+/**
+ * Renders distance and elevation measurements in Metric units (`m` and `km`).
+ *
+ * - **Distances** rounding to below `1,000` meters are displayed in whole meters (`m`).
+ * - **Distances** from `1,000` meters (`1.0` km) up to `< 10` kilometers are displayed in
+ * kilometers (`km`) with `1` decimal digit (keeping trailing `.0`, e.g., `"1.0 km"`, `"2.5 km"`).
+ * - **Distances** of `10` kilometers or greater are displayed in whole kilometers (`km`, e.g., `"10 km"`).
+ * - **Elevations** are always displayed in whole meters (`m`).
+ */
+public object MetricUnitsConverter : UnitsConverter() {
+ /** Threshold (`1,000` meters = `1` kilometer) at which metric distances switch from meters to kilometers. */
+ @get:JvmName("getKilometersThreshold")
+ public val KILOMETERS_THRESHOLD: Meters = METERS_PER_KILOMETER.meters
+
+ /** The [UnitSystem] implemented by this converter ([UnitSystem.METRIC]). */
+ public val unitSystem: UnitSystem = UnitSystem.METRIC
+
+ override fun toDistanceString(meters: Meters, symbols: NumberFormatSymbols): String = formatDistance(
+ meters = meters,
+ symbols = symbols,
+ formatMeters = { "$it m" },
+ formatKilometers = { "$it km" },
+ )
+
+ override fun toDistanceString(resources: Resources, meters: Meters): String = formatDistance(
+ meters = meters,
+ symbols = NumberFormatSymbols.fromLocale(primaryLocale(resources)),
+ formatMeters = { resources.getString(R.string.amu_in_meters, it) },
+ formatKilometers = { resources.getString(R.string.amu_in_kilometers, it) },
+ )
+
+ override fun toElevationString(meters: Meters, symbols: NumberFormatSymbols): String =
+ "${symbols.formatFixedDecimal(meters.inMeters, decimals = 0)} m"
+
+ override fun toElevationString(resources: Resources, meters: Meters): String {
+ val symbols = NumberFormatSymbols.fromLocale(primaryLocale(resources))
+ return resources.getString(R.string.amu_in_meters, symbols.formatFixedDecimal(meters.inMeters, decimals = 0))
+ }
+
+ private inline fun formatDistance(
+ meters: Meters,
+ symbols: NumberFormatSymbols,
+ formatMeters: (String) -> String,
+ formatKilometers: (String) -> String,
+ ): String {
+ val metersValue = meters.inMeters
+ return if (abs(metersValue).roundToLong() < METERS_PER_KILOMETER) {
+ formatMeters(symbols.formatFixedDecimal(metersValue, decimals = 0))
+ } else {
+ formatKilometers(symbols.formatDistanceValue(meters.inKilometers))
+ }
+ }
+}
+// [END maps_android_utils_units_converters]
+
+// =================================================================================================
+// § 5. Top-Level Factory & Extension Functions
+// =================================================================================================
+
+/**
+ * Constructs a [UnitsConverter] using the specified [distanceUnits] and [elevationUnits].
+ *
+ * @param distanceUnits The [UnitSystem] to use for distances ([UnitSystem.METRIC] or [UnitSystem.IMPERIAL]).
+ * @param elevationUnits The [UnitSystem] to use for elevations ([UnitSystem.METRIC] or [UnitSystem.IMPERIAL]).
+ * @return The corresponding [UnitsConverter].
+ */
+public fun UnitsConverter(distanceUnits: UnitSystem, elevationUnits: UnitSystem): UnitsConverter =
+ UnitsConverter.create(distanceUnits, elevationUnits)
+
+/**
+ * Constructs a composite [UnitsConverter] that delegates distance formatting to
+ * [distanceConverter] and elevation formatting to [elevationConverter].
+ *
+ * Example (Imperial road distances with Metric elevations for the UK):
+ * ```kotlin
+ * val ukConverter = UnitsConverter(
+ * distanceConverter = ImperialUnitsConverter,
+ * elevationConverter = MetricUnitsConverter,
+ * )
+ * ```
+ *
+ * @param distanceConverter Converter used for [UnitsConverter.toDistanceString].
+ * @param elevationConverter Converter used for [UnitsConverter.toElevationString].
+ * @return A composite [UnitsConverter].
+ */
+public fun UnitsConverter(distanceConverter: UnitsConverter, elevationConverter: UnitsConverter): UnitsConverter =
+ UnitsConverter.create(distanceConverter, elevationConverter)
+
+/**
+ * Constructs a custom [UnitsConverter] using the provided [distanceFormatter] and
+ * [elevationFormatter] lambdas (or method references such as `MetricUnitsConverter::toElevationString`).
+ *
+ * Example:
+ * ```kotlin
+ * val customConverter = UnitsConverter(
+ * distanceFormatter = { meters, symbols ->
+ * "${symbols.formatFixedDecimal(meters.inKilometers, decimals = 2)} km"
+ * },
+ * elevationFormatter = MetricUnitsConverter::toElevationString,
+ * )
+ * ```
+ *
+ * @param distanceFormatter Lambda formatting a [Meters] distance with [NumberFormatSymbols].
+ * @param elevationFormatter Lambda formatting a [Meters] elevation with [NumberFormatSymbols].
+ * @return A custom [UnitsConverter].
+ */
+@JvmSynthetic
+public fun UnitsConverter(
+ distanceFormatter: (Meters, NumberFormatSymbols) -> String,
+ elevationFormatter: (Meters, NumberFormatSymbols) -> String,
+): UnitsConverter = CompositeUnitsConverter(
+ distanceFormatter = distanceFormatter,
+ elevationFormatter = elevationFormatter,
+)
+
+/**
+ * Constructs a composite [UnitsConverter] that delegates distance formatting to
+ * [distanceConverter] and uses [elevationFormatter] for elevations.
+ *
+ * @param distanceConverter Converter used for [UnitsConverter.toDistanceString].
+ * @param elevationFormatter Lambda formatting a [Meters] elevation with [NumberFormatSymbols].
+ * @return A composite [UnitsConverter].
+ */
+@JvmSynthetic
+public fun UnitsConverter(
+ distanceConverter: UnitsConverter,
+ elevationFormatter: (Meters, NumberFormatSymbols) -> String,
+): UnitsConverter {
+ val resolvedDistance = (distanceConverter as? CompositeUnitsConverter)?.distanceDelegate ?: distanceConverter
+ return CompositeUnitsConverter(
+ distanceFormatter = resolvedDistance::toDistanceString,
+ elevationFormatter = elevationFormatter,
+ distanceResourcesFormatter = resolvedDistance::toDistanceString,
+ distanceDelegate = resolvedDistance,
+ )
+}
+
+/**
+ * Constructs a composite [UnitsConverter] that uses [distanceFormatter] for distances and
+ * delegates elevation formatting to [elevationConverter].
+ *
+ * @param distanceFormatter Lambda formatting a [Meters] distance with [NumberFormatSymbols].
+ * @param elevationConverter Converter used for [UnitsConverter.toElevationString].
+ * @return A composite [UnitsConverter].
+ */
+@JvmSynthetic
+public fun UnitsConverter(
+ distanceFormatter: (Meters, NumberFormatSymbols) -> String,
+ elevationConverter: UnitsConverter,
+): UnitsConverter {
+ val resolvedElevation = (elevationConverter as? CompositeUnitsConverter)?.elevationDelegate ?: elevationConverter
+ return CompositeUnitsConverter(
+ distanceFormatter = distanceFormatter,
+ elevationFormatter = resolvedElevation::toElevationString,
+ elevationResourcesFormatter = resolvedElevation::toElevationString,
+ elevationDelegate = resolvedElevation,
+ )
+}
+
+/**
+ * Returns the appropriate [UnitsConverter] for the given [locale] (defaulting to [Locale.getDefault]),
+ * with optional overrides for [distanceUnits] or [elevationUnits].
+ *
+ * For `"GB"` ([Locale.UK]), distances default to [UnitSystem.IMPERIAL] (`ft` / `mi`) and elevations
+ * default to [UnitSystem.METRIC] (`m`).
+ *
+ * @param locale The [Locale] used to determine the default distance and elevation unit systems.
+ * @param distanceUnits Preferred [UnitSystem] for distances (defaults to [UnitSystem.distanceFromLocale]).
+ * @param elevationUnits Preferred [UnitSystem] for elevations (defaults to [UnitSystem.elevationFromLocale]).
+ * @return The resolved [UnitsConverter].
+ */
+@JvmOverloads
+public fun getUnitsConverter(
+ locale: Locale = Locale.getDefault(),
+ distanceUnits: UnitSystem = UnitSystem.distanceFromLocale(locale),
+ elevationUnits: UnitSystem = UnitSystem.elevationFromLocale(locale),
+): UnitsConverter = UnitsConverter.create(distanceUnits, elevationUnits)
+
+/**
+ * Returns the appropriate [UnitsConverter] for the primary locale of the given Android [resources],
+ * with optional overrides for [distanceUnits] or [elevationUnits].
+ *
+ * For `"GB"` ([Locale.UK]), distances default to [UnitSystem.IMPERIAL] (`ft` / `mi`) and elevations
+ * default to [UnitSystem.METRIC] (`m`).
+ *
+ * @param resources The Android [Resources] whose configuration locale determines the default unit systems.
+ * @param distanceUnits Preferred [UnitSystem] for distances (defaults to [UnitSystem.distanceFromResources]).
+ * @param elevationUnits Preferred [UnitSystem] for elevations (defaults to [UnitSystem.elevationFromResources]).
+ * @return The resolved [UnitsConverter].
+ */
+@JvmOverloads
+public fun getUnitsConverter(
+ resources: Resources,
+ distanceUnits: UnitSystem = UnitSystem.distanceFromResources(resources),
+ elevationUnits: UnitSystem = UnitSystem.elevationFromResources(resources),
+): UnitsConverter = UnitsConverter.create(distanceUnits, elevationUnits)
+
+/**
+ * Returns the appropriate [UnitsConverter] for the given ISO 3166-1 alpha-2 [countryCode],
+ * with optional overrides for [distanceUnits] or [elevationUnits].
+ *
+ * For `"GB"`, distances default to [UnitSystem.IMPERIAL] (`ft` / `mi`) and elevations default to
+ * [UnitSystem.METRIC] (`m`).
+ *
+ * @param countryCode The two-letter country code (case-insensitive), or `null`.
+ * @param distanceUnits Preferred [UnitSystem] for distances (defaults to [UnitSystem.distanceFromCountryCode]).
+ * @param elevationUnits Preferred [UnitSystem] for elevations (defaults to [UnitSystem.elevationFromCountryCode]).
+ * @return The resolved [UnitsConverter].
+ */
+@JvmOverloads
+public fun getUnitsConverter(
+ countryCode: String?,
+ distanceUnits: UnitSystem = UnitSystem.distanceFromCountryCode(countryCode),
+ elevationUnits: UnitSystem = UnitSystem.elevationFromCountryCode(countryCode),
+): UnitsConverter = UnitsConverter.create(distanceUnits, elevationUnits)
+
+/**
+ * Returns the [UnitsConverter] corresponding to the specified [unitSystem] for both distances
+ * and elevations.
+ *
+ * @param unitSystem The desired [UnitSystem] for both distances and elevations.
+ * @return [MetricUnitsConverter] or [ImperialUnitsConverter].
+ */
+public fun getUnitsConverter(unitSystem: UnitSystem): UnitsConverter = unitSystem.converter
+
+/**
+ * Formats a scalar distance [value] (in large units such as kilometers or miles) using logical
+ * precision based on order of magnitude and pure Kotlin arithmetic:
+ * - Values `< 10` (when rounded to 1 decimal place) are formatted with `1` decimal digit, keeping
+ * the trailing `.0` (e.g., `"0.3"`, `"1.0"`, `"2.5"`, `"9.9"`).
+ * - Values `>= 10` are formatted as whole integers with `0` decimal digits and thousands grouping
+ * according to [symbols] (e.g., `"10"`, `"1,250"`).
+ *
+ * @param value The numeric value in target units (e.g., kilometers or miles).
+ * @param symbols The [NumberFormatSymbols] used for grouping and decimal separators.
+ * @return The formatted numeric string without a unit suffix.
+ */
+public fun formatDistanceValue(value: Double, symbols: NumberFormatSymbols): String =
+ if (isUnderTenAtOneDecimal(value)) {
+ formatFixedDecimal(value, decimals = 1, symbols = symbols)
+ } else {
+ formatFixedDecimal(value, decimals = 0, symbols = symbols)
+ }
+
+/**
+ * Formats a scalar distance [value] (in large units such as kilometers or miles) using logical
+ * precision based on order of magnitude and the formatting rules of [locale]:
+ * - Values `< 10` (when rounded to 1 decimal place) are formatted with `1` decimal digit, keeping
+ * the trailing `.0` (e.g., `"0.3"`, `"1.0"`, `"2.5"`, `"9.9"`).
+ * - Values `>= 10` are formatted as whole integers with `0` decimal digits (e.g., `"10"`, `"42"`).
+ *
+ * @param value The numeric value in target units (e.g., kilometers or miles).
+ * @param locale The [Locale] whose grouping and decimal rules are applied (defaults to [Locale.getDefault]).
+ * @return The formatted numeric string without a unit suffix.
+ */
+@JvmOverloads
+public fun formatDistanceValue(value: Double, locale: Locale = Locale.getDefault()): String =
+ formatDistanceValue(value, NumberFormatSymbols.fromLocale(locale))
+
+/**
+ * Formats this [Meters] measurement as a human-readable distance string using [locale] and [converter].
+ *
+ * @param locale The [Locale] used for unit selection and number formatting (defaults to [Locale.getDefault]).
+ * @param converter The [UnitsConverter] to use (defaults to [getUnitsConverter] for [locale]).
+ * @return The formatted distance string.
+ */
+@JvmName("toDistanceString")
+@JvmOverloads
+public fun Meters.toDistanceString(
+ locale: Locale = Locale.getDefault(),
+ converter: UnitsConverter = getUnitsConverter(locale),
+): String = converter.toDistanceString(this, locale)
+
+/**
+ * Formats this [Meters] measurement as a human-readable distance string using the specified
+ * [converter] and [symbols].
+ *
+ * @param converter The [UnitsConverter] to use.
+ * @param symbols The [NumberFormatSymbols] used for grouping and decimal separators.
+ * @return The formatted distance string.
+ */
+@JvmName("toDistanceString")
+@JvmOverloads
+public fun Meters.toDistanceString(
+ converter: UnitsConverter,
+ symbols: NumberFormatSymbols = NumberFormatSymbols.fromLocale(),
+): String = converter.toDistanceString(this, symbols)
+
+/**
+ * Formats this [Meters] measurement as a localized distance string using the translated unit
+ * templates (`R.string.amu_in_*`) and primary locale of [resources].
+ *
+ * @param resources The Android [Resources] used for unit selection, number formatting, and localized unit labels.
+ * @param converter The [UnitsConverter] to use (defaults to [getUnitsConverter] for [resources]).
+ * @return The formatted distance string.
+ */
+@JvmName("toDistanceString")
+@JvmOverloads
+public fun Meters.toDistanceString(
+ resources: Resources,
+ converter: UnitsConverter = getUnitsConverter(resources),
+): String = converter.toDistanceString(resources, this)
+
+/**
+ * Formats this [Meters] measurement as a human-readable elevation string using [locale] and [converter].
+ *
+ * @param locale The [Locale] used for unit selection and number formatting (defaults to [Locale.getDefault]).
+ * @param converter The [UnitsConverter] to use (defaults to [getUnitsConverter] for [locale]).
+ * @return The formatted elevation string.
+ */
+@JvmName("toElevationString")
+@JvmOverloads
+public fun Meters.toElevationString(
+ locale: Locale = Locale.getDefault(),
+ converter: UnitsConverter = getUnitsConverter(locale),
+): String = converter.toElevationString(this, locale)
+
+/**
+ * Formats this [Meters] measurement as a human-readable elevation string using the specified
+ * [converter] and [symbols].
+ *
+ * @param converter The [UnitsConverter] to use.
+ * @param symbols The [NumberFormatSymbols] used for grouping and decimal separators.
+ * @return The formatted elevation string.
+ */
+@JvmName("toElevationString")
+@JvmOverloads
+public fun Meters.toElevationString(
+ converter: UnitsConverter,
+ symbols: NumberFormatSymbols = NumberFormatSymbols.fromLocale(),
+): String = converter.toElevationString(this, symbols)
+
+/**
+ * Formats this [Meters] measurement as a localized elevation string using the translated unit
+ * templates (`R.string.amu_in_*`) and primary locale of [resources].
+ *
+ * @param resources The Android [Resources] used for unit selection, number formatting, and localized unit labels.
+ * @param converter The [UnitsConverter] to use (defaults to [getUnitsConverter] for [resources]).
+ * @return The formatted elevation string.
+ */
+@JvmName("toElevationString")
+@JvmOverloads
+public fun Meters.toElevationString(
+ resources: Resources,
+ converter: UnitsConverter = getUnitsConverter(resources),
+): String = converter.toElevationString(resources, this)
+
+// =================================================================================================
+// § 6. Internal Pure-Kotlin Formatting Helpers
+// =================================================================================================
+
+private fun primaryLocale(resources: Resources): Locale =
+ ConfigurationCompat.getLocales(resources.configuration)[0] ?: Locale.getDefault()
+
+// [START maps_android_utils_units_pure_kotlin_formatter]
+
+/**
+ * Returns `true` if `abs(value)` rounds to less than `10.0` at one decimal place (`< 9.95`).
+ * Values in `[9.95, 10.0)` round to `10` at one decimal place and therefore use the whole-number
+ * format (`"10"`) rather than `"10.0"`.
+ */
+private fun isUnderTenAtOneDecimal(value: Double): Boolean = (abs(value) * 10.0).roundToLong() < 100L
+
+/**
+ * Formats [value] with [decimals] fractional digits and locale-specific thousands grouping using pure
+ * `kotlin.math` integer arithmetic (`roundToLong`, `/`, `%`) and `buildString`, avoiding any
+ * dependency on `java.util.Formatter` (`String.format`).
+ */
+internal fun formatFixedDecimal(value: Double, decimals: Int, symbols: NumberFormatSymbols): String {
+ require(decimals >= 0) { "decimals must be non-negative, was $decimals" }
+ var factor = 1L
+ repeat(decimals) { factor *= 10L }
+ val scaled = (abs(value) * factor).roundToLong()
+ val isNegative = value < 0.0 && scaled > 0L
+ val integerPart = scaled / factor
+ val groupedInteger = formatGroupedInteger(
+ value = integerPart,
+ groupingSeparator = symbols.groupingSeparator,
+ groupingSize = symbols.groupingSize,
+ )
+ return buildString {
+ if (isNegative) append('-')
+ append(groupedInteger)
+ if (decimals > 0) {
+ append(symbols.decimalSeparator)
+ append((scaled % factor).toString().padStart(decimals, '0'))
+ }
+ }
+}
+
+/**
+ * Formats a non-negative integer [value] with [groupingSeparator] inserted every [groupingSize]
+ * digits from the right.
+ */
+internal fun formatGroupedInteger(value: Long, groupingSeparator: Char, groupingSize: Int): String {
+ val digits = value.toString()
+ if (groupingSize <= 0 || digits.length <= groupingSize) {
+ return digits
+ }
+ return buildString(digits.length + (digits.length - 1) / groupingSize) {
+ for (i in digits.indices) {
+ if (i > 0 && (digits.length - i) % groupingSize == 0) {
+ append(groupingSeparator)
+ }
+ append(digits[i])
+ }
+ }
+}
+// [END maps_android_utils_units_pure_kotlin_formatter]
diff --git a/library/src/main/res/values-ar/strings.xml b/library/src/main/res/values-ar/strings.xml
new file mode 100644
index 000000000..1ba42c5db
--- /dev/null
+++ b/library/src/main/res/values-ar/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+ %1$s قدم
+ %1$s ميل
+ %1$s م
+ %1$s كم
+
diff --git a/library/src/main/res/values-bg/strings.xml b/library/src/main/res/values-bg/strings.xml
new file mode 100644
index 000000000..333ad723b
--- /dev/null
+++ b/library/src/main/res/values-bg/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+ %1$s фута
+ %1$s мили
+ %1$s м
+ %1$s км
+
diff --git a/library/src/main/res/values-de/strings.xml b/library/src/main/res/values-de/strings.xml
new file mode 100644
index 000000000..d82f180a9
--- /dev/null
+++ b/library/src/main/res/values-de/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+ %1$s ft
+ %1$s mi
+ %1$s m
+ %1$s km
+
diff --git a/library/src/main/res/values-el/strings.xml b/library/src/main/res/values-el/strings.xml
new file mode 100644
index 000000000..3b7cf5a56
--- /dev/null
+++ b/library/src/main/res/values-el/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+ %1$s πόδ.
+ %1$s μίλ.
+ %1$s m
+ %1$s χλμ.
+
diff --git a/library/src/main/res/values-es/strings.xml b/library/src/main/res/values-es/strings.xml
new file mode 100644
index 000000000..d82f180a9
--- /dev/null
+++ b/library/src/main/res/values-es/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+ %1$s ft
+ %1$s mi
+ %1$s m
+ %1$s km
+
diff --git a/library/src/main/res/values-fa/strings.xml b/library/src/main/res/values-fa/strings.xml
new file mode 100644
index 000000000..2811dfe80
--- /dev/null
+++ b/library/src/main/res/values-fa/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+ %1$s فوت
+ %1$s مایل
+ %1$s متر
+ %1$s کیلومتر
+
diff --git a/library/src/main/res/values-fr/strings.xml b/library/src/main/res/values-fr/strings.xml
new file mode 100644
index 000000000..ffd7e89c0
--- /dev/null
+++ b/library/src/main/res/values-fr/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+ %1$s pi
+ %1$s mi
+ %1$s m
+ %1$s km
+
diff --git a/library/src/main/res/values-hi/strings.xml b/library/src/main/res/values-hi/strings.xml
new file mode 100644
index 000000000..6eabc12f3
--- /dev/null
+++ b/library/src/main/res/values-hi/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+ %1$s फ़ीट
+ %1$s मील
+ %1$s मी॰
+ %1$s कि॰मी॰
+
diff --git a/library/src/main/res/values-in/strings.xml b/library/src/main/res/values-in/strings.xml
new file mode 100644
index 000000000..d82f180a9
--- /dev/null
+++ b/library/src/main/res/values-in/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+ %1$s ft
+ %1$s mi
+ %1$s m
+ %1$s km
+
diff --git a/library/src/main/res/values-it/strings.xml b/library/src/main/res/values-it/strings.xml
new file mode 100644
index 000000000..d82f180a9
--- /dev/null
+++ b/library/src/main/res/values-it/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+ %1$s ft
+ %1$s mi
+ %1$s m
+ %1$s km
+
diff --git a/library/src/main/res/values-iw/strings.xml b/library/src/main/res/values-iw/strings.xml
new file mode 100644
index 000000000..e24c890c4
--- /dev/null
+++ b/library/src/main/res/values-iw/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+ %1$s רגל
+ %1$s מייל
+ %1$s מ׳
+ %1$s ק״מ
+
diff --git a/library/src/main/res/values-ja/strings.xml b/library/src/main/res/values-ja/strings.xml
new file mode 100644
index 000000000..6f09c1219
--- /dev/null
+++ b/library/src/main/res/values-ja/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+ %1$s フィート
+ %1$s マイル
+ %1$s m
+ %1$s km
+
diff --git a/library/src/main/res/values-ko/strings.xml b/library/src/main/res/values-ko/strings.xml
new file mode 100644
index 000000000..bdca1df67
--- /dev/null
+++ b/library/src/main/res/values-ko/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+ %1$s피트
+ %1$s마일
+ %1$s m
+ %1$s km
+
diff --git a/library/src/main/res/values-nl/strings.xml b/library/src/main/res/values-nl/strings.xml
new file mode 100644
index 000000000..d82f180a9
--- /dev/null
+++ b/library/src/main/res/values-nl/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+ %1$s ft
+ %1$s mi
+ %1$s m
+ %1$s km
+
diff --git a/library/src/main/res/values-pl/strings.xml b/library/src/main/res/values-pl/strings.xml
new file mode 100644
index 000000000..9a541d697
--- /dev/null
+++ b/library/src/main/res/values-pl/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+ %1$s st.
+ %1$s mi
+ %1$s m
+ %1$s km
+
diff --git a/library/src/main/res/values-pt/strings.xml b/library/src/main/res/values-pt/strings.xml
new file mode 100644
index 000000000..7532ea94f
--- /dev/null
+++ b/library/src/main/res/values-pt/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+ %1$s pés
+ %1$s mi
+ %1$s m
+ %1$s km
+
diff --git a/library/src/main/res/values-ru/strings.xml b/library/src/main/res/values-ru/strings.xml
new file mode 100644
index 000000000..045d2c15b
--- /dev/null
+++ b/library/src/main/res/values-ru/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+ %1$s фт
+ %1$s ми
+ %1$s м
+ %1$s км
+
diff --git a/library/src/main/res/values-th/strings.xml b/library/src/main/res/values-th/strings.xml
new file mode 100644
index 000000000..33e845816
--- /dev/null
+++ b/library/src/main/res/values-th/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+ %1$s ฟุต
+ %1$s ไมล์
+ %1$s ม.
+ %1$s กม.
+
diff --git a/library/src/main/res/values-tr/strings.xml b/library/src/main/res/values-tr/strings.xml
new file mode 100644
index 000000000..63b463320
--- /dev/null
+++ b/library/src/main/res/values-tr/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+ %1$s fit
+ %1$s mil
+ %1$s m
+ %1$s km
+
diff --git a/library/src/main/res/values-uk/strings.xml b/library/src/main/res/values-uk/strings.xml
new file mode 100644
index 000000000..f24540773
--- /dev/null
+++ b/library/src/main/res/values-uk/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+ %1$s фт
+ %1$s милі
+ %1$s м
+ %1$s км
+
diff --git a/library/src/main/res/values-vi/strings.xml b/library/src/main/res/values-vi/strings.xml
new file mode 100644
index 000000000..9ee478274
--- /dev/null
+++ b/library/src/main/res/values-vi/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+ %1$s ft
+ %1$s dặm
+ %1$s m
+ %1$s km
+
diff --git a/library/src/main/res/values-zh-rCN/strings.xml b/library/src/main/res/values-zh-rCN/strings.xml
new file mode 100644
index 000000000..1ea7acc0c
--- /dev/null
+++ b/library/src/main/res/values-zh-rCN/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+ %1$s 英尺
+ %1$s 英里
+ %1$s 米
+ %1$s 公里
+
diff --git a/library/src/main/res/values-zh-rHK/strings.xml b/library/src/main/res/values-zh-rHK/strings.xml
new file mode 100644
index 000000000..4eaf3018b
--- /dev/null
+++ b/library/src/main/res/values-zh-rHK/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+ %1$s 英尺
+ %1$s 英里
+ %1$s 公尺
+ %1$s 公里
+
diff --git a/library/src/main/res/values-zh-rTW/strings.xml b/library/src/main/res/values-zh-rTW/strings.xml
new file mode 100644
index 000000000..4eaf3018b
--- /dev/null
+++ b/library/src/main/res/values-zh-rTW/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+ %1$s 英尺
+ %1$s 英里
+ %1$s 公尺
+ %1$s 公里
+
diff --git a/library/src/main/res/values/strings.xml b/library/src/main/res/values/strings.xml
new file mode 100644
index 000000000..6fefcc380
--- /dev/null
+++ b/library/src/main/res/values/strings.xml
@@ -0,0 +1,37 @@
+
+
+
+
+ %1$s ft
+
+
+ %1$s mi
+
+
+ %1$s m
+
+
+ %1$s km
+
diff --git a/library/src/test/java/com/google/maps/android/UnitsJavaTest.java b/library/src/test/java/com/google/maps/android/UnitsJavaTest.java
new file mode 100644
index 000000000..d88beed48
--- /dev/null
+++ b/library/src/test/java/com/google/maps/android/UnitsJavaTest.java
@@ -0,0 +1,174 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.maps.android;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.maps.android.Units.feet;
+import static com.google.maps.android.Units.inFeet;
+import static com.google.maps.android.Units.inKilometers;
+import static com.google.maps.android.Units.inMeters;
+import static com.google.maps.android.Units.inMiles;
+import static com.google.maps.android.Units.kilometers;
+import static com.google.maps.android.Units.meters;
+import static com.google.maps.android.Units.miles;
+import static com.google.maps.android.Units.toDistanceString;
+import static com.google.maps.android.Units.toElevationString;
+
+import android.content.Context;
+import android.content.res.Configuration;
+import android.content.res.Resources;
+import java.util.Locale;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.RuntimeEnvironment;
+
+/**
+ * Verifies the Java interoperability surface for {@link Units}, {@link UnitSystem}, {@link
+ * NumberFormatSymbols}, {@link UnitsConverter}, {@link MetricUnitsConverter}, and {@link
+ * ImperialUnitsConverter}.
+ */
+@RunWith(RobolectricTestRunner.class)
+public class UnitsJavaTest {
+
+ @Test
+ public void unitConversions_workIdiomaticallyFromJava() {
+ // [START maps_android_utils_units_java_conversions]
+ // Expressing numbers in meters from Java via static factory methods
+ double twelveFeetInMeters = feet(12);
+ assertThat(twelveFeetInMeters).isWithin(1e-9).of(12 * Units.METERS_PER_FOOT);
+ assertThat(inFeet(twelveFeetInMeters)).isWithin(1e-9).of(12.0);
+
+ double twoAndHalfKmInMeters = kilometers(2.5);
+ assertThat(twoAndHalfKmInMeters).isEqualTo(2_500.0);
+ assertThat(inKilometers(twoAndHalfKmInMeters)).isWithin(1e-9).of(2.5);
+
+ double quarterMileInMeters = miles(0.25);
+ assertThat(quarterMileInMeters).isWithin(1e-9).of(1_320 * Units.METERS_PER_FOOT);
+ assertThat(inMiles(quarterMileInMeters)).isWithin(1e-9).of(0.25);
+ assertThat(inFeet(quarterMileInMeters)).isWithin(1e-9).of(1_320.0);
+
+ double hundredMeters = meters(100);
+ assertThat(hundredMeters).isEqualTo(100.0);
+ assertThat(inMeters(hundredMeters)).isEqualTo(100.0);
+ // [END maps_android_utils_units_java_conversions]
+ }
+
+ @Test
+ public void unitSystemAndConverters_resolveFromJava() {
+ assertThat(UnitSystem.fromLocale(Locale.US)).isEqualTo(UnitSystem.IMPERIAL);
+ assertThat(UnitSystem.fromLocale(Locale.FRANCE)).isEqualTo(UnitSystem.METRIC);
+ assertThat(UnitSystem.fromCountryCode("GB")).isEqualTo(UnitSystem.IMPERIAL);
+ assertThat(UnitSystem.fromCountryCode("DE")).isEqualTo(UnitSystem.METRIC);
+
+ UnitsConverter imperial = Units.getUnitsConverter(UnitSystem.IMPERIAL);
+ UnitsConverter metric = Units.getUnitsConverter(UnitSystem.METRIC);
+ assertThat(imperial).isSameInstanceAs(ImperialUnitsConverter.INSTANCE);
+ assertThat(metric).isSameInstanceAs(MetricUnitsConverter.INSTANCE);
+
+ // Threshold getters are exposed without inline value class mangling
+ assertThat(ImperialUnitsConverter.INSTANCE.getMilesThreshold()).isWithin(1e-9).of(miles(0.25));
+ assertThat(MetricUnitsConverter.INSTANCE.getKilometersThreshold()).isEqualTo(kilometers(1.0));
+ }
+
+ @Test
+ public void distanceAndElevationFormatting_worksFromJava() {
+ UnitsConverter imperial = ImperialUnitsConverter.INSTANCE;
+ UnitsConverter metric = MetricUnitsConverter.INSTANCE;
+
+ // Formatting via converter instance methods
+ assertThat(imperial.toDistanceString(feet(528), Locale.US)).isEqualTo("528 ft");
+ assertThat(imperial.toDistanceString(feet(1319.6), Locale.US)).isEqualTo("0.3 mi");
+ assertThat(imperial.toDistanceString(miles(0.25), Locale.US)).isEqualTo("0.3 mi");
+ assertThat(imperial.toDistanceString(miles(2.0), Locale.US)).isEqualTo("2.0 mi");
+ assertThat(imperial.toDistanceString(miles(12.4), Locale.US)).isEqualTo("12 mi");
+ assertThat(imperial.toElevationString(meters(4401.2), Locale.US)).isEqualTo("14,440 ft");
+
+ assertThat(metric.toDistanceString(meters(450), Locale.US)).isEqualTo("450 m");
+ assertThat(metric.toDistanceString(meters(999.6), Locale.US)).isEqualTo("1.0 km");
+ assertThat(metric.toDistanceString(kilometers(2.0), Locale.US)).isEqualTo("2.0 km");
+ assertThat(metric.toDistanceString(kilometers(12.4), Locale.US)).isEqualTo("12 km");
+ assertThat(metric.toElevationString(meters(4401.2), Locale.GERMANY)).isEqualTo("4.401 m");
+
+ // Formatting via top-level Units static methods
+ assertThat(toDistanceString(kilometers(2.0), Locale.US)).isEqualTo("1.2 mi");
+ assertThat(toDistanceString(kilometers(2.0), Locale.FRANCE)).isEqualTo("2,0 km");
+ assertThat(toDistanceString(kilometers(2.0), metric, NumberFormatSymbols.DEFAULT))
+ .isEqualTo("2.0 km");
+ assertThat(toElevationString(meters(4401.2), Locale.US)).isEqualTo("14,440 ft");
+ assertThat(toElevationString(meters(4401.2), metric, NumberFormatSymbols.DEFAULT))
+ .isEqualTo("4,401 m");
+
+ // Standalone logical number formatting
+ assertThat(Units.formatDistanceValue(2.0, Locale.US)).isEqualTo("2.0");
+ assertThat(Units.formatDistanceValue(1250.4, NumberFormatSymbols.fromLocale(Locale.GERMANY)))
+ .isEqualTo("1.250");
+ assertThat(NumberFormatSymbols.DEFAULT.formatFixedDecimal(1234.5, 2)).isEqualTo("1,234.50");
+ assertThat(NumberFormatSymbols.DEFAULT.formatFixedDecimal(1234.5)).isEqualTo("1,235");
+ assertThat(NumberFormatSymbols.DEFAULT.formatDistanceValue(2.0)).isEqualTo("2.0");
+ }
+
+ @Test
+ public void customUnitsConverter_worksFromJavaWithConvertersAndFormatters() {
+ // 1. UK (GB) defaults out-of-the-box to Imperial distances (ft/mi) + Metric elevations (m)
+ UnitsConverter ukDefault = Units.getUnitsConverter(Locale.UK);
+ assertThat(ukDefault.toDistanceString(kilometers(2.0), Locale.UK)).isEqualTo("1.2 mi");
+ assertThat(ukDefault.toElevationString(meters(1345.0), Locale.UK)).isEqualTo("1,345 m");
+
+ // 2. Simple overrides for either distance or elevation from Java
+ UnitsConverter ukWithFeetElevation = ukDefault.withElevationUnits(UnitSystem.IMPERIAL);
+ assertThat(ukWithFeetElevation).isSameInstanceAs(ImperialUnitsConverter.INSTANCE);
+ assertThat(ukWithFeetElevation.toElevationString(meters(1345.0), Locale.UK))
+ .isEqualTo("4,413 ft");
+
+ UnitsConverter ukWithMetricDistance = ukDefault.withDistanceUnits(UnitSystem.METRIC);
+ assertThat(ukWithMetricDistance).isSameInstanceAs(MetricUnitsConverter.INSTANCE);
+ assertThat(ukWithMetricDistance.toDistanceString(kilometers(2.0), Locale.UK))
+ .isEqualTo("2.0 km");
+
+ UnitsConverter fromUnitSystems = UnitsConverter.create(UnitSystem.IMPERIAL, UnitSystem.METRIC);
+ assertThat(fromUnitSystems).isSameInstanceAs(ukDefault);
+
+ // 3. Mix & match existing converters explicitly
+ UnitsConverter ukConverter =
+ UnitsConverter.create(ImperialUnitsConverter.INSTANCE, MetricUnitsConverter.INSTANCE);
+ assertThat(ukConverter.toDistanceString(kilometers(2.0), Locale.UK)).isEqualTo("1.2 mi");
+ assertThat(ukConverter.toElevationString(meters(1345.0), Locale.UK)).isEqualTo("1,345 m");
+
+ // 2. Custom MeasurementFormatter lambdas and method references from Java
+ UnitsConverter customConverter =
+ UnitsConverter.create(
+ (meters, symbols) -> symbols.formatFixedDecimal(inKilometers(meters), 2) + " km",
+ MetricUnitsConverter.INSTANCE::toElevationString);
+ assertThat(customConverter.toDistanceString(meters(1500), Locale.US)).isEqualTo("1.50 km");
+ assertThat(customConverter.toElevationString(meters(4401.2), Locale.US)).isEqualTo("4,401 m");
+ }
+
+ @Test
+ public void resourcesOverloads_andLocalizedTranslations_workFromJava() {
+ Context app = RuntimeEnvironment.getApplication();
+ Configuration jaConfig = new Configuration(app.getResources().getConfiguration());
+ jaConfig.setLocale(Locale.JAPAN);
+ Resources jaResources = app.createConfigurationContext(jaConfig).getResources();
+
+ assertThat(UnitSystem.fromResources(jaResources)).isEqualTo(UnitSystem.METRIC);
+ assertThat(toDistanceString(meters(450), jaResources)).isEqualTo("450 m");
+ assertThat(toDistanceString(miles(2.5), jaResources, ImperialUnitsConverter.INSTANCE))
+ .isEqualTo("2.5 マイル");
+ assertThat(toElevationString(meters(4401.2), jaResources, ImperialUnitsConverter.INSTANCE))
+ .isEqualTo("14,440 フィート");
+ }
+}
diff --git a/library/src/test/java/com/google/maps/android/UnitsTest.kt b/library/src/test/java/com/google/maps/android/UnitsTest.kt
new file mode 100644
index 000000000..b653ab705
--- /dev/null
+++ b/library/src/test/java/com/google/maps/android/UnitsTest.kt
@@ -0,0 +1,544 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.maps.android
+
+import android.content.res.Configuration
+import android.content.res.Resources
+import com.google.common.truth.Truth.assertThat
+import io.mockk.every
+import io.mockk.mockkStatic
+import org.junit.Assert.assertThrows
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import java.text.DecimalFormat
+import java.text.NumberFormat
+import java.util.Locale
+
+/**
+ * Comprehensive unit tests for [Meters], [UnitSystem], [UnitsConverter], [MetricUnitsConverter],
+ * [ImperialUnitsConverter], and logical distance/elevation formatting.
+ */
+@RunWith(RobolectricTestRunner::class)
+class UnitsTest {
+
+ // =============================================================================================
+ // § 1. Physical Conversion Constants & `Meters` Value Class
+ // =============================================================================================
+
+ @Test
+ fun conversionConstants_areMathematicallyConsistent() {
+ assertThat(METERS_PER_FOOT).isEqualTo(0.3048)
+ assertThat(METERS_PER_KILOMETER).isEqualTo(1_000)
+ assertThat(FEET_PER_MILE).isEqualTo(5_280)
+ assertThat(METERS_PER_MILE).isWithin(1e-9).of(1609.344)
+ assertThat(METERS_PER_FOOT * FEET_PER_METER).isWithin(1e-12).of(1.0)
+ assertThat(METERS_PER_MILE * MILES_PER_METER).isWithin(1e-12).of(1.0)
+ }
+
+ @Test
+ fun numberExtensions_constructMetersAccurately() {
+ assertThat(10.meters.value).isEqualTo(10.0)
+ assertThat(1.kilometers).isEqualTo(1_000.meters)
+ assertThat(2.5.kilometers).isEqualTo(2_500.meters)
+ assertThat(10.feet.value).isWithin(1e-9).of(3.048)
+ assertThat(1.miles.value).isWithin(1e-9).of(1609.344)
+ assertThat(5280.feet.value).isWithin(1e-9).of(1.miles.value)
+ }
+
+ @Test
+ fun metersExtensions_convertToScalarUnitsAccurately() {
+ assertThat(1500.meters.inMeters).isEqualTo(1500.0)
+ assertThat(1234.meters.inKilometers).isWithin(1e-6).of(1.234)
+ assertThat(300.meters.inFeet).isWithin(1e-4).of(984.252)
+ assertThat(10.kilometers.inMiles).isWithin(1e-4).of(6.21371)
+
+ // Exact round-trip conversions
+ assertThat(1.miles.inMiles).isWithin(1e-12).of(1.0)
+ assertThat(1.feet.inFeet).isWithin(1e-12).of(1.0)
+ assertThat(5280.feet.inMiles).isWithin(1e-12).of(1.0)
+ }
+
+ @Test
+ fun meters_arithmeticAndComparisons_behaveCorrectly() {
+ val shortDistance = 100.meters
+ val longDistance = 500.meters
+
+ assertThat(shortDistance).isLessThan(longDistance)
+ assertThat(longDistance).isGreaterThan(shortDistance)
+ assertThat(100.meters).isEquivalentAccordingToCompareTo(Meters(100.0))
+
+ assertThat(longDistance + shortDistance).isEqualTo(600.meters)
+ assertThat(longDistance - shortDistance).isEqualTo(400.meters)
+ assertThat(shortDistance * 3).isEqualTo(300.meters)
+ assertThat(longDistance / 2).isEqualTo(250.meters)
+ assertThat(longDistance / shortDistance).isEqualTo(5.0)
+ assertThat(-shortDistance).isEqualTo((-100).meters)
+ }
+
+ // =============================================================================================
+ // § 2. Framework-Agnostic `UnitSystem` & Locale Resolution
+ // =============================================================================================
+
+ @Test
+ fun unitSystem_fromCountryCode_returnsImperialForUsLrMm_andMixedForGb() {
+ for (code in listOf("US", "us", "LR", "lr", "MM", "mm")) {
+ assertThat(UnitSystem.fromCountryCode(code)).isEqualTo(UnitSystem.IMPERIAL)
+ assertThat(UnitSystem.distanceFromCountryCode(code)).isEqualTo(UnitSystem.IMPERIAL)
+ assertThat(UnitSystem.elevationFromCountryCode(code)).isEqualTo(UnitSystem.IMPERIAL)
+ assertThat(getUnitsConverter(code)).isSameInstanceAs(ImperialUnitsConverter)
+ }
+ for (code in listOf("GB", "gb")) {
+ assertThat(UnitSystem.fromCountryCode(code)).isEqualTo(UnitSystem.IMPERIAL)
+ assertThat(UnitSystem.distanceFromCountryCode(code)).isEqualTo(UnitSystem.IMPERIAL)
+ assertThat(UnitSystem.elevationFromCountryCode(code)).isEqualTo(UnitSystem.METRIC)
+ val gbConverter = getUnitsConverter(code)
+ assertThat(gbConverter.toDistanceString(2_000.meters, Locale.UK)).isEqualTo("1.2 mi")
+ assertThat(gbConverter.toElevationString(1_345.meters, Locale.UK)).isEqualTo("1,345 m")
+ }
+ }
+
+ @Test
+ fun unitSystem_fromCountryCode_returnsMetricForOtherCountriesAndNullOrBlank() {
+ for (code in listOf("CA", "FR", "DE", "IN", "JP", "AU", "BR", "", " ", null)) {
+ assertThat(UnitSystem.fromCountryCode(code)).isEqualTo(UnitSystem.METRIC)
+ assertThat(UnitSystem.distanceFromCountryCode(code)).isEqualTo(UnitSystem.METRIC)
+ assertThat(UnitSystem.elevationFromCountryCode(code)).isEqualTo(UnitSystem.METRIC)
+ assertThat(getUnitsConverter(code)).isSameInstanceAs(MetricUnitsConverter)
+ }
+ }
+
+ @Test
+ fun unitSystem_fromLocale_resolvesAppropriateDistanceAndElevationUnitSystems() {
+ assertThat(UnitSystem.fromLocale(Locale.US)).isEqualTo(UnitSystem.IMPERIAL)
+ assertThat(UnitSystem.distanceFromLocale(Locale.US)).isEqualTo(UnitSystem.IMPERIAL)
+ assertThat(UnitSystem.elevationFromLocale(Locale.US)).isEqualTo(UnitSystem.IMPERIAL)
+
+ // GB uses Imperial for road distances (ft/mi) and Metric for elevations (m)
+ assertThat(UnitSystem.fromLocale(Locale.UK)).isEqualTo(UnitSystem.IMPERIAL)
+ assertThat(UnitSystem.distanceFromLocale(Locale.UK)).isEqualTo(UnitSystem.IMPERIAL)
+ assertThat(UnitSystem.elevationFromLocale(Locale.UK)).isEqualTo(UnitSystem.METRIC)
+ assertThat(2_000.meters.toDistanceString(Locale.UK)).isEqualTo("1.2 mi")
+ assertThat(4401.2.meters.toElevationString(Locale.UK)).isEqualTo("4,401 m")
+
+ val lrLocale = Locale.Builder().setLanguage("en").setRegion("LR").build()
+ assertThat(UnitSystem.distanceFromLocale(lrLocale)).isEqualTo(UnitSystem.IMPERIAL)
+ assertThat(UnitSystem.elevationFromLocale(lrLocale)).isEqualTo(UnitSystem.IMPERIAL)
+
+ val mmLocale = Locale.Builder().setLanguage("my").setRegion("MM").build()
+ assertThat(UnitSystem.distanceFromLocale(mmLocale)).isEqualTo(UnitSystem.IMPERIAL)
+ assertThat(UnitSystem.elevationFromLocale(mmLocale)).isEqualTo(UnitSystem.IMPERIAL)
+
+ assertThat(UnitSystem.fromLocale(Locale.CANADA)).isEqualTo(UnitSystem.METRIC)
+ assertThat(UnitSystem.distanceFromLocale(Locale.FRANCE)).isEqualTo(UnitSystem.METRIC)
+ assertThat(UnitSystem.elevationFromLocale(Locale.FRANCE)).isEqualTo(UnitSystem.METRIC)
+ assertThat(UnitSystem.fromLocale(Locale.JAPAN)).isEqualTo(UnitSystem.METRIC)
+ }
+
+ @Test
+ fun preferredUnitsOverrides_changeDistanceOrElevationUnitsSimply() {
+ // 1. Overriding elevation or distance on a locale-resolved converter via withElevationUnits / withDistanceUnits
+ val ukDefault = getUnitsConverter(Locale.UK)
+ assertThat(ukDefault.toDistanceString(2_000.meters, Locale.UK)).isEqualTo("1.2 mi")
+ assertThat(ukDefault.toElevationString(1_345.meters, Locale.UK)).isEqualTo("1,345 m")
+
+ val ukWithFeetElevation = ukDefault.withElevationUnits(UnitSystem.IMPERIAL)
+ assertThat(ukWithFeetElevation).isSameInstanceAs(ImperialUnitsConverter)
+ assertThat(ukWithFeetElevation.toDistanceString(2_000.meters, Locale.UK)).isEqualTo("1.2 mi")
+ assertThat(ukWithFeetElevation.toElevationString(1_345.meters, Locale.UK)).isEqualTo("4,413 ft")
+
+ val ukWithMetricDistance = ukDefault.withDistanceUnits(UnitSystem.METRIC)
+ assertThat(ukWithMetricDistance).isSameInstanceAs(MetricUnitsConverter)
+ assertThat(ukWithMetricDistance.toDistanceString(2_000.meters, Locale.UK)).isEqualTo("2.0 km")
+ assertThat(ukWithMetricDistance.toElevationString(1_345.meters, Locale.UK)).isEqualTo("1,345 m")
+
+ // 2. Overriding distance or elevation directly in getUnitsConverter(...)
+ val usWithMetricElevation = getUnitsConverter(Locale.US, elevationUnits = UnitSystem.METRIC)
+ assertThat(usWithMetricElevation.toDistanceString(2_000.meters, Locale.US)).isEqualTo("1.2 mi")
+ assertThat(usWithMetricElevation.toElevationString(4401.2.meters, Locale.US)).isEqualTo("4,401 m")
+
+ val deWithImperialDistance = getUnitsConverter(Locale.GERMANY, distanceUnits = UnitSystem.IMPERIAL)
+ assertThat(deWithImperialDistance.toDistanceString(2_000.meters, Locale.GERMANY)).isEqualTo("1,2 mi")
+ assertThat(deWithImperialDistance.toElevationString(4401.2.meters, Locale.GERMANY)).isEqualTo("4.401 m")
+
+ // 3. Constructing directly from (distanceUnits, elevationUnits)
+ val hybrid = UnitsConverter(distanceUnits = UnitSystem.IMPERIAL, elevationUnits = UnitSystem.METRIC)
+ assertThat(hybrid).isSameInstanceAs(ukDefault)
+ assertThat(UnitsConverter(UnitSystem.METRIC, UnitSystem.METRIC)).isSameInstanceAs(MetricUnitsConverter)
+ assertThat(UnitsConverter(UnitSystem.IMPERIAL, UnitSystem.IMPERIAL)).isSameInstanceAs(ImperialUnitsConverter)
+ }
+
+ @Test
+ fun getUnitsConverter_defaultsToDeviceLocale() {
+ val originalLocale = Locale.getDefault()
+ try {
+ Locale.setDefault(Locale.US)
+ assertThat(UnitSystem.fromLocale()).isEqualTo(UnitSystem.IMPERIAL)
+ assertThat(getUnitsConverter()).isSameInstanceAs(ImperialUnitsConverter)
+
+ Locale.setDefault(Locale.GERMANY)
+ assertThat(UnitSystem.fromLocale()).isEqualTo(UnitSystem.METRIC)
+ assertThat(getUnitsConverter()).isSameInstanceAs(MetricUnitsConverter)
+ } finally {
+ Locale.setDefault(originalLocale)
+ }
+ }
+
+ @Test
+ fun getUnitsConverter_fromExplicitUnitSystem_returnsExpectedConverter() {
+ assertThat(getUnitsConverter(UnitSystem.METRIC)).isSameInstanceAs(MetricUnitsConverter)
+ assertThat(getUnitsConverter(UnitSystem.IMPERIAL)).isSameInstanceAs(ImperialUnitsConverter)
+ assertThat(MetricUnitsConverter.unitSystem).isEqualTo(UnitSystem.METRIC)
+ assertThat(ImperialUnitsConverter.unitSystem).isEqualTo(UnitSystem.IMPERIAL)
+ }
+
+ // =============================================================================================
+ // § 3. Logical Distance Formatting: Metric (`m` and `km`)
+ // =============================================================================================
+
+ @Test
+ fun metricDistance_belowOneKilometer_formatsAsWholeMeters() {
+ assertThat(MetricUnitsConverter.toDistanceString(0.meters, Locale.US)).isEqualTo("0 m")
+ assertThat(MetricUnitsConverter.toDistanceString(100.meters, Locale.US)).isEqualTo("100 m")
+ assertThat(MetricUnitsConverter.toDistanceString(450.meters, Locale.US)).isEqualTo("450 m")
+ assertThat(MetricUnitsConverter.toDistanceString(999.meters, Locale.US)).isEqualTo("999 m")
+ assertThat(MetricUnitsConverter.toDistanceString(999.4.meters, Locale.US)).isEqualTo("999 m")
+ }
+
+ @Test
+ fun metricDistance_fromOneToUnderTenKilometers_keepsOneDecimalWithTrailingZero() {
+ // 999.6 m rounds to 1,000 m, so it must switch to the kilometers branch ("1.0 km") rather than "1,000 m"
+ assertThat(MetricUnitsConverter.toDistanceString(999.6.meters, Locale.US)).isEqualTo("1.0 km")
+ assertThat(MetricUnitsConverter.toDistanceString(1_000.meters, Locale.US)).isEqualTo("1.0 km")
+ assertThat(MetricUnitsConverter.toDistanceString(1_010.meters, Locale.US)).isEqualTo("1.0 km")
+ assertThat(MetricUnitsConverter.toDistanceString(1_500.meters, Locale.US)).isEqualTo("1.5 km")
+ assertThat(MetricUnitsConverter.toDistanceString(1_900.meters, Locale.US)).isEqualTo("1.9 km")
+ assertThat(MetricUnitsConverter.toDistanceString(2_000.meters, Locale.US)).isEqualTo("2.0 km")
+ assertThat(MetricUnitsConverter.toDistanceString(9_900.meters, Locale.US)).isEqualTo("9.9 km")
+ }
+
+ @Test
+ fun metricDistance_tenKilometersOrGreater_roundsToWholeKilometers() {
+ // 9.96 km rounds to 10 at 1 decimal place, so it should use the whole-number format ("10 km")
+ assertThat(MetricUnitsConverter.toDistanceString(9_960.meters, Locale.US)).isEqualTo("10 km")
+ assertThat(MetricUnitsConverter.toDistanceString(10_000.meters, Locale.US)).isEqualTo("10 km")
+ assertThat(MetricUnitsConverter.toDistanceString(10_400.meters, Locale.US)).isEqualTo("10 km")
+ assertThat(MetricUnitsConverter.toDistanceString(11_600.meters, Locale.US)).isEqualTo("12 km")
+ assertThat(MetricUnitsConverter.toDistanceString(1_250.kilometers, Locale.US)).isEqualTo("1,250 km")
+ }
+
+ // =============================================================================================
+ // § 4. Logical Distance Formatting: Imperial (`ft` and `mi`, switching at 0.25 miles)
+ // =============================================================================================
+
+ @Test
+ fun imperialDistance_belowQuarterMile_formatsAsWholeFeet() {
+ assertThat(ImperialUnitsConverter.toDistanceString(0.meters, Locale.US)).isEqualTo("0 ft")
+ assertThat(ImperialUnitsConverter.toDistanceString(100.meters, Locale.US)).isEqualTo("328 ft")
+ assertThat(ImperialUnitsConverter.toDistanceString(0.1.miles, Locale.US)).isEqualTo("528 ft")
+ assertThat(ImperialUnitsConverter.toDistanceString(1_319.feet, Locale.US)).isEqualTo("1,319 ft")
+ assertThat(ImperialUnitsConverter.toDistanceString(1_319.4.feet, Locale.US)).isEqualTo("1,319 ft")
+ }
+
+ @Test
+ fun imperialDistance_fromQuarterMileToUnderTenMiles_keepsOneDecimalWithTrailingZero() {
+ // 1,319.6 ft rounds to 1,320 ft (0.25 mi), so it must switch to miles ("0.3 mi") rather than "1,320 ft"
+ assertThat(ImperialUnitsConverter.toDistanceString(1_319.6.feet, Locale.US)).isEqualTo("0.3 mi")
+ // Exactly 0.25 miles (1,320 feet) switches to miles ("0.3 mi" at 1 decimal place)
+ assertThat(ImperialUnitsConverter.toDistanceString(0.25.miles, Locale.US)).isEqualTo("0.3 mi")
+ assertThat(ImperialUnitsConverter.toDistanceString(0.5.miles, Locale.US)).isEqualTo("0.5 mi")
+ assertThat(ImperialUnitsConverter.toDistanceString(1.0.miles, Locale.US)).isEqualTo("1.0 mi")
+ assertThat(ImperialUnitsConverter.toDistanceString(2_000.meters, Locale.US)).isEqualTo("1.2 mi")
+ assertThat(ImperialUnitsConverter.toDistanceString(3_200.meters, Locale.US)).isEqualTo("2.0 mi")
+ assertThat(ImperialUnitsConverter.toDistanceString(2.5.miles, Locale.US)).isEqualTo("2.5 mi")
+ assertThat(ImperialUnitsConverter.toDistanceString(9.9.miles, Locale.US)).isEqualTo("9.9 mi")
+ }
+
+ @Test
+ fun imperialDistance_tenMilesOrGreater_roundsToWholeMiles() {
+ assertThat(ImperialUnitsConverter.toDistanceString(9.96.miles, Locale.US)).isEqualTo("10 mi")
+ assertThat(ImperialUnitsConverter.toDistanceString(10.0.miles, Locale.US)).isEqualTo("10 mi")
+ assertThat(ImperialUnitsConverter.toDistanceString(16_100.meters, Locale.US)).isEqualTo("10 mi")
+ assertThat(ImperialUnitsConverter.toDistanceString(25.6.miles, Locale.US)).isEqualTo("26 mi")
+ assertThat(ImperialUnitsConverter.toDistanceString(1_250.miles, Locale.US)).isEqualTo("1,250 mi")
+ }
+
+ // =============================================================================================
+ // § 5. Elevation Formatting (`m` and `ft` only)
+ // =============================================================================================
+
+ @Test
+ fun elevationFormatting_alwaysUsesMetersOrFeetRegardlessOfMagnitude() {
+ // Mount Elbert: 4,401.2 m / 14,440 ft
+ val mountElbert = 4401.2.meters
+ assertThat(MetricUnitsConverter.toElevationString(mountElbert, Locale.US)).isEqualTo("4,401 m")
+ assertThat(ImperialUnitsConverter.toElevationString(mountElbert, Locale.US)).isEqualTo("14,440 ft")
+
+ // Mount Everest: 8,848.86 m / 29,032 ft
+ val mountEverest = 8848.86.meters
+ assertThat(MetricUnitsConverter.toElevationString(mountEverest, Locale.US)).isEqualTo("8,849 m")
+ assertThat(ImperialUnitsConverter.toElevationString(mountEverest, Locale.US)).isEqualTo("29,032 ft")
+
+ // Below sea level (Death Valley: -86 m / -282 ft)
+ val deathValley = (-86).meters
+ assertThat(MetricUnitsConverter.toElevationString(deathValley, Locale.US)).isEqualTo("-86 m")
+ assertThat(ImperialUnitsConverter.toElevationString(deathValley, Locale.US)).isEqualTo("-282 ft")
+ }
+
+ @Test
+ fun negativeNearZeroValues_doNotFormatWithNegativeSign() {
+ assertThat(MetricUnitsConverter.toDistanceString((-0.01).meters, Locale.US)).isEqualTo("0 m")
+ assertThat(ImperialUnitsConverter.toDistanceString((-0.01).meters, Locale.US)).isEqualTo("0 ft")
+ assertThat(MetricUnitsConverter.toElevationString((-0.01).meters, Locale.US)).isEqualTo("0 m")
+ assertThat(ImperialUnitsConverter.toElevationString((-0.01).meters, Locale.US)).isEqualTo("0 ft")
+ assertThat(formatDistanceValue(-0.01, Locale.US)).isEqualTo("0.0")
+ }
+
+ // =============================================================================================
+ // § 6. Extension Functions, Raw `Double` Overloads, & `NumberFormatSymbols`
+ // =============================================================================================
+
+ @Test
+ fun metersExtensionsAndDoubleOverloads_formatCorrectly() {
+ // Extension functions with Locale
+ assertThat(2_000.meters.toDistanceString(Locale.US)).isEqualTo("1.2 mi")
+ assertThat(2_000.meters.toDistanceString(Locale.FRANCE)).isEqualTo("2,0 km")
+ assertThat(2_000.meters.toDistanceString(MetricUnitsConverter)).isEqualTo(
+ MetricUnitsConverter.toDistanceString(2_000.meters),
+ )
+
+ assertThat(4401.2.meters.toElevationString(Locale.US)).isEqualTo("14,440 ft")
+ assertThat(4401.2.meters.toElevationString(MetricUnitsConverter)).isEqualTo(
+ MetricUnitsConverter.toElevationString(4401.2.meters),
+ )
+
+ // Raw Double overloads (for Java callers)
+ assertThat(MetricUnitsConverter.toDistanceString(2000.0, Locale.US)).isEqualTo("2.0 km")
+ assertThat(MetricUnitsConverter.toElevationString(450.0, Locale.US)).isEqualTo("450 m")
+
+ // Standalone logical number formatter
+ assertThat(formatDistanceValue(0.25, Locale.US)).isEqualTo("0.3")
+ assertThat(formatDistanceValue(2.0, Locale.US)).isEqualTo("2.0")
+ assertThat(formatDistanceValue(9.94, Locale.US)).isEqualTo("9.9")
+ assertThat(formatDistanceValue(9.96, Locale.US)).isEqualTo("10")
+ assertThat(formatDistanceValue(12.4, Locale.US)).isEqualTo("12")
+ }
+
+ @Test
+ fun numberFormatSymbols_respectsLocaleGroupingAndDecimalRulesInPureKotlin() {
+ val usSymbols = NumberFormatSymbols.fromLocale(Locale.US)
+ assertThat(usSymbols.groupingSeparator).isEqualTo(',')
+ assertThat(usSymbols.decimalSeparator).isEqualTo('.')
+ assertThat(usSymbols.groupingSize).isEqualTo(3)
+ assertThat(MetricUnitsConverter.toElevationString(1_234_567.meters, usSymbols)).isEqualTo("1,234,567 m")
+ assertThat(MetricUnitsConverter.toDistanceString(2_500.0, usSymbols)).isEqualTo("2.5 km")
+
+ val deSymbols = NumberFormatSymbols.fromLocale(Locale.GERMANY)
+ assertThat(deSymbols.groupingSeparator).isEqualTo('.')
+ assertThat(deSymbols.decimalSeparator).isEqualTo(',')
+ assertThat(deSymbols.groupingSize).isEqualTo(3)
+ assertThat(MetricUnitsConverter.toElevationString(4401.2.meters, deSymbols)).isEqualTo("4.401 m")
+ assertThat(MetricUnitsConverter.toElevationString(4401.2, Locale.GERMANY)).isEqualTo("4.401 m")
+ assertThat(MetricUnitsConverter.toDistanceString(2_000.meters, deSymbols)).isEqualTo("2,0 km")
+ assertThat(formatDistanceValue(1_250.4, deSymbols)).isEqualTo("1.250")
+
+ // Custom KMP symbols (e.g. grouping disabled)
+ val ungroupedSymbols = NumberFormatSymbols(groupingSeparator = '_', decimalSeparator = '.', groupingSize = 0)
+ assertThat(ImperialUnitsConverter.toElevationString(4401.2.meters, ungroupedSymbols)).isEqualTo("14440 ft")
+ assertThat(4401.2.meters.toElevationString(ImperialUnitsConverter, ungroupedSymbols)).isEqualTo("14440 ft")
+ assertThat(2_500.meters.toDistanceString(MetricUnitsConverter, ungroupedSymbols)).isEqualTo("2.5 km")
+
+ // When a locale's NumberFormat has isGroupingUsed == false, fromLocale must return groupingSize = 0
+ val ungroupedFormat = (NumberFormat.getNumberInstance(Locale.US) as DecimalFormat).apply {
+ isGroupingUsed = false
+ }
+ mockkStatic(NumberFormat::class) {
+ every { NumberFormat.getNumberInstance(Locale.US) } returns ungroupedFormat
+ val symbolsFromUngroupedLocale = NumberFormatSymbols.fromLocale(Locale.US)
+ assertThat(symbolsFromUngroupedLocale.groupingSize).isEqualTo(0)
+ assertThat(ImperialUnitsConverter.toElevationString(4401.2.meters, symbolsFromUngroupedLocale))
+ .isEqualTo("14440 ft")
+ }
+ }
+
+ // =============================================================================================
+ // § 7. Public `NumberFormatSymbols` Formatting & Custom / Composite `UnitsConverter`s
+ // =============================================================================================
+
+ @Test
+ fun numberFormatSymbols_formatFixedDecimal_and_formatDistanceValue_supportArbitraryPrecision() {
+ val usSymbols = NumberFormatSymbols.DEFAULT
+ assertThat(usSymbols.formatFixedDecimal(1234.567)).isEqualTo("1,235")
+ assertThat(usSymbols.formatFixedDecimal(1234.567, decimals = 0)).isEqualTo("1,235")
+ assertThat(usSymbols.formatFixedDecimal(1234.567, decimals = 1)).isEqualTo("1,234.6")
+ assertThat(usSymbols.formatFixedDecimal(1234.5, decimals = 2)).isEqualTo("1,234.50")
+ assertThat(usSymbols.formatFixedDecimal(1.006, decimals = 2)).isEqualTo("1.01")
+ assertThat(usSymbols.formatFixedDecimal(1.05, decimals = 2)).isEqualTo("1.05")
+ assertThat(usSymbols.formatFixedDecimal(-0.001, decimals = 2)).isEqualTo("0.00")
+ assertThat(usSymbols.formatFixedDecimal(-42.5, decimals = 1)).isEqualTo("-42.5")
+
+ val deSymbols = NumberFormatSymbols.fromLocale(Locale.GERMANY)
+ assertThat(deSymbols.formatFixedDecimal(1234.5, decimals = 2)).isEqualTo("1.234,50")
+ assertThat(deSymbols.formatDistanceValue(2.0)).isEqualTo("2,0")
+ assertThat(deSymbols.formatDistanceValue(1250.4)).isEqualTo("1.250")
+
+ assertThrows(IllegalArgumentException::class.java) {
+ usSymbols.formatFixedDecimal(10.0, decimals = -1)
+ }
+ }
+
+ @Test
+ fun customUnitsConverter_mixesAndMatchesConvertersAndCustomFormatters() {
+ // 1. Mix & match existing converters (e.g., UK: Imperial road distances + Metric elevations)
+ val ukConverter = UnitsConverter(
+ distanceConverter = ImperialUnitsConverter,
+ elevationConverter = MetricUnitsConverter,
+ )
+ assertThat(2_000.meters.toDistanceString(Locale.UK, ukConverter)).isEqualTo("1.2 mi")
+ assertThat(1_345.meters.toElevationString(Locale.UK, ukConverter)).isEqualTo("1,345 m")
+
+ // 2. Mix converter with a custom formatter lambda
+ val imperialWithAslElevation = UnitsConverter(
+ distanceConverter = ImperialUnitsConverter,
+ elevationFormatter = { meters, symbols ->
+ "${symbols.formatFixedDecimal(meters.inMeters, decimals = 1)} m ASL"
+ },
+ )
+ assertThat(imperialWithAslElevation.toDistanceString(0.5.miles, Locale.US)).isEqualTo("0.5 mi")
+ assertThat(imperialWithAslElevation.toElevationString(1344.56.meters, Locale.US))
+ .isEqualTo("1,344.6 m ASL")
+
+ // 3. Custom distance formatter + existing elevation converter
+ val nauticalDistanceWithMetricElevation = UnitsConverter(
+ distanceFormatter = { meters, symbols ->
+ val nauticalMiles = meters.inMeters / 1852.0
+ "${symbols.formatFixedDecimal(nauticalMiles, decimals = 2)} NM"
+ },
+ elevationConverter = MetricUnitsConverter,
+ )
+ assertThat(nauticalDistanceWithMetricElevation.toDistanceString(1852.meters, Locale.US))
+ .isEqualTo("1.00 NM")
+ assertThat(nauticalDistanceWithMetricElevation.toElevationString(450.meters, Locale.US))
+ .isEqualTo("450 m")
+
+ // 4. Both custom formatters (or method references)
+ val customBoth = UnitsConverter(
+ distanceFormatter = { meters, symbols ->
+ "${symbols.formatFixedDecimal(meters.inKilometers, decimals = 2)} kilometers"
+ },
+ elevationFormatter = MetricUnitsConverter::toElevationString,
+ )
+ assertThat(customBoth.toDistanceString(1500.meters, Locale.FRANCE))
+ .isEqualTo("1,50 kilometers")
+ assertThat(customBoth.toElevationString(4401.2.meters, Locale.FRANCE))
+ .isEqualTo(
+ "4\u202f401 m".replace('\u202f', NumberFormatSymbols.fromLocale(Locale.FRANCE).groupingSeparator),
+ )
+ }
+
+ // =============================================================================================
+ // § 8. Android `Resources` Overloads & Localized Unit Label Translations
+ // =============================================================================================
+
+ @Test
+ fun resourcesOverloads_resolveUnitSystemAndFormatWithLocalizedStringResources() {
+ val usResources = resourcesForLocale(Locale.US)
+ assertThat(UnitSystem.fromResources(usResources)).isEqualTo(UnitSystem.IMPERIAL)
+ assertThat(getUnitsConverter(usResources)).isSameInstanceAs(ImperialUnitsConverter)
+ assertThat(0.1.miles.toDistanceString(usResources)).isEqualTo("528 ft")
+ assertThat(2.5.miles.toDistanceString(usResources)).isEqualTo("2.5 mi")
+ assertThat(4401.2.meters.toElevationString(usResources)).isEqualTo("14,440 ft")
+
+ val deResources = resourcesForLocale(Locale.GERMANY)
+ assertThat(UnitSystem.fromResources(deResources)).isEqualTo(UnitSystem.METRIC)
+ assertThat(getUnitsConverter(deResources)).isSameInstanceAs(MetricUnitsConverter)
+ assertThat(2_000.meters.toDistanceString(deResources)).isEqualTo("2,0 km")
+ assertThat(4401.2.meters.toElevationString(deResources)).isEqualTo("4.401 m")
+
+ // UK Resources default out-of-the-box to Imperial road distances + Metric elevations
+ val ukResources = resourcesForLocale(Locale.UK)
+ assertThat(UnitSystem.distanceFromResources(ukResources)).isEqualTo(UnitSystem.IMPERIAL)
+ assertThat(UnitSystem.elevationFromResources(ukResources)).isEqualTo(UnitSystem.METRIC)
+ assertThat(2_000.meters.toDistanceString(ukResources)).isEqualTo("1.2 mi")
+ assertThat(1_345.meters.toElevationString(ukResources)).isEqualTo("1,345 m")
+ assertThat(
+ 1_345.meters.toElevationString(
+ ukResources,
+ getUnitsConverter(ukResources, elevationUnits = UnitSystem.IMPERIAL),
+ ),
+ ).isEqualTo("4,413 ft")
+ }
+
+ @Test
+ fun localizedStringResources_translateUnitLabelsAcrossLanguages() {
+ // Japanese (ja-JP): metric uses "m" / "km", imperial uses "フィート" / "マイル"
+ val jaResources = resourcesForLocale(Locale.JAPAN)
+ assertThat(450.meters.toDistanceString(jaResources)).isEqualTo("450 m")
+ assertThat(2.0.kilometers.toDistanceString(jaResources)).isEqualTo("2.0 km")
+ assertThat(0.1.miles.toDistanceString(jaResources, ImperialUnitsConverter)).isEqualTo("528 フィート")
+ assertThat(2.5.miles.toDistanceString(jaResources, ImperialUnitsConverter)).isEqualTo("2.5 マイル")
+ assertThat(4401.2.meters.toElevationString(jaResources, ImperialUnitsConverter)).isEqualTo("14,440 フィート")
+
+ // Korean (ko-KR): imperial uses "피트" / "마일" (no space per Korean CLDR convention)
+ val koResources = resourcesForLocale(Locale.KOREA)
+ assertThat(450.meters.toDistanceString(koResources)).isEqualTo("450 m")
+ assertThat(2.0.kilometers.toDistanceString(koResources)).isEqualTo("2.0 km")
+ assertThat(0.1.miles.toDistanceString(koResources, ImperialUnitsConverter)).isEqualTo("528피트")
+ assertThat(2.5.miles.toDistanceString(koResources, ImperialUnitsConverter)).isEqualTo("2.5마일")
+
+ // Simplified Chinese (zh-CN): "米", "公里", "英尺", "英里"
+ val zhCnResources = resourcesForLocale(Locale.SIMPLIFIED_CHINESE)
+ assertThat(450.meters.toDistanceString(zhCnResources)).isEqualTo("450 米")
+ assertThat(2.0.kilometers.toDistanceString(zhCnResources)).isEqualTo("2.0 公里")
+ assertThat(0.1.miles.toDistanceString(zhCnResources, ImperialUnitsConverter)).isEqualTo("528 英尺")
+ assertThat(2.5.miles.toDistanceString(zhCnResources, ImperialUnitsConverter)).isEqualTo("2.5 英里")
+
+ // Traditional Chinese (zh-TW): "公尺", "公里", "英尺", "英里"
+ val zhTwResources = resourcesForLocale(Locale.TRADITIONAL_CHINESE)
+ assertThat(450.meters.toDistanceString(zhTwResources)).isEqualTo("450 公尺")
+ assertThat(2.0.kilometers.toDistanceString(zhTwResources)).isEqualTo("2.0 公里")
+
+ // Russian (ru-RU): Cyrillic "м", "км", "фт", "ми" + comma decimal separator
+ val ruLocale = Locale.Builder().setLanguage("ru").setRegion("RU").build()
+ val ruResources = resourcesForLocale(ruLocale)
+ assertThat(450.meters.toDistanceString(ruResources)).isEqualTo("450 м")
+ assertThat(2.0.kilometers.toDistanceString(ruResources)).isEqualTo("2,0 км")
+ assertThat(0.1.miles.toDistanceString(ruResources, ImperialUnitsConverter)).isEqualTo("528 фт")
+ assertThat(2.5.miles.toDistanceString(ruResources, ImperialUnitsConverter)).isEqualTo("2,5 ми")
+
+ // Arabic (ar-EG): "م", "كم", "قدم", "ميل"
+ val arLocale = Locale.Builder().setLanguage("ar").setRegion("EG").build()
+ val arResources = resourcesForLocale(arLocale)
+ assertThat(450.meters.toDistanceString(arResources)).endsWith(" م")
+ assertThat(2.0.kilometers.toDistanceString(arResources)).endsWith(" كم")
+ assertThat(0.1.miles.toDistanceString(arResources, ImperialUnitsConverter)).endsWith(" قدم")
+ assertThat(2.5.miles.toDistanceString(arResources, ImperialUnitsConverter)).endsWith(" ميل")
+
+ // French (fr-FR): feet translated as "pi"
+ val frResources = resourcesForLocale(Locale.FRANCE)
+ assertThat(0.1.miles.toDistanceString(frResources, ImperialUnitsConverter)).isEqualTo("528 pi")
+ assertThat(2.0.kilometers.toDistanceString(frResources)).isEqualTo("2,0 km")
+ }
+
+ private fun resourcesForLocale(locale: Locale): Resources {
+ val baseContext = RuntimeEnvironment.getApplication()
+ val config = Configuration(baseContext.resources.configuration).apply {
+ setLocale(locale)
+ }
+ return baseContext.createConfigurationContext(config).resources
+ }
+}
diff --git a/llm-integration-prompt.md b/llm-integration-prompt.md
index 176e85a23..b5f647324 100644
--- a/llm-integration-prompt.md
+++ b/llm-integration-prompt.md
@@ -1,6 +1,6 @@
---
name: maps-utils-android
-description: Guide for integrating the Google Maps Utility Library for Android (android-maps-utils) into an application. Use when users ask to add features like Marker Clustering, Heatmaps, GeoJSON, KML, or Polyline encoding/decoding.
+description: Guide for integrating the Google Maps Utility Library for Android (android-maps-utils) into an application. Use when users ask to add features like Marker Clustering, Heatmaps, GeoJSON, KML, Polyline encoding/decoding, or Units (distance and elevation conversion and formatting).
---
# Google Maps Utility Library for Android Integration
@@ -46,6 +46,128 @@ Used for server-client coordinate compression and distance calculations.
* **Decoding:** `PolyUtil.decode(encodedPathString)`
* **Distance:** `SphericalUtil.computeDistanceBetween(latLng1, latLng2)`
+### Units & Logical Distance/Elevation Formatting
+Used to represent measurements with the type-safe `Meters` inline value class and format distances and elevations in Metric (`m`, `km`) or Imperial (`ft`, `mi`) units based on the device's `Locale` or `Resources` (`US`, `LR`, and `MM` default to `IMPERIAL` for both; `GB` defaults to `IMPERIAL` road distances + `METRIC` elevations; all others default to `METRIC`), or an explicit `UnitSystem` / formatter override.
+
+* **Logical Precision Rules:**
+ * **Small distances** (`< 1 km` metric or `< 0.25 mi` imperial) and **all elevations** format as whole integers with locale thousands grouping (`"450 m"`, `"528 ft"`, `"14,440 ft"`).
+ * **Medium distances** (`1.0` to `< 10` km, or `0.25` to `< 10` mi) format with 1 decimal digit, keeping trailing `.0` (`"0.3 mi"`, `"1.0 km"`, `"2.5 mi"`).
+ * **Large distances** (`>= 10` km or `>= 10` mi) round to whole integers (`"10 km"`, `"12 mi"`).
+
+* **Kotlin Example:**
+```kotlin
+import com.google.maps.android.ImperialUnitsConverter
+import com.google.maps.android.Meters
+import com.google.maps.android.MetricUnitsConverter
+import com.google.maps.android.UnitSystem
+import com.google.maps.android.UnitsConverter
+import com.google.maps.android.feet
+import com.google.maps.android.getUnitsConverter
+import com.google.maps.android.inFeet
+import com.google.maps.android.inKilometers
+import com.google.maps.android.kilometers
+import com.google.maps.android.meters
+import com.google.maps.android.miles
+import com.google.maps.android.sphericalDistance
+import com.google.maps.android.toDistanceString
+import com.google.maps.android.toElevationString
+import java.util.Locale
+
+// Construct type-safe Meters and perform arithmetic or unit conversions
+val summitElevation: Meters = 4401.2.meters
+val elevationInFeet: Double = summitElevation.inFeet // 14439.6325...
+val routeDistance: Meters = startLatLng.sphericalDistance(endLatLng).meters + 500.feet
+
+// 1. Default formatting using device Locale, explicit Locale, or Android Resources
+val defaultDistance: String = routeDistance.toDistanceString()
+val localizedDistance: String = routeDistance.toDistanceString(context.resources)
+val ukDistance: String = 1.miles.toDistanceString(Locale.UK) // "1.0 mi"
+val ukElevation: String = 1345.meters.toElevationString(Locale.UK) // "1,345 m"
+
+// 2. Override both distance and elevation to Metric or Imperial
+val metric = getUnitsConverter(UnitSystem.METRIC)
+val imperial = getUnitsConverter(UnitSystem.IMPERIAL)
+
+val shortWalk = 450.meters.toDistanceString(metric) // "450 m"
+val quarterMile = 0.25.miles.toDistanceString(imperial) // "0.3 mi"
+val mediumDrive = 2.0.kilometers.toDistanceString(metric) // "2.0 km"
+val longTrip = 12.4.kilometers.toDistanceString(metric) // "12 km"
+val summit = summitElevation.toElevationString(imperial) // "14,440 ft"
+
+// 3. Override only distance or only elevation
+val usMetricElevation = getUnitsConverter(Locale.US, elevationUnits = UnitSystem.METRIC)
+val ukImperialElevation = getUnitsConverter(Locale.UK).withElevationUnits(UnitSystem.IMPERIAL)
+val hybridBySystem = UnitsConverter(
+ distanceUnits = UnitSystem.IMPERIAL,
+ elevationUnits = UnitSystem.METRIC,
+)
+
+// 4. Mix & match converters or supply custom MeasurementFormatter lambdas
+val customConverter = UnitsConverter(
+ distanceFormatter = { meters, symbols ->
+ "${symbols.formatFixedDecimal(meters.inKilometers, decimals = 2)} km"
+ },
+ elevationConverter = MetricUnitsConverter,
+)
+val customElevation = getUnitsConverter().withElevationFormatter { meters, symbols ->
+ "${symbols.formatInteger(meters.value)} MASL"
+}
+```
+
+* **Java Example:**
+```java
+import static com.google.maps.android.Units.feet;
+import static com.google.maps.android.Units.getUnitsConverter;
+import static com.google.maps.android.Units.inFeet;
+import static com.google.maps.android.Units.inKilometers;
+import static com.google.maps.android.Units.kilometers;
+import static com.google.maps.android.Units.meters;
+import static com.google.maps.android.Units.miles;
+import static com.google.maps.android.Units.toDistanceString;
+import static com.google.maps.android.Units.toElevationString;
+
+import com.google.maps.android.ImperialUnitsConverter;
+import com.google.maps.android.MetricUnitsConverter;
+import com.google.maps.android.SphericalUtil;
+import com.google.maps.android.UnitSystem;
+import com.google.maps.android.UnitsConverter;
+import java.util.Locale;
+
+// Express measurements in meters and convert between units
+double twelveFeetInMeters = feet(12); // 3.6576 m
+double elevationInFeet = inFeet(meters(4401.2)); // 14439.63 ft
+
+// 1. Default formatting (Locale or Android Resources)
+double distanceMeters = SphericalUtil.computeDistanceBetween(pointA, pointB);
+String defaultFormatted = toDistanceString(distanceMeters);
+String localizedFormatted = toDistanceString(distanceMeters, context.getResources());
+String ukElevation = toElevationString(meters(1345), Locale.UK); // "1,345 m"
+
+// 2. Override both distance and elevation
+UnitsConverter imperial = getUnitsConverter(UnitSystem.IMPERIAL);
+String quarterMile = imperial.toDistanceString(miles(0.25)); // "0.3 mi"
+String mediumDrive = imperial.toDistanceString(kilometers(2.0)); // "1.2 mi"
+String summit = imperial.toElevationString(meters(4401.2)); // "14,440 ft"
+
+// 3. Override only distance or only elevation
+UnitsConverter usMetricElevation = getUnitsConverter(Locale.US)
+ .withElevationUnits(UnitSystem.METRIC);
+UnitsConverter hybridBySystem = UnitsConverter.create(
+ UnitSystem.IMPERIAL,
+ UnitSystem.METRIC
+);
+
+// 4. Mix & match converters or supply custom MeasurementFormatter lambdas
+UnitsConverter ukConverter = UnitsConverter.create(
+ ImperialUnitsConverter.INSTANCE,
+ MetricUnitsConverter.INSTANCE
+);
+UnitsConverter customConverter = UnitsConverter.create(
+ (meters, symbols) -> symbols.formatFixedDecimal(inKilometers(meters), 2) + " km",
+ MetricUnitsConverter.INSTANCE::toElevationString
+);
+```
+
## 3. Best Practices
1. **Performance:** For massive datasets (e.g., parsing huge GeoJSON files), do not block the main thread. Parse on a background dispatcher and only call `layer.addLayerToMap()` on the UI thread.
2. **Custom Renderers:** If the user wants custom cluster icons, extend `DefaultClusterRenderer` and override `onBeforeClusterItemRendered` and `onBeforeClusterRendered`.