Skip to content

feat(HillyRoadAlert): add Hilly & Undulating Road Early Alerts sample across Kotlin, Java, and Compose - #98

Open
LoyalAbbas wants to merge 1 commit into
mainfrom
feat/hillyroad_alert
Open

LoyalAbbas wants to merge 1 commit into
mainfrom
feat/hillyroad_alert

Conversation

@LoyalAbbas

Copy link
Copy Markdown
Collaborator

Summary

Adds the Hilly & Undulating (Roller-Coaster) Road Early Alerts showcase sample with full 3-way parity across Kotlin Views, Java Views, and Jetpack Compose.

This sample demonstrates how 3D elevation route profiles (LatLngAltitude with explicit ABSOLUTE altitudes) can be analyzed ahead of a moving vehicle in real time to detect rapid crests, dips, blind summits, and sustained steep climbs/descents before the driver reaches them.


Key Features

  • Lookahead Terrain & Grade Analysis (RoadTerrainAnalyzer):
    • Scans configurable lookahead horizons (250m, 500m, 750m) ahead of the vehicle to detect local elevation peaks (CREST) and troughs (DIP) using vertical prominence hysteresis (when (state) state machine).
    • Computes monotonic-clamped and lookahead-bounded road grades (Max Grade: +X.X% / -Y.Y%) and classifies upcoming hazards (UNDULATING_ROLLER_COASTER, BLIND_CREST, STEEP_CLIMB, STEEP_DESCENT, NONE) with CAUTION / WARNING severity escalation.
    • Calculates reference-only advisory speeds inspired by AASHTO Green Book vertical-curve (sight distance & comfort acceleration) and grade-adjusted braking equations (rounded to 5 km/h increments, with explicit reference-only documentation disclaimer).
  • 3D Map Rendering & Smooth Adaptive Camera (HillyRoadAlertViewModel):
    • Renders the base 3D route polyline, dynamic hazard-zone highlight polyline (CAUTION amber / WARNING red), crest/dip 3D markers, and the 3D red car marker tracking the route.
    • Smoothly adapts camera altitude (lookahead terrain blending + grade-proportional height boost + EMA vertical smoothing) and optional crest camera pitch preview to prevent terrain clipping on steep transitions.
    • Defers playback and telemetry progression until the 3D map is ready (onMapReady) and gracefully handles offline launches (ACCESS_NETWORK_STATE) so the native Maps 3D offline prompt displays without crashing.
  • Live HUD & Collapsible Controls:
    • Real-time HUD alert banner with upcoming hazard distance, crest/dip count, max uphill/downhill grade, reference advisory speed, and live lookahead elevation sparkline (ElevationSparklineView / Compose Canvas) with vehicle, crest, and dip indicators.
    • Compact collapsible Terrain Lookahead Controls bottom sheet with play/pause, route scrubber, Jump to Hills quick action, route preset chips (Roller-Coaster, Mountain Pass, Coastal), lookahead horizon selector, playback speed (0.5x–5x), and crest camera preview toggle.
  • Externalized JSON Route Dataset (hilly_routes.json & HillyRouteData):
    • Stores the three 3D route coordinate traces in Maps3DSamples/ApiDemos/common/src/main/resources/hilly_routes.json and loads them lazily on first access to keep Kotlin sources and .dex static initialization lean.

Architecture & 3-Way Parity

  • Shared Domain, Data & Presentation (:Maps3DSamples:ApiDemos:common):
    • RoadTerrainAnalyzer.kt — Pure Kotlin terrain, extrema, grade, sparkline, and advisory speed engine.
    • HillyRouteData.kt & resources/hilly_routes.json — Lazy-loaded 3D route presets.
    • HillyRoadAlertViewModel.kt — UDF presentation state machine exposing StateFlow (Kotlin/Compose) and LiveData via asLiveData() (Java) synchronized with hardware frame ticks.
    • ElevationSparklineView.kt & res/layout/activity_hilly_road_alerts.xml — Shared custom sparkline view and Material 3 layout for Kotlin & Java Views.
  • Thin UI Implementations:
    • Kotlin Views: Maps3DSamples/ApiDemos/kotlin-app/.../hillyroadalerts/HillyRoadAlertsActivity.kt
    • Java Views: Maps3DSamples/ApiDemos/java-app/.../hillyroadalerts/HillyRoadAlertsActivity.java
    • Jetpack Compose: Maps3DSamples/ComposeDemos/app/.../hillyroadalerts/HillyRoadAlertsActivity.kt

