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 android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<!-- The trip monitor has to keep polling while the screen is off;
without this the CPU sleeps between location callbacks and the
refresh loop stops until the user next wakes the phone. -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ object AppLaunchConstants {
routeKey: Int,
routeId: String? = null,
pathId: Int? = null,
stopId: Int? = null,
stopId: Long? = null,
destinationPathId: Int? = null,
destinationStopId: Int? = null,
destinationStopId: Long? = null,
): Intent {
return Intent(context, MainActivity::class.java).apply {
putExtra(EXTRA_TARGET, TARGET_ROUTE_DETAIL)
Expand Down Expand Up @@ -100,23 +100,21 @@ object AppLaunchConstants {
if (intent.hasExtra(EXTRA_PATH_ID)) {
payload["pathId"] = intent.getIntExtra(EXTRA_PATH_ID, 0)
}
if (intent.hasExtra(EXTRA_STOP_ID)) {
payload["stopId"] = intent.getIntExtra(EXTRA_STOP_ID, 0)
}
// Stop ids are sent at full width, because Dart keys stops by
// the raw feed id: 高雄's exceed Int, and narrowing them here
// would name a different stop than the app's models hold. The
// native-only fold (`foldStopId`) is for joining the live
// feeds, not for identifying a stop to Flutter.
stopIdExtra(intent, EXTRA_STOP_ID)?.let { payload["stopId"] = it }
val destinationPathId = intent.getIntExtra(
EXTRA_DESTINATION_PATH_ID,
Int.MIN_VALUE,
)
if (destinationPathId != Int.MIN_VALUE) {
payload["destinationPathId"] = destinationPathId
}
val destinationStopId = intent.getIntExtra(
EXTRA_DESTINATION_STOP_ID,
Int.MIN_VALUE,
)
if (destinationStopId != Int.MIN_VALUE) {
payload["destinationStopId"] = destinationStopId
}
stopIdExtra(intent, EXTRA_DESTINATION_STOP_ID)
?.let { payload["destinationStopId"] = it }
payload
}

Expand Down Expand Up @@ -145,6 +143,19 @@ object AppLaunchConstants {
}
}

/**
* Reads a stop id extra as a `Long` whichever width it was stored at.
*
* New intents store a `Long`. Shortcuts pinned to the home screen before
* that keep the `Int` they were created with for as long as they exist, and
* `getLongExtra` answers its default for an extra of another type — which
* would quietly open those shortcuts with no stop selected.
*/
@Suppress("DEPRECATION")
private fun stopIdExtra(intent: Intent, key: String): Long? {
return (intent.extras?.get(key) as? Number)?.toLong()
}

private fun extractAuthCallbackPayload(uri: android.net.Uri?): Map<String, Any?>? {
uri ?: return null
if (uri.scheme?.lowercase() != "yabus" || uri.host?.lowercase() != "auth-callback") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,8 @@ object FavoriteGroupWidgetSupport {
)
}

private fun createItemPendingIntent(
/** The tap intent for one widget row. Internal so a test can read it back. */
internal fun createItemPendingIntent(
context: Context,
item: FavoriteWidgetItem,
): PendingIntent {
Expand All @@ -409,9 +410,12 @@ object FavoriteGroupWidgetSupport {
routeKey = item.routeKey,
routeId = item.routeId,
pathId = item.pathId.takeIf { item.type == "boarding" },
stopId = item.stopId.takeIf { item.type == "boarding" },
// The full-width id, not the folded one: Flutter compares this
// against the ids its own stop models carry, and a folded
// 高雄 id matches nothing there.
stopId = item.launchStopId.takeIf { item.type == "boarding" },
destinationPathId = item.destinationPathId,
destinationStopId = item.destinationStopId,
destinationStopId = item.launchDestinationStopId,
).apply {
data = Uri.parse(
"yabus://route/${item.provider}/${item.routeKey}/${Uri.encode(item.routeId.orEmpty())}",
Expand All @@ -434,9 +438,14 @@ object FavoriteGroupWidgetSupport {
val raw = runCatching {
preferences.getString(FAVORITE_GROUPS_KEY, null)
?: preferences.getString(FAVORITE_GROUPS_FALLBACK_KEY, null)
}.getOrNull() ?: return emptyMap()
}.getOrNull()
return parseFavoriteGroups(raw)
}

/** Reads the favourites Dart saved. Internal so a test can feed it a config. */
internal fun parseFavoriteGroups(raw: String?): Map<String, List<FavoriteWidgetItem>> {
val root = try {
JSONObject(raw)
JSONObject(raw ?: return emptyMap())
} catch (_: Exception) {
return emptyMap()
}
Expand All @@ -449,7 +458,13 @@ object FavoriteGroupWidgetSupport {
val item = groupArray.optJSONObject(index) ?: continue
val type = item.optString("type", "boarding").trim().ifEmpty { "boarding" }
val routeKey = item.optInt("routeKey", 0)
val stopId = item.optInt("stopId", 0)
// The config is written by Dart with the id as Dart holds it,
// which exceeds Int for 高雄. It is needed twice, as two
// different numbers: folded the way the live feed is, for the
// ETA lookup; and at full width, for the tap that launches
// back into Flutter.
val dartStopId = item.opt("stopId")
val stopId = foldStopId(dartStopId)
val routeId = item.optString("routeId", "").trim().takeIf { it.isNotEmpty() }
val stationId = item.optString("stationId", "").trim()
val valid = when (type) {
Expand All @@ -461,19 +476,15 @@ object FavoriteGroupWidgetSupport {
if (!valid) {
continue
}
val destinationStopId = item.optInt("destinationStopId", 0)
.takeIf { it > 0 }
val destinationPathId = if (destinationStopId == null) {
null
} else {
item.optInt("destinationPathId", item.optInt("pathId", 0))
}
val dartDestinationStopId = item.opt("destinationStopId")
val hasDestination = foldStopId(dartDestinationStopId) > 0
groupItems += FavoriteWidgetItem(
type = type,
provider = item.optString("provider", "twn"),
routeKey = routeKey,
pathId = item.optInt("pathId", 0),
stopId = stopId,
launchStopId = launchStopId(dartStopId).takeIf { stopId > 0 },
routeId = routeId,
routeName = item.optString("routeName", ""),
routeDescription = item.optString("routeDescription", "")
Expand All @@ -482,8 +493,13 @@ object FavoriteGroupWidgetSupport {
stopName = item.optString("stopName", ""),
stationId = stationId,
stationName = item.optString("stationName", ""),
destinationPathId = destinationPathId,
destinationStopId = destinationStopId,
destinationPathId = if (hasDestination) {
item.optInt("destinationPathId", item.optInt("pathId", 0))
} else {
null
},
launchDestinationStopId = launchStopId(dartDestinationStopId)
.takeIf { hasDestination },
destinationStopName = item.optString("destinationStopName", "")
.trim()
.takeIf { it.isNotEmpty() },
Expand Down Expand Up @@ -690,21 +706,7 @@ object FavoriteGroupWidgetSupport {
return result
}

private fun parseStopId(raw: Any?): Int {
if (raw is Number) {
return raw.toInt()
}
val text = raw?.toString()?.trim().orEmpty()
val parsed = text.toIntOrNull()
if (parsed != null) {
return parsed
}
var hash = 17
for (char in text) {
hash = (hash * 31 + char.code) and 0x7fffffff
}
return hash
}
private fun parseStopId(raw: Any?): Int = foldStopId(raw)

private fun toIntOrNull(raw: Any?): Int? {
return when (raw) {
Expand Down Expand Up @@ -807,15 +809,18 @@ data class FavoriteWidgetItem(
val provider: String,
val routeKey: Int,
val pathId: Int,
/** Folded, as the live feeds are: only usable for looking up ETAs. */
val stopId: Int,
/** Full-width, as Flutter holds it: what a tap is launched with. */
val launchStopId: Long?,
val routeId: String?,
val routeName: String,
val routeDescription: String?,
val stopName: String,
val stationId: String,
val stationName: String,
val destinationPathId: Int?,
val destinationStopId: Int?,
val launchDestinationStopId: Long?,
val destinationStopName: String?,
) {
val identity: String
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,10 @@ class MainActivity : FlutterActivity() {
val routeKey = call.argument<Int>("routeKey")
val routeId = call.argument<String>("routeId")?.trim().orEmpty()
val pathId = call.argument<Int>("pathId")
val stopId = call.argument<Int>("stopId")
// Stop ids can exceed Int (高雄's are 64-bit), and the channel decodes a
// Dart int to Integer or Long depending on its magnitude, so both are
// accepted rather than letting the larger ones read back as null.
val stopId = call.argument<Number>("stopId")?.toLong()
val routeName = call.argument<String>("routeName")?.trim().orEmpty()
val stopName = call.argument<String>("stopName")?.trim().orEmpty()
val stationId = call.argument<String>("stationId")?.trim().orEmpty()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package tw.avianjay.taiwanbus.flutter

import android.location.Location
import android.os.SystemClock

/**
* When [location] was measured, on the wall clock the ride detector runs on.
*
* A fix's age is read off the monotonic clock, the only one a fix and "now" are
* guaranteed to share: `Location.time` may come from the satellites, the
* network or the system clock, and none of those has to agree with
* `System.currentTimeMillis()`. Taking that age off the current wall-clock time
* puts a fix cached five minutes ago five minutes in the past — where the
* detector can see it is stale — however the device's clock happens to be set.
*
* A location with no monotonic stamp did not come from the platform (the
* session's seed position is built by hand). Its own `time` is used when it has
* a believable one, and it counts as current otherwise.
*/
internal fun rideFixTimestampMs(
location: Location,
nowWallMs: Long = System.currentTimeMillis(),
nowElapsedNanos: Long = SystemClock.elapsedRealtimeNanos(),
): Long {
val fixElapsedNanos = location.elapsedRealtimeNanos
if (fixElapsedNanos > 0L) {
val ageMs = ((nowElapsedNanos - fixElapsedNanos) / NANOS_PER_MILLI).coerceAtLeast(0L)
return nowWallMs - ageMs
}
return location.time.takeIf { it in 1L..nowWallMs } ?: nowWallMs
}

private const val NANOS_PER_MILLI = 1_000_000L
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package tw.avianjay.taiwanbus.flutter

import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.hypot

internal data class RideProjection(
val alongMeters: Double,
val crossTrackMeters: Double,
val segmentIndex: Int,
)

/**
* The stop sequence as a polyline in a local equirectangular plane, with
* cumulative distance, so that the user and every bus reduce to one comparable
* number: metres travelled from the first stop.
*/
internal class RideRouteLine(private val stops: List<TrackingStop>) {
private val originLat = stops.first().lat
private val originLon = stops.first().lon
private val metersPerDegreeLon = EARTH_RADIUS_METERS * cos(originLat * PI / 180.0) * PI / 180.0
private val metersPerDegreeLat = EARTH_RADIUS_METERS * PI / 180.0
private val xs = DoubleArray(stops.size) { (stops[it].lon - originLon) * metersPerDegreeLon }
private val ys = DoubleArray(stops.size) { (stops[it].lat - originLat) * metersPerDegreeLat }
private val cumulativeMeters = DoubleArray(stops.size).also { cumulative ->
for (index in 1 until stops.size) {
cumulative[index] = cumulative[index - 1] +
hypot(xs[index] - xs[index - 1], ys[index] - ys[index - 1])
}
}

/** Metres from the first stop to [stopIndex]. */
fun alongOfStop(stopIndex: Int): Double = cumulativeMeters[stopIndex.coerceIn(0, stops.size - 1)]

fun segmentLengthMeters(segmentIndex: Int): Double {
val index = segmentIndex.coerceIn(0, (stops.size - 2).coerceAtLeast(0))
return if (stops.size < 2) 0.0 else cumulativeMeters[index + 1] - cumulativeMeters[index]
}

fun metersBetween(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
return hypot((lon2 - lon1) * metersPerDegreeLon, (lat2 - lat1) * metersPerDegreeLat)
}

fun metersToStop(latitude: Double, longitude: Double, stopIndex: Int): Double {
val stop = stops[stopIndex.coerceIn(0, stops.size - 1)]
return metersBetween(latitude, longitude, stop.lat, stop.lon)
}

/**
* Projects a fix onto the polyline.
*
* [hintMeters] disambiguates routes that run along the same street twice
* (loops, out-and-back sections): among the segments about as close as the
* closest one, the one nearest the previous along-route position wins.
*/
fun project(latitude: Double, longitude: Double, hintMeters: Double?): RideProjection {
val segmentCount = stops.size - 1
if (segmentCount <= 0) {
return RideProjection(0.0, metersToStop(latitude, longitude, 0), 0)
}
val px = (longitude - originLon) * metersPerDegreeLon
val py = (latitude - originLat) * metersPerDegreeLat
val distances = DoubleArray(segmentCount)
val alongs = DoubleArray(segmentCount)
var closest = 0
for (index in 0 until segmentCount) {
val vx = xs[index + 1] - xs[index]
val vy = ys[index + 1] - ys[index]
val lengthSquared = vx * vx + vy * vy
val t = if (lengthSquared <= 1e-9) {
0.0
} else {
(((px - xs[index]) * vx + (py - ys[index]) * vy) / lengthSquared).coerceIn(0.0, 1.0)
}
distances[index] = hypot(px - (xs[index] + t * vx), py - (ys[index] + t * vy))
alongs[index] = cumulativeMeters[index] +
t * (cumulativeMeters[index + 1] - cumulativeMeters[index])
if (distances[index] < distances[closest]) {
closest = index
}
}
var chosen = closest
if (hintMeters != null) {
for (index in 0 until segmentCount) {
if (distances[index] <= distances[closest] + HINT_SLACK_METERS &&
abs(alongs[index] - hintMeters) < abs(alongs[chosen] - hintMeters)
) {
chosen = index
}
}
}
return RideProjection(alongs[chosen], distances[closest], chosen)
}

fun nearestStopIndex(alongMeters: Double): Int {
var best = 0
for (index in cumulativeMeters.indices) {
if (abs(cumulativeMeters[index] - alongMeters) < abs(cumulativeMeters[best] - alongMeters)) {
best = index
}
}
return best
}

/**
* The last stop reached at [alongMeters]. A vehicle pulling in counts as
* having reached the stop, hence the tolerance. Unlike the nearest stop,
* this only moves forward as the vehicle does, so "stops to go" counts down
* at the stops themselves rather than midway between them.
*/
fun stopIndexAt(alongMeters: Double): Int {
var reached = 0
for (index in cumulativeMeters.indices) {
if (cumulativeMeters[index] <= alongMeters + STOP_REACHED_TOLERANCE_METERS) {
reached = index
}
}
return reached
}

private companion object {
const val EARTH_RADIUS_METERS = 6_371_000.0
const val HINT_SLACK_METERS = 60.0
const val STOP_REACHED_TOLERANCE_METERS = 25.0
}
}
Loading
Loading