Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/*
* Copyright (C) 2026 Open Transit Software Foundation
*
* 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 org.onebusaway.android.ui.arrivals

import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.semantics.getOrNull
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performSemanticsAction
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.onebusaway.android.R
import org.onebusaway.android.ui.arrivals.components.ArrivalRowCallbacks
import org.onebusaway.android.ui.arrivals.components.RouteArrivalRow
import org.onebusaway.android.ui.arrivals.components.previewArrival

class RouteArrivalRowLongPressTest {

// See EtaStripJustifyTest for why the v1 (Unconfined) rule is used here (issue #1792).
@Suppress("DEPRECATION")
@get:Rule
val composeRule = createComposeRule()
Comment on lines +40 to +42

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n -C2 '`@Suppress`\("DEPRECATION"\)|createComposeRule\s*\(' \
  onebusaway-android

Repository: OneBusAway/onebusaway-android

Length of output: 15365


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== RouteArrivalRowLongPressTest.kt ==\n'
sed -n '1,120p' onebusaway-android/src/androidTest/java/org/onebusaway/android/ui/arrivals/RouteArrivalRowLongPressTest.kt

printf '\n== Nearby compose tests with similar suppression ==\n'
sed -n '1,120p' onebusaway-android/src/androidTest/java/org/onebusaway/android/ui/arrivals/ArrivalAlertIndicatorTest.kt
printf '\n---\n'
sed -n '1,120p' onebusaway-android/src/androidTest/java/org/onebusaway/android/ui/arrivals/ArrivalsPanelHeaderTest.kt
printf '\n---\n'
sed -n '1,120p' onebusaway-android/src/androidTest/java/org/onebusaway/android/ui/compose/components/SlideBoxTest.kt
printf '\n---\n'
sed -n '1,120p' onebusaway-android/src/androidTest/java/org/onebusaway/android/ui/arrivals/EtaStripJustifyTest.kt

Repository: OneBusAway/onebusaway-android

Length of output: 20852


Document this suppression or remove it. @Suppress("DEPRECATION") needs the required one-line rationale and tracking issue link here, matching the nearby Compose tests; otherwise it violates the Kotlin warning policy.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@onebusaway-android/src/androidTest/java/org/onebusaway/android/ui/arrivals/RouteArrivalRowLongPressTest.kt`
around lines 37 - 39, Update the `@Suppress`("DEPRECATION") annotation on
composeRule in RouteArrivalRowLongPressTest to include the required one-line
rationale and tracking issue link, matching the format used by nearby Compose
tests; retain the suppression only if the deprecated API remains necessary.

Source: Coding guidelines


@Test
fun longPressOpensScheduleWithoutOverflowButton() {
val trip = previewArrival("40", "Northgate", etaMinutes = 3)
val scheduleUrl = "https://example.com/routes/40"
var openedScheduleUrl: String? = null
val callbacks = ArrivalRowCallbacks(
onRouteFavorite = {},
onShowVehiclesOnMap = {},
onEtaClick = {},
onShowTripStatus = {},
onSetReminder = {},
onShowRouteSchedule = { openedScheduleUrl = it },
onReportArrivalProblem = {},
onShowAlert = {},
)
val actions = ArrivalActions(
tripId = trip.tripId,
routeId = trip.routeId,
routeShortName = trip.shortName.orEmpty(),
routeLongName = "Downtown Seattle",
routeColor = 0xFF0A5B3E.toInt(),
scheduleUrl = scheduleUrl,
agencyName = null,
blockId = null,
)

composeRule.setContent {
RouteArrivalRow(
group = RouteRowGroup(listOf(trip)),
actionsFor = { actions },
isFavorite = false,
callbacks = callbacks,
)
}

val context = InstrumentationRegistry.getInstrumentation().targetContext
// The route-level action moved off a dedicated overflow button and onto the row's long press,
// so the overflow affordance must be gone.
composeRule.onAllNodesWithContentDescription(
context.getString(R.string.stop_info_item_options_title)
).assertCountEquals(0)

// Drive the long press through the row's OnLongClick *semantics action* rather than an injected
// longClick() gesture. combinedClickable registers the same lambda as both the gesture and the
// accessibility action, so invoking the action exercises the real wiring — but deterministically,
// without depending on the long-press timeout elapsing against the test's virtual frame clock
// (that timing was racy on the CI emulator: the hold sometimes registered as a plain tap, so the
// menu never opened). onLongClickLabel disambiguates the row's schedule action from the ETA
// pill's own trip-actions long press.
val scheduleLabel = context.getString(R.string.bus_options_menu_show_route_schedule)
composeRule.onNode(
SemanticsMatcher("has long-press action labeled \"$scheduleLabel\"") { node ->
node.config.getOrNull(SemanticsActions.OnLongClick)?.label == scheduleLabel
}
).performSemanticsAction(SemanticsActions.OnLongClick)

// The centered menu opens in a Dialog (a separate window); on slower devices (e.g. the CI
// emulator) the main composition can report idle a frame before that window is laid out, so
// wait for the schedule item to appear before acting on it rather than asserting immediately.
composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodesWithText(scheduleLabel).fetchSemanticsNodes().isNotEmpty()
}
composeRule.onNodeWithText(scheduleLabel).performClick()

assertEquals(scheduleUrl, openedScheduleUrl)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ package org.onebusaway.android.ui.arrivals.components

import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
Expand All @@ -31,7 +32,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
Expand All @@ -48,6 +48,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
Expand All @@ -70,11 +71,13 @@ import org.onebusaway.android.models.Status
import org.onebusaway.android.time.ServerTime
import org.onebusaway.android.ui.arrivals.ArrivalActions
import org.onebusaway.android.ui.arrivals.ArrivalInfo
import org.onebusaway.android.ui.arrivals.RouteRowGroup
import org.onebusaway.android.ui.compose.components.CenteredLongPressMenu
import org.onebusaway.android.ui.compose.components.DirectionHeadsign
import org.onebusaway.android.ui.compose.components.FavoriteStarButton
import org.onebusaway.android.ui.compose.components.LineBadge
import org.onebusaway.android.ui.compose.components.MaterialSymbols
import org.onebusaway.android.ui.compose.components.rememberRouteBadgeColors
import org.onebusaway.android.ui.arrivals.RouteRowGroup
import org.onebusaway.android.ui.compose.theme.ObaTheme
import org.onebusaway.android.util.DisplayFormat

Expand Down Expand Up @@ -245,8 +248,7 @@ internal fun ArrivalRowContent(
* - The top-left corner star toggles the route favorite ([ArrivalRowCallbacks.onRouteFavorite]).
* - The badge section's top-right corner (by the divider) shows a service-alert warning glyph when
* any trip in the group is affected by an active alert; tapping it opens that alert ([ArrivalRowCallbacks.onShowAlert]).
* - The top-right overflow ⋮ opens the route-level menu (schedule), shown only when the route has a
* schedule URL.
* - Long-pressing the row body opens the route-level menu (schedule) when the route has a schedule URL.
*
* [actionsFor] resolves each trip's [ArrivalActions] (keyed by trip id upstream); the representative
* trip's actions drive the badge color and the route menu. [etaAnchor] is attached to the first pill
Expand All @@ -273,6 +275,10 @@ fun RouteArrivalRow(
val direction = group.headsign?.takeIf { it.isNotBlank() } ?: routeActions?.routeLongName.orEmpty()
val onAlertClick = alertClick(group, actionsFor, callbacks)
val selectionColor = mapRouteColor ?: routeActions?.routeColor
val scheduleUrl = routeActions?.scheduleUrl?.takeIf { it.isNotBlank() }
val scheduleActionLabel = if (scheduleUrl != null) {
stringResource(R.string.bus_options_menu_show_route_schedule)
} else null
val displayedRouteNames = selectedRouteNames.takeIf { selected }.orEmpty()
val compoundBadge = displayedRouteNames.size > 1
val selectionBorder = selectionColor
Expand All @@ -285,10 +291,13 @@ fun RouteArrivalRow(
.fillMaxWidth()
// Give the row a definite height (its tallest child) so the divider can fill it.
.height(IntrinsicSize.Min)
// Tapping the row body frames the whole route on the map (the pills below focus
// individual trips instead).
.clickable { callbacks.onShowVehiclesOnMap(representative) }
// A little top/end room so the badge and pills clear the overlaid overflow icon.
// Tap frames the whole route; long press opens its schedule menu. The ETA pills
// remain independent children with their own trip-specific tap/long-press actions.
.combinedClickable(
onClick = { callbacks.onShowVehiclesOnMap(representative) },
onLongClickLabel = scheduleActionLabel,
onLongClick = if (scheduleUrl != null) ({ menuExpanded = true }) else null,
)
.padding(
start = 10.dp,
top = ROW_VERTICAL_PADDING,
Expand Down Expand Up @@ -339,11 +348,7 @@ fun RouteArrivalRow(
Spacer(Modifier.width(10.dp))
Column(Modifier.weight(1f)) {
if (direction.isNotBlank()) {
// Only the header needs its own clearance from the overlaid overflow icon
// (it sits right under it); the ETA strip below reaches the row's true end —
// its own trailing chevron gutter already reserves that room, and the pills
// sit low enough in the row to clear the icon vertically.
DirectionHeadsign(direction, modifier = Modifier.padding(end = OVERFLOW_ICON_CLEARANCE))
DirectionHeadsign(direction)
Spacer(Modifier.height(6.dp))
}
EtaStrip(
Expand Down Expand Up @@ -386,24 +391,13 @@ fun RouteArrivalRow(
)
}
}
// The route-level overflow menu now offers only "show route schedule", so it appears only
// when the route has a schedule URL — no empty menu.
val scheduleUrl = routeActions?.scheduleUrl?.takeIf { it.isNotBlank() }
if (scheduleUrl != null) {
Box(Modifier.align(Alignment.TopEnd)) {
CornerIcon(
iconRes = R.drawable.more_vert,
contentDescription = stringResource(R.string.stop_info_item_options_title),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
onClick = { menuExpanded = true }
)
RouteActionsMenu(
expanded = menuExpanded,
onDismiss = { menuExpanded = false },
scheduleUrl = scheduleUrl,
callbacks = callbacks,
)
}
RouteActionsMenu(
expanded = menuExpanded,
onDismiss = { menuExpanded = false },
scheduleUrl = scheduleUrl,
callbacks = callbacks,
)
}
}
}
Expand All @@ -424,19 +418,14 @@ internal fun alertClick(
): (() -> Unit)? =
group.activeAlertSituationId(actionsFor)?.let { id -> { callbacks.onShowAlert(id) } }

/** Horizontal clearance [RouteArrivalRow] gives the direction header so it doesn't run under the
* overlaid corner icon below ([CornerIcon]'s own footprint is 18dp + 4dp padding on each side —
* this is a bit tighter, tuned by eye against a device screenshot rather than derived from it). */
private val OVERFLOW_ICON_CLEARANCE = 20.dp

/** The arrival row's top/bottom padding. The corner alert glyph offsets up by this amount to cancel
* it, so its top lines up with the favorite star (which floats above this padding at the card top);
* keep the two in sync via this single value rather than a bare literal on each side. */
private val ROW_VERTICAL_PADDING = 8.dp

private val CORNER_ICON_HITBOX_SIZE = 26.dp

/** A small fixed tap target tucked into a card corner — the legacy star / overflow / close icons. */
/** A small fixed tap target tucked into a card corner — currently the selected-route close icon. */
@Composable
private fun CornerIcon(
iconRes: Int,
Expand All @@ -458,27 +447,31 @@ private fun CornerIcon(
)
}

/** The route-level overflow menu (the row's ⋮): open the route's schedule. The route's star lives as
* the row's own corner toggle ([FavoriteStarButton]); per-trip actions live on each pill's long-press
* menu ([TripActionsMenu]). Shown only when the route has a [scheduleUrl]. */
/** The route-level long-press menu: open the route's schedule. The route's star lives as the row's own
* corner toggle ([FavoriteStarButton]); per-trip actions live on each pill's long-press menu
* ([TripActionsMenu]). Shown only when the route has a [scheduleUrl]. */
@Composable
internal fun RouteActionsMenu(
expanded: Boolean,
onDismiss: () -> Unit,
scheduleUrl: String,
callbacks: ArrivalRowCallbacks
) {
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
MenuRow(R.string.bus_options_menu_show_route_schedule) {
CenteredLongPressMenu(expanded = expanded, onDismissRequest = onDismiss) {
MenuRow(R.string.bus_options_menu_show_route_schedule, MaterialSymbols.Schedule) {
onDismiss(); callbacks.onShowRouteSchedule(scheduleUrl)
}
}
}

/** A dropdown item that just shows a string resource; shared by the per-arrival and overflow menus. */
/** A labelled menu item with an optional decorative leading [icon]. */
@Composable
internal fun MenuRow(textRes: Int, onClick: () -> Unit) {
DropdownMenuItem(text = { Text(stringResource(textRes)) }, onClick = onClick)
internal fun MenuRow(textRes: Int, icon: ImageVector? = null, onClick: () -> Unit) {
DropdownMenuItem(
text = { Text(stringResource(textRes)) },
onClick = onClick,
leadingIcon = icon?.let { { Icon(imageVector = it, contentDescription = null) } },
)
}

private val PillShape = RoundedCornerShape(6.dp)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
Expand Down Expand Up @@ -74,6 +73,8 @@ import org.onebusaway.android.time.ServerTime
import org.onebusaway.android.time.rememberLiveServerTime
import org.onebusaway.android.ui.arrivals.ArrivalActions
import org.onebusaway.android.ui.arrivals.ArrivalInfo
import org.onebusaway.android.ui.compose.components.CenteredLongPressMenu
import org.onebusaway.android.ui.compose.components.MaterialSymbols
import org.onebusaway.android.ui.compose.components.SlideBox
import org.onebusaway.android.ui.compose.theme.ObaTheme
import org.onebusaway.android.util.DisplayFormat
Expand Down Expand Up @@ -313,7 +314,7 @@ private fun EtaPillWithMenu(
}

/** The per-trip menu opened by long-pressing a pill: trip details, a reminder, or a problem report
* for that specific trip. Route-wide actions live on the row's menu ([RouteActionsMenu]). */
* for that specific trip. Route-wide actions live on the row's long-press menu ([RouteActionsMenu]). */
@Composable
internal fun TripActionsMenu(
expanded: Boolean,
Expand All @@ -322,15 +323,15 @@ internal fun TripActionsMenu(
actions: ArrivalActions?,
callbacks: ArrivalRowCallbacks
) {
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
MenuRow(R.string.bus_options_menu_show_trip_details) {
CenteredLongPressMenu(expanded = expanded, onDismissRequest = onDismiss) {
MenuRow(R.string.bus_options_menu_show_trip_details, MaterialSymbols.TripStatus) {
onDismiss(); callbacks.onShowTripStatus(arrival)
}
MenuRow(R.string.bus_options_menu_set_reminder) {
MenuRow(R.string.bus_options_menu_set_reminder, MaterialSymbols.AddReminder) {
onDismiss(); callbacks.onSetReminder(arrival)
}
if (actions != null) {
MenuRow(R.string.bus_options_menu_report_trip_problem) {
MenuRow(R.string.bus_options_menu_report_trip_problem, MaterialSymbols.Report) {
onDismiss(); callbacks.onReportArrivalProblem(actions)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/*
* Copyright (C) 2026 Open Transit Software Foundation
*
* 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 org.onebusaway.android.ui.compose.components

import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog

/**
* A secondary-action menu opened by long press. A dialog supplies one consistent screen-centered
* position regardless of which row or horizontally scrolled ETA pill launched it; the content stays
* ordinary Material [androidx.compose.material3.DropdownMenuItem] rows.
*/
@Composable
internal fun CenteredLongPressMenu(
expanded: Boolean,
onDismissRequest: () -> Unit,
content: @Composable ColumnScope.() -> Unit,
) {
if (!expanded) return
Dialog(onDismissRequest = onDismissRequest) {
Surface(
modifier = Modifier.widthIn(min = 196.dp, max = 320.dp),
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceContainer,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
tonalElevation = 3.dp,
shadowElevation = 8.dp,
) {
Column(Modifier.padding(vertical = 8.dp), content = content)
}
}
}
Loading