Skip to content

feat: add Units API for distance and elevation conversion and formatting - #1832

Open
dkhawk wants to merge 7 commits into
mainfrom
feat/units
Open

dkhawk wants to merge 7 commits into
mainfrom
feat/units

Conversation

@dkhawk

@dkhawk dkhawk commented Oct 22, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a public Units API (com.google.maps.android) to :library for type-safe distance and elevation representation, framework-agnostic unit system resolution, and KMP-compatible logical precision formatting.

Key Additions

  • Physical Conversion Constants: Exact international foot (METERS_PER_FOOT = 0.3048, FEET_PER_METER = 1.0 / METERS_PER_FOOT) and statute mile (FEET_PER_MILE = 5_280, METERS_PER_MILE = 1609.344, MILES_PER_METER) constants alongside METERS_PER_KILOMETER = 1_000.
  • Meters Inline Value Class: @JvmInline value class Meters(val value: Double) : Comparable<Meters> with +, -, *, /, unary -, Number construction extensions (.meters/.m, .kilometers/.km, .feet/.ft, .miles/.mi), and scalar conversions (.toMeters, .toKilometers, .toFeet, .toMiles).
  • Framework-Agnostic UnitSystem & NumberFormatSymbols:
    • UnitSystem.METRIC and UnitSystem.IMPERIAL with fromLocale(locale = Locale.getDefault()) and fromCountryCode(countryCode) defaulting "US", "LR", "MM", and "GB" to IMPERIAL and all others to METRIC.
    • NumberFormatSymbols for KMP-friendly locale grouping and decimal separator resolution without relying on java.util.Formatter (String.format).
  • Logical Precision Formatting (UnitsConverter, MetricUnitsConverter, ImperialUnitsConverter):
    • Switches from ft to mi at 0.25 miles (1,320 feet) and from m to km at 1,000 meters (1.0 km).
    • Formats small units (m below 1 km, ft below 0.25 mi, and all elevations) as whole integers (0 decimals, with locale thousands grouping, e.g., "450 m", "528 ft", "14,440 ft").
    • Formats medium distances (0.25 to < 10 mi, 1.0 to < 10 km) with 1 decimal digit, keeping the trailing .0 (e.g., "0.3 mi", "1.0 km", "2.0 km", "2.5 mi").
    • Formats large distances (>= 10 km or >= 10 mi) as whole integers (0 decimals, e.g., "10 km", "12 mi").
    • Supports both pure-Kotlin/JVM formatting (Locale / NumberFormatSymbols) and Android Resources formatting (amu_in_*), plus Double overloads for Java callers.
  • Samples & Tests:
    • Added UnitsDemoActivity (Kotlin + Compose + GoogleMap) and updated DistanceDemoActivity (Java).
    • Added comprehensive unit test suites in UnitsTest and UnitsDemoTest.

Test Plan

  • ./gradlew apiCheck spotlessCheck :library:testDebugUnitTest :demo:testStandardDebugUnitTest :library:lintDebug
  • Verified UnitsDemoActivity and DistanceDemoActivity on emulator (emulator-5554) and Pixel 6.

@googlemaps-bot

googlemaps-bot commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Code Coverage

Overall Project 63.69% -0.34% 🍏
Files changed 91.42% 🍏

Module Coverage
Kover Gradle Plugin XML report for :library 90.02% -1.88% 🍏
Files
Module File Coverage
Kover Gradle Plugin XML report for :library Units.kt 91.42% -8.58% 🍏

@dkhawk
dkhawk marked this pull request as ready for review October 26, 2026 19:14
@dkhawk
dkhawk requested a review from kikoso October 26, 2026 19:14
@snippet-bot

snippet-bot Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

Here is the summary of changes.

You are about to add 10 region tags.

This comment is generated by snippet-bot.
If you find problems with this result, please file an issue at:
https://github.com/googleapis/repo-automation-bots/issues.
To update this comment, add snippet-bot:force-run label or use the checkbox below:

  • Refresh this comment

val groupingSize = if (decimalFormat != null && decimalFormat.isGroupingUsed) {
decimalFormat.groupingSize
} else {
3

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the locale does not use grouping we fall back to 3, so we would still group the digits. The KDoc says <= 0 disables grouping, so shouldn't we return 0 here?

Suggested change
3
0

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! Updated NumberFormatSymbols.fromLocale so that when decimalFormat == null || !decimalFormat.isGroupingUsed, groupingSize is set to 0 (disabling grouping) rather than falling back to 3. Also added a unit test verifying that NumberFormatSymbols.fromLocale returns groupingSize = 0 when isGroupingUsed == false.

override val unitSystem: UnitSystem = UnitSystem.METRIC

override fun toDistanceUnits(meters: Meters): ValueWithUnitsTemplate =
if (abs(meters.value) < KILOMETERS_THRESHOLD.value) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is an edge case right below the threshold: 999.6 m stays in the meters branch but rounds up when formatted, so we get "1,000 m" instead of "1.0 km" (same for 1,319.6 ft, which shows as "1,320 ft" instead of "0.3 mi"). We already handle this for the 10 km/mi switch with isUnderTenAtOneDecimal. Could we compare the rounded value here too, and add a test near the threshold?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great catch on the boundary rounding! Updated both MetricUnitsConverter.toDistanceString and ImperialUnitsConverter.toDistanceString to compare the rounded integer value in small units (abs(metersValue).roundToLong() < METERS_PER_KILOMETER and abs(feet).roundToLong() < THRESHOLD_FEET, clamping the miles magnitude to at least 0.25 once rounded feet reach 1,320 ft). Now 999.6.meters formats as "1.0 km" instead of "1,000 m", and 1_319.6.feet formats as "0.3 mi" instead of "1,320 ft". Added unit tests for both boundaries in UnitsTest and UnitsJavaTest.

Comment thread library/src/main/res/values/strings.xml Outdated
Distance or elevation in feet, formatted to 0 decimal places.
For example, 1234.56 feet will be formatted as "1,235 ft".
-->
<string name="amu_in_feet" translatable="false">%1$,.0f ft</string>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to understand it: since all the templates are translatable="false", the Resources overloads produce the same output as the pure Kotlin path (formatWithTemplate), and we end up with two code paths to keep in sync. Should we either make the unit labels translatable, or drop the Resources overloads and the resource IDs from the public API?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great observation! We went with the first option (making the unit templates translatable while unifying onto a single number-formatting code path):

  1. Translatable %1$s Unit Templates (amu_in_feet, amu_in_miles, amu_in_meters, amu_in_kilometers): Removed translatable="false", switched the placeholders from %1$,.0f / %1$,.1f to %1$s (cutting the resource count from 6 to 4 by dropping amu_in_miles_whole and amu_in_kilometers_whole), and added 24 localized values-<locale>/strings.xml translations (e.g., ja, ko, zh-rCN, zh-rTW, ru, uk, ar, fr, hi, th, el, iw, etc.).
  2. Single Number-Formatting Code Path: Both the pure-Kotlin/KMP NumberFormatSymbols path and the Android Resources path now share the exact same formatDistance / NumberFormatSymbols rounding and grouping logic (ValueWithUnitsTemplate and String.format are gone), and callers can also compose or customize converters via UnitsConverter(...) / UnitsConverter.create(...).

*/
public object ImperialUnitsConverter : UnitsConverter() {
/** Threshold (`0.25` miles = `1,320` feet) at which imperial distances switch from feet to miles. */
public val MILES_THRESHOLD: Meters = 0.25.miles

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since Meters is a value class, the getter gets mangled (getMILES_THRESHOLD-NA-LpbA in library.api) and can't be called from Java. Could we add @get:JvmName here and on KILOMETERS_THRESHOLD, or expose them as Double constants?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added @get:JvmName("getMilesThreshold") on ImperialUnitsConverter.MILES_THRESHOLD and @get:JvmName("getKilometersThreshold") on MetricUnitsConverter.KILOMETERS_THRESHOLD so Java callers can invoke getMilesThreshold() and getKilometersThreshold() without inline value class mangling (verified in library.api and UnitsJavaTest).


/** Shorthand to create a [Meters] measurement from a [Number] of meters. */
@get:JvmName("m")
public inline val Number.m: Meters

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we could drop the short aliases (m, ft, mi, km). Being these extensions on Number in our root package, anyone importing them gets 5.m in autocompletion, and m is quite likely to clash with other libraries. The long forms read well enough, what do you think?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — since com.google.maps.android is the root package, single/two-letter extensions (m, km, ft, mi) on Number are too prone to autocomplete noise and collisions. Removed .m, .km, .ft, and .mi and kept the explicit .meters, .kilometers, .feet, and .miles extensions.


/** Returns the equivalent number of international feet for this [Meters] measurement. */
@get:JvmName("toFeet")
public inline val Meters.toFeet: Double

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: in Kotlin to* names are usually functions, so meters.toFeet reads a bit like it is missing the (). What about inFeet / inMiles / inKilometers, similar to how Duration exposes its values?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed toMeters, toKilometers, toFeet, and toMiles to inMeters, inKilometers, inFeet, and inMiles (including @get:JvmName("inMeters"), etc. for Java static imports) to match Kotlin's Duration naming convention (inWholeMeters / toDouble(DurationUnit)).

* and elevations: United States (`"US"`), Liberia (`"LR"`), Myanmar (`"MM"`), and
* United Kingdom (`"GB"`).
*/
public val IMPERIAL_COUNTRY_CODES: Set<String> = setOf("US", "LR", "MM", "GB")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Out of curiosity, is GB meant to apply to elevations too? In the UK road distances are in miles, but elevations (e.g. Ordnance Survey maps) are in meters, so "14,440 ft" may look odd there. Not a blocker, but maybe worth documenting, or splitting the distance and elevation systems later.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great point! Updated UnitSystem (distanceFromLocale / elevationFromLocale) and getUnitsConverter so that "GB" (Locale.UK) defaults out-of-the-box to Imperial road distances (ft / mi) and Metric elevations (m), and added simple ways to override either distance or elevation (converter.withDistanceUnits(...), converter.withElevationUnits(...), getUnitsConverter(locale, distanceUnits = ..., elevationUnits = ...), and UnitsConverter(distanceUnits, elevationUnits)).

@kikoso kikoso left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @dkhawk ! Nice addition, and very well documented. Can you take a look at the comments? Thanks! Since #1830 (7.0.0) is still open, this feat would ride along with that release, which works for me.

…nits

- Default `groupingSize` to `0` in `NumberFormatSymbols.fromLocale` when `!decimalFormat.isGroupingUsed`
- Fix threshold boundary rounding in `MetricUnitsConverter` (`999.6 m` -> `"1.0 km"`) and `ImperialUnitsConverter` (`1,319.6 ft` -> `"0.3 mi"`)
- Replace `translatable="false"` numeric templates with 4 translatable `%1$s` unit templates (`amu_in_feet`, `amu_in_miles`, `amu_in_meters`, `amu_in_kilometers`), add 24 localized `values-<locale>/strings.xml` translations, and unify `Locale` and `Resources` formatting onto a single `NumberFormatSymbols` code path
- Add `@get:JvmName("getMilesThreshold")` and `@get:JvmName("getKilometersThreshold")` for Java interop
- Remove short `Number.m`, `.km`, `.ft`, and `.mi` extensions and rename `Meters.to*` properties to `inMeters`, `inKilometers`, `inFeet`, and `inMiles`
- Default `"GB"` (`Locale.UK`) out-of-the-box to Imperial road distances (`ft`/`mi`) + Metric elevations (`m`), and add `withDistanceUnits`, `withElevationUnits`, `withDistanceConverter`, `withElevationConverter`, `withDistanceFormatter`, `withElevationFormatter`, and `UnitsConverter(...)` / `UnitsConverter.create(...)` overloads
- Update `README.md`, `llm-integration-prompt.md`, `.gemini/skills/android-maps-utils/SKILL.md`, and unit tests (`UnitsTest`, `UnitsJavaTest`, `UnitsDemoTest`)
@dkhawk
dkhawk requested a review from kikoso October 28, 2026 23:22
@dkhawk

dkhawk commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

I am seeking to have a good general purpose distance / elevation formatting tool that we can use in the scale bar fix.

…esholds for ScaleBar

- Add `trimTrailingZeros: Boolean = false` and `NumberFormatSymbols.COMPACT` (`groupingSize = 0`, `trimTrailingZeros = true`) for compact widgets like `ScaleBar`
- Add `ImperialUnitsConverter(milesThreshold = ...)` / `ImperialUnitsConverter.withMilesThreshold(...)` and `MetricUnitsConverter(kilometersThreshold = ...)` / `MetricUnitsConverter.withKilometersThreshold(...)`
- Add `ScaleBar` distance formatting tests matching `googlemaps/android-maps-compose#1045` (`ScaleBarDistanceTest` and `ScaleBarRenderingTests`)
- Update `library.api` and documentation examples

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants