Skip to content

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

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

dkhawk wants to merge 5 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.15% -0.65% 🍏
Files changed 80.92% 🍏

Module Coverage
Kover Gradle Plugin XML report for :library 87.96% -3.66% 🍏
Files
Module File Coverage
Kover Gradle Plugin XML report for :library Units.kt 80.92% -19.08% 🍏

@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

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?

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?

*/
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?


/** 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?


/** 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?

* 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.

@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.

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