Testing & Verification

  • Added JVM unit tests in :Maps3DSamples:ApiDemos:common:
    • RoadTerrainAnalyzerTest.kt — Verifies flat/gentle control routes, early CAUTION → WARNING escalation on undulating terrain, stable crest/dip IDs and grade deltas, sustained climb/blind crest/descent classification, and zero-distance/duplicate-point guards.
    • HillyRoadAlertViewModelTest.kt — Verifies onMapReady gating, frame-tick progression, route preset switching, horizon updates, scrubbing, and Jump to Hills behavior.
  • Verified ./gradlew spotlessApply, :Maps3DSamples:ApiDemos:common:testDebugUnitTest, and debug compilation across :Maps3DSamples:ApiDemos:kotlin-app, :Maps3DSamples:ApiDemos:java-app, and :Maps3DSamples:ComposeDemos:app.

@LoyalAbbas
LoyalAbbas requested review from dkhawk and kikoso November 6, 2026 08:42

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

@LoyalAbbas Great work on the 3-way parity architecture, lookahead elevation sparkline, and unit tests!

I did a thorough review and found 2 blocking CI failures (build-ApiDemos unit test failure and :Maps3DSamples:ApiDemos:common:lintDebug missing permission errors), along with a few route dataset mismatches in hilly_routes.json, a BLIND_CREST hazard bounds bug in RoadTerrainAnalyzer, and a couple of lifecycle/performance items. I've left inline comments on the specific lines below.

Also, please add entries for Hilly & Undulating Road Alerts to the three module catalog READMEs (Maps3DSamples/ComposeDemos/app/README.md, Maps3DSamples/ApiDemos/kotlin-app/README.md, and Maps3DSamples/ApiDemos/java-app/README.md).

put(R.string.feature_title_camera_restrictions, CameraRestrictionsActivity.class);
put(R.string.feature_title_flight_simulator, FlightSimulatorActivity.class);
put(R.string.feature_title_path_following, PathFollowingActivity.class);
put(R.string.feature_title_hilly_road_alerts, HillyRoadAlertsActivity.class);

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.

Blocking CI Failure (build-ApiDemos): Adding HillyRoadAlertsActivity increased sampleActivities from 24 to 25, causing MainActivityTest > testSampleActivitiesContainsAllSamples (assertThat(samples).hasSize(24)) to fail in :Maps3DSamples:ApiDemos:java-app:testDebugUnitTest.

Please update Maps3DSamples/ApiDemos/java-app/src/test/java/com/example/maps3djava/mainactivity/MainActivityTest.java to hasSize(25) and assert .contains(com.example.maps3djava.hillyroadalerts.HillyRoadAlertsActivity.class).

Sample(R.string.feature_title_camera_restrictions, CameraRestrictionsActivity::class.java),
Sample(R.string.feature_title_flight_simulator, FlightSimulatorActivity::class.java),
Sample(R.string.feature_title_path_following, PathFollowingActivity::class.java),
Sample(R.string.feature_title_hilly_road_alerts, HillyRoadAlertsActivity::class.java),

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.

Unit Test Failure (:Maps3DSamples:ApiDemos:kotlin-app:testDebugUnitTest): Adding HillyRoadAlertsActivity here also increases samples from 24 to 25, which breaks MainActivityTest.kt (assertThat(samples).hasSize(24)).

Please update Maps3DSamples/ApiDemos/kotlin-app/src/test/java/com/example/maps3dkotlin/mainactivity/MainActivityTest.kt to hasSize(25) and assert .contains(com.example.maps3dkotlin.hillyroadalerts.HillyRoadAlertsActivity::class.java).

const val BASE_ROUTE_POLYLINE_ID = "hilly_road_base_polyline"
const val TRAVELED_ROUTE_POLYLINE_ID = "hilly_road_traveled_polyline"
const val HAZARD_ROUTE_POLYLINE_ID = "hilly_road_hazard_polyline"
const val VEHICLE_MARKER_ID = "hilly_road_vehicle_marker"

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.

Unused constant VEHICLE_MARKER_ID (and <string name="hilly_marker_vehicle_label"> in strings.xml:383) — can be removed since the vehicle is rendered using VEHICLE_MODEL_ID.

