Skip to content
Open
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
4 changes: 4 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,10 @@ jobs:
--dart-define=YABUS_GOOGLE_IOS_CLIENT_ID=${{ env.YABUS_GOOGLE_IOS_CLIENT_ID }}
--dart-define=APP_NIGHTLY_ARTIFACT=android-apk-release

- name: Test Android widget ETA and rendering
working-directory: android
run: ./gradlew :app:testDebugUnitTest --tests 'tw.avianjay.taiwanbus.flutter.*Widget*Test'
Comment thread
steventeng2022 marked this conversation as resolved.

- name: Upload Android artifact
uses: actions/upload-artifact@v6
with:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,8 @@ object FavoriteGroupWidgetSupport {
if (showLoading) {
appWidgetIds.forEach { appWidgetId ->
runCatching {
appWidgetManager.updateAppWidget(
// Update only the status, keeping the previous arrivals visible.
appWidgetManager.partiallyUpdateAppWidget(
appWidgetId,
buildLoadingRemoteViews(appContext, appWidgetId),
)
Expand Down Expand Up @@ -245,10 +246,11 @@ object FavoriteGroupWidgetSupport {
}

private fun buildLoadingRemoteViews(context: Context, appWidgetId: Int): RemoteViews {
val title = loadConfiguredGroup(context, appWidgetId) ?: "YABus"
return buildBaseRemoteViews(context, appWidgetId, title).apply {
setViewVisibility(R.id.favorite_widget_empty, View.VISIBLE)
setTextViewText(R.id.favorite_widget_empty, "更新中...")
return RemoteViews(context.packageName, R.layout.favorite_group_widget).apply {
setTextViewText(
R.id.favorite_widget_updated_at,
"${formatLastUpdated(context, loadLastUpdated(context, appWidgetId))} · 更新中...",
)
}
}

Expand All @@ -265,25 +267,36 @@ object FavoriteGroupWidgetSupport {
return WidgetRenderResult(views, updateTimestamp = true)
}

val boardingItems = items.filter { it.type == "boarding" }
val visibleItems = items.take(MAX_WIDGET_ITEMS)
val boardingItems = visibleItems.filter { it.type == "boarding" }
val liveStopsByRoute = linkedMapOf<String, Map<String, WidgetLiveStop>>()
val failedRoutes = mutableSetOf<String>()
val failedStations = mutableSetOf<String>()
var successfulFetches = 0
boardingItems.associateBy(::routeRequestKey).forEach { (requestKey, item) ->
val fetchResult = fetchLiveStopMap(context, item)
if (fetchResult.success) {
successfulFetches += 1
} else {
failedRoutes += requestKey
}
liveStopsByRoute[requestKey] = fetchResult.liveStops
}
val stationArrivals = linkedMapOf<String, WidgetStationArrival?>()
items.filter { it.type == "station" }.forEach { item ->
visibleItems.filter { it.type == "station" }.associateBy(::stationRequestKey).forEach { (requestKey, item) ->
val fetchResult = fetchStationArrival(item)
if (fetchResult.success) successfulFetches += 1
stationArrivals[stationRequestKey(item)] = fetchResult.arrival
else failedStations += requestKey
stationArrivals[requestKey] = fetchResult.arrival
}

views.removeAllViews(R.id.favorite_widget_items_container)
items.take(MAX_WIDGET_ITEMS).forEach { item ->
visibleItems.forEach { item ->
val fetchFailed = when (item.type) {
"boarding" -> routeRequestKey(item) in failedRoutes
"station" -> stationRequestKey(item) in failedStations
else -> false
}
val liveStop = if (item.type == "boarding") {
liveStopsByRoute[routeRequestKey(item)]?.get("${item.pathId}:${item.stopId}")
} else {
Expand All @@ -293,7 +306,7 @@ object FavoriteGroupWidgetSupport {
val itemViews = RemoteViews(context.packageName, R.layout.favorite_group_widget_item)
itemViews.setTextViewText(
R.id.favorite_widget_item_eta,
formatEtaText(liveStop),
if (fetchFailed) "更新失敗" else widgetEtaText(liveStop?.sec, liveStop?.msg),
)
itemViews.setTextViewText(
R.id.favorite_widget_item_route,
Expand All @@ -308,7 +321,7 @@ object FavoriteGroupWidgetSupport {
when (item.type) {
"route" -> item.routeDescription.orEmpty().ifBlank { item.provider.uppercase() }
"station" -> stationArrival?.let { "${it.routeName} · ${it.sideLabel} 側" }
?: "目前沒有即將抵達班次"
?: if (fetchFailed) "無法取得班次,請重試" else "目前沒有即將抵達班次"
else -> item.stopName.ifBlank { "站牌 ${item.stopId}" }
},
)
Expand All @@ -327,9 +340,15 @@ object FavoriteGroupWidgetSupport {
views.addView(R.id.favorite_widget_items_container, itemViews)
}

val hasFailures = failedRoutes.isNotEmpty() || failedStations.isNotEmpty()
if (hasFailures) {
views.setViewVisibility(R.id.favorite_widget_empty, View.VISIBLE)
views.setTextViewText(R.id.favorite_widget_empty, "部分資料更新失敗,請點右上角重試。")
}
return WidgetRenderResult(
views = views,
updateTimestamp = successfulFetches > 0,
// The timestamp describes the last complete refresh, not partial success.
updateTimestamp = successfulFetches > 0 && !hasFailures,
)
}

Expand Down Expand Up @@ -738,22 +757,6 @@ object FavoriteGroupWidgetSupport {
return null
}

private fun formatEtaText(liveStop: WidgetLiveStop?): String {
liveStop ?: return "--"
val message = liveStop.msg?.trim().orEmpty()
if (message.isNotEmpty()) {
return message
}
val seconds = liveStop.sec ?: return "--"
if (seconds <= 0) {
return "進站中"
}
if (seconds < 60) {
return "即將進站"
}
return "${seconds / 60}分"
}

private fun formatLastUpdated(
context: Context,
timestamp: Long?,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package tw.avianjay.taiwanbus.flutter

/** Keep unavailable estimates distinct from a bus actually entering the stop. */
internal fun widgetEtaText(seconds: Int?, message: String?): String {
val normalizedMessage = message?.trim().orEmpty()
if (normalizedMessage.isNotEmpty() && !normalizedMessage.equals("null", ignoreCase = true)) {
return normalizedMessage
}
return when {
seconds == null || seconds < 0 -> "--"
seconds == 0 -> "進站中"
seconds < 60 -> "即將進站"
else -> "${seconds / 60}分"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package tw.avianjay.taiwanbus.flutter

import android.content.Context
import android.view.View
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.TextView
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config

@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28])
class FavoriteWidgetRenderingTest {
private val context: Context = RuntimeEnvironment.getApplication()

@Test
fun failedStationIsNotReportedAsNoUpcomingBus() {
// Blank provider fails locally, with no network request.
val result = render(listOf(item("station")))
val root = result.views.apply(context, FrameLayout(context))
assertEquals("更新失敗", root.findViewById<TextView>(R.id.favorite_widget_item_eta).text.toString())
assertEquals("無法取得班次,請重試", root.findViewById<TextView>(R.id.favorite_widget_item_stop).text.toString())
assertEquals(View.VISIBLE, root.findViewById<View>(R.id.favorite_widget_empty).visibility)
assertFalse(result.updateTimestamp)
}

@Test
fun hiddenEntriesDoNotTriggerFailures() {
val result = render(List(6) { item("route") } + item("station"))
val root = result.views.apply(context, FrameLayout(context))
assertEquals(6, root.findViewById<LinearLayout>(R.id.favorite_widget_items_container).childCount)
assertEquals(View.GONE, root.findViewById<View>(R.id.favorite_widget_empty).visibility)
}

private fun render(items: List<FavoriteWidgetItem>): WidgetRenderResult {
val method = FavoriteGroupWidgetSupport::class.java.getDeclaredMethod(
"buildContentRemoteViews", Context::class.java, Int::class.javaPrimitiveType,
String::class.java, List::class.java,
)
method.isAccessible = true
return method.invoke(FavoriteGroupWidgetSupport, context, 1, "Test", items) as WidgetRenderResult
}

private fun item(type: String) = FavoriteWidgetItem(
type = type, provider = "", routeKey = 1, pathId = 0, stopId = 1,
routeId = null, routeName = "Test route", routeDescription = null,
stopName = "Test stop", stationId = "test", stationName = "Test station",
destinationPathId = null, destinationStopId = null, destinationStopName = null,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package tw.avianjay.taiwanbus.flutter

import org.junit.Assert.assertEquals
import org.junit.Test

class WidgetEtaTextTest {
@Test
fun unavailableEstimatesAreNotArriving() {
listOf<Int?>(null, -1, -60, Int.MIN_VALUE).forEach { seconds ->
assertEquals("--", widgetEtaText(seconds, null))
}
}

@Test
fun arrivalBoundaries() {
assertEquals("進站中", widgetEtaText(0, null))
assertEquals("即將進站", widgetEtaText(1, null))
assertEquals("即將進站", widgetEtaText(59, null))
assertEquals("1分", widgetEtaText(60, null))
assertEquals("1分", widgetEtaText(119, null))
assertEquals("2分", widgetEtaText(120, null))
}

@Test
fun meaningfulMessagesTakePrecedence() {
assertEquals("未發車", widgetEtaText(-1, " 未發車 "))
assertEquals("末班已過", widgetEtaText(0, "末班已過"))
}

@Test
fun emptyAndJsonNullMessagesDoNotHideEstimates() {
listOf<String?>(null, "", " ", "null", " NULL ").forEach { message ->
assertEquals("2分", widgetEtaText(120, message))
assertEquals("--", widgetEtaText(-1, message))
}
}
}
Loading