return try {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
?: return true
val activeNetwork = cm.activeNetwork ?: return false

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.

Blocking CI Failure (:Maps3DSamples:ApiDemos:common:lintDebug): Calling cm.activeNetwork (line 132) and cm.getNetworkCapabilities(activeNetwork) (line 133) inside :Maps3DSamples:ApiDemos:common fails Android Lint with 4 MissingPermission errors because Maps3DSamples/ApiDemos/common/src/main/AndroidManifest.xml does not declare <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />.

Declaring <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> in Maps3DSamples/ApiDemos/common/src/main/AndroidManifest.xml will fix :common:lintDebug and automatically merge into the downstream app manifests.

*/
COASTAL_DRIVE(
displayName = "Coastal Drive (Flat)",
subtitle = "Gentle < 2% grade control route"

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.

Dataset vs. Preset Mismatch & Hardcoded Strings:

  1. COASTAL_DRIVE is described here as "Coastal Drive (Flat)" / "Gentle < 2% grade control route", and MOUNTAIN_PASS as "Sustained steep climb & blind summit". However, in hilly_routes.json:
    • coastal_drive (37.8700, -122.5899, Muir Beach Overlook) climbs +6.7% to 145.3m and then drops 65m down to 80.2m at -14.1% grade, triggering BLIND_CREST and STEEP_DESCENT (WARNING) instead of acting as the flat < 2% grade control route (TerrainAlertType.NONE).
    • mountain_pass starts at 1380.2m and descends to 1342.4m with 21 closely spaced DEM oscillations (DIP/CREST pairs), causing it to classify as UNDULATING_ROLLER_COASTER rather than STEEP_CLIMB / BLIND_CREST / STEEP_DESCENT.
  2. displayName and subtitle are hardcoded English strings (violating AGENTS.md Rule 4) and are unused by the UI (which uses R.string.hilly_route_*).

LatLngAltitude(37.80600, -122.43780, 8.3),
LatLngAltitude(37.80600, -122.43610, 8.0)
)
val state = RoadTerrainAnalyzer.analyze(

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.

Please also test HillyRouteData.COASTAL_DRIVE_PATH and HillyRouteData.MOUNTAIN_PASS_PATH directly here (in addition to the synthetic test routes) so unit tests verify that the bundled JSON presets in hilly_routes.json produce their intended TerrainAlertType classifications.


override fun onMapReady(googleMap3D: GoogleMap3D) {
super.onMapReady(googleMap3D)
googleMap3D.setMapMode(Map3DMode.HYBRID)

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.

Calling googleMap3D.setMapMode(Map3DMode.HYBRID) directly after super.onMapReady(googleMap3D) overrides Map3DSettingsController.shared (which SampleBaseActivity.onMapReady already initializes with defaultMapMode = Map3DMode.HYBRID). Same in HillyRoadAlertsActivity.java:162.

if (isReady) {
lastFrameTimeNanos = frameTimeNanos
}
Choreographer.getInstance().postFrameCallback(this)

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.

Unconditional 60/120Hz Choreographer Loop & onPause Parity:

  1. doFrame() re-posts Choreographer.getInstance().postFrameCallback(this) on every VSYNC tick even when playback is paused (!viewModel.currentState.isPlaying) or scrubbing. Consider only running the frame callback while isPlaying && !isUserScrubbing (matching Compose's LaunchedEffect and PathFollowingActivity.manageAnimationTicker).
  2. For 3-way parity with Compose (HillyRoadAlertsActivity.kt:164), onPause() in both Kotlin Views and Java Views should also pause playback (viewModel.setPlaying(false)).

getString(
R.string.hilly_marker_toast_format,
markerLabel,
extremum.distanceAheadMeters.roundToInt(),

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.

Stale distanceAheadMeters in Marker Click Toast: Because extrema markers are created once when extremum.id first enters the lookahead horizon (e.g., at ~495m ahead) and cached without recreation, capturing extremum.distanceAheadMeters at marker creation time causes the Toast to show the stale initial distance even when the vehicle is right next to the crest/dip.

Looking up the latest upcomingExtrema entry by extremum.id from viewModel.currentState.terrainAlert.upcomingExtrema inside the click listener (here, in HillyRoadAlertsActivity.java:674, and in Compose HillyRoadAlertsActivity.kt:340) will display the live distance ahead.

/**
* Jetpack Compose showcase activity for **"Hilly & Undulating (Roller-Coaster) Road Early Alerts"**.
*/
class HillyRoadAlertsActivity : ComponentActivity() {

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.

File Size vs. Thin View Guideline (AGENTS.md / ARCHITECTURE.md): At 1,026 lines, this file bundles the Activity, screen, HUD banner, custom sparkline Canvas, and collapsible bottom sheet in one monolithic file (vs. the ~250-line thin-view guideline). Extracting the stateless Compose HUD/Sparkline/BottomControls components into a dedicated file will keep HillyRoadAlertsActivity.kt thin and focused.

return try {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
?: return true
val activeNetwork = cm.activeNetwork ?: return false

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.

This is what makes lint fail: ACCESS_NETWORK_STATE is only declared in the three app manifests, and lint checks common against its own manifest. Since onMapSteady already calls onMapReady(), could we remove isInternetAvailable() together with the uses-permission entries? Otherwise the permission should move to common/src/main/AndroidManifest.xml.

<string name="hilly_alert_distance_clear">Normal Grade</string>
<string name="hilly_telemetry_extrema_format" tools:ignore="PluralsCandidate">%1$d Crests / %2$d Dips</string>
<string name="hilly_telemetry_max_grade_format">Max Grade: +%1$.1f%% / %2$.1f%%</string>
<string name="hilly_telemetry_advisory_speed_format">Advisory: %1$d km/h</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.

I am a bit worried about showing an advisory speed in an official sample. The disclaimer only lives in the KDoc, and on a flat road we display a made-up "Advisory: 80 km/h". Could we drop the speed chip and its calculation? If we want to keep it, we should at least show it only when there is an alert, and say clearly on screen that it is not for driving.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

@kikoso I completely agree with your point. That's why, while creating this advisory speed, I clearly mentioned a disclaimer that it is for reference only : https://github.com/googlemaps-samples/android-maps3d-samples/pull/98/changes#diff-69d1ea93dbc25cd8c23178a9f1445f539113f322c9263e5a6f0cfcdd7f0a9817R924

Still you think we need to remove this ??


// Only start forward elevation & terrain analysis once the 3D map is ready
val terrainAlert = if (isMapReady) {
RoadTerrainAnalyzer.analyze(

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: analyze() rebuilds the segments and extrema for the whole route on every frame, but the route does not change after selectRoute(). Could we precompute them once per route and only slice the lookahead window here?

/**
* Real-world GPS/DEM route trace for the undulating Roller-Coaster road preset.
*/
val SAN_FRANCISCO_HYDE_ST_ROLLER_COASTER: List<LatLngAltitude>

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.

The coordinates for this route are around 36.49, -114.53 (Lake Mead area, Nevada), not Hyde Street in San Francisco. Should we give it a neutral name?

*/
COASTAL_DRIVE(
displayName = "Coastal Drive (Flat)",
subtitle = "Gentle < 2% grade control route"

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: the bundled coastal route goes from ~139 m down to ~82 m over ~1.35 km (around -4% net), so "< 2%" is not accurate. Would it make sense to add a test asserting this route yields NONE, if it is meant as the control?

getString(
R.string.hilly_marker_toast_format,
markerLabel,
extremum.distanceAheadMeters.roundToInt(),

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.

distanceAheadMeters is captured when the marker is created, so the toast keeps showing the same "N m ahead" while the car moves (same in the Java and Compose versions). Could we compute it at click time from the current state?

@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 @LoyalAbbas ! A few things before this can go in. build-ApiDemos fails because MainActivityTest in both java-app and kotlin-app still expects 24 samples, so those need hasSize(25). Since this adds a new sample, let's use feat instead of fix in the title. And in general, ~6,900 lines is a lot for a sample: could we cut it down (one route, a simpler grade scan, no speed model) so it stays easy to follow?

@LoyalAbbas

Copy link
Copy Markdown
Collaborator Author

Hi @LoyalAbbas ! A few things before this can go in. build-ApiDemos fails because MainActivityTest in both java-app and kotlin-app still expects 24 samples, so those need hasSize(25). Since this adds a new sample, let's use feat instead of fix in the title. And in general, ~6,900 lines is a lot for a sample: could we cut it down (one route, a simpler grade scan, no speed model) so it stays easy to follow?

Hi @kikoso Most of this code is shared infrastructure, boilerplate, and common logic needed across samples, with only the route definitions differing. If we were to cut it down, we'd essentially need to split this up by stack level (Java, Kotlin, and Compose). Would you prefer us to break this down by stack level to keep each module smaller?

@LoyalAbbas LoyalAbbas changed the title fix(HillyRoadAlert): add Hilly & Undulating Road Early Alerts sample across Kotlin, Java, and Compose feat(HillyRoadAlert): add Hilly & Undulating Road Early Alerts sample across Kotlin, Java, and Compose Nov 11, 2026
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.

3 participants