From 4ba61c1d62b65295dbb718d51dd3d6cf74d2f247 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 02:47:10 +0000 Subject: [PATCH 1/2] Trip: you're a blue dot on the map, not a bus The journey map drew your position with the bus marker. It's now a blue dot with a faint ring for how sure the GPS is. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011C2fAvFFcMRkaF7qSvRA1v --- .../java/nz/aryan/akllive/ui/JourneyScreen.kt | 2 +- app/src/main/java/nz/aryan/akllive/ui/LiveMap.kt | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/nz/aryan/akllive/ui/JourneyScreen.kt b/app/src/main/java/nz/aryan/akllive/ui/JourneyScreen.kt index 40d8474..96e6950 100644 --- a/app/src/main/java/nz/aryan/akllive/ui/JourneyScreen.kt +++ b/app/src/main/java/nz/aryan/akllive/ui/JourneyScreen.kt @@ -228,7 +228,7 @@ private fun JourneyView(vm: AppViewModel, it: Itinerary, from: Place?, to: Place live[r.tripId]?.vehicle?.let { v -> MapMarker(r.tripId, v.lat, v.lon, v.bearing, routeColor(r.route, r.mode), train = r.mode == Mode.Train, tag = r.route) } - } + listOfNotNull(if (tracking && trip.lat != null) MapMarker("me", trip.lat!!, trip.lon!!, null, Color(0xFF1A73E8), train = false, tag = "You") + } + listOfNotNull(if (tracking && trip.lat != null) MapMarker("me", trip.lat!!, trip.lon!!, null, Color(0xFF1A73E8), train = false, me = true, accuracy = trip.accuracy) else null) // the first fix of a trip brings the map to you var centred by remember(tracking) { mutableStateOf(false) } diff --git a/app/src/main/java/nz/aryan/akllive/ui/LiveMap.kt b/app/src/main/java/nz/aryan/akllive/ui/LiveMap.kt index fa335c2..ba3ca7a 100644 --- a/app/src/main/java/nz/aryan/akllive/ui/LiveMap.kt +++ b/app/src/main/java/nz/aryan/akllive/ui/LiveMap.kt @@ -112,6 +112,9 @@ data class MapMarker( val train: Boolean, val tag: String? = null, val alert: Color? = null, + /** you: a blue dot with a ring for how sure the GPS is (metres), not a vehicle */ + val me: Boolean = false, + val accuracy: Float = 0f, ) /** @@ -484,6 +487,18 @@ fun LiveMap( val pulse = 0.5f + 0.5f * sin(t * 4f) drawCircle(mk.color.copy(alpha = 0.2f + 0.2f * pulse), (20 + 8 * pulse) * dp, p) } + if (mk.me) { + val mpp = proj.getMetersPerPixelAtLatitude(la).takeIf { it > 0 } ?: 1.0 + val halo = (mk.accuracy / mpp).toFloat().coerceIn(0f, 400 * dp) + if (halo > 12 * dp) { + drawCircle(mk.color.copy(alpha = 0.14f), halo, p) + drawCircle(mk.color.copy(alpha = 0.35f), halo, p, style = Stroke(1 * dp)) + } + drawCircle(Color.Black.copy(alpha = 0.25f), 10 * dp, p + Offset(0f, 1 * dp)) + drawCircle(Color.White, 9.5f * dp, p) + drawCircle(mk.color, 6.5f * dp, p) + continue + } if (mk.train) drawTrainMarker(p, mk, sel, dp) else drawBusMarker(p, mk, dp) mk.tag?.let { drawTag(measurer, it, p + Offset(0f, -(if (mk.train) 14 else 16) * dp), mk.color, Color.White, dp) } } From 3a6e3cbfe972f51c3a772ca72e8d03141f320fe3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 02:55:32 +0000 Subject: [PATCH 2/2] Bus photos from the AT Metro Wiki, saved on the phone, on the Home cards too Each model points at its wiki page and takes that page's lead photo; the loose Commons searches that turned up a boat for the eT12 are gone, and Commons is only a fallback when the file name names the model. Photos are fetched ahead of time and kept in app storage. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011C2fAvFFcMRkaF7qSvRA1v --- README.md | 6 +- app/src/main/assets/models.json | 36 ++-- .../java/nz/aryan/akllive/AppViewModel.kt | 3 + .../main/java/nz/aryan/akllive/data/Fleet.kt | 6 + .../main/java/nz/aryan/akllive/data/Photos.kt | 158 +++++++++++++----- .../java/nz/aryan/akllive/ui/BusScreen.kt | 2 +- .../java/nz/aryan/akllive/ui/FleetScreen.kt | 15 +- 7 files changed, 160 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index bbb9f4d..12ad11f 100644 --- a/README.md +++ b/README.md @@ -84,8 +84,10 @@ Hillsborough, both directions (stop 8669 to Britomart, 8664 to Waikowhai).

Fleet · A model's page · Fleet dex

-- **Fleet**: every bus model in Auckland with a real photo (from Wikimedia - Commons, credited on each), how many are out right now, and who runs them. +- **Fleet**: every bus model in Auckland with a real photo (from its + [AT Metro Wiki](https://atmetro.fandom.com) page, credited on each), how + many are out right now, and who runs them. The photos are saved on the + phone, and the bus coming to your stop shows its photo too. - **A model's page**: specs, a short history, the fleet numbers each operator uses, and a live map of every one of them on the road. - **Fleet dex**: every model that pulls up at your Home stops gets spotted and diff --git a/app/src/main/assets/models.json b/app/src/main/assets/models.json index 0012ed4..d01c823 100644 --- a/app/src/main/assets/models.json +++ b/app/src/main/assets/models.json @@ -1,91 +1,91 @@ [ { - "id": "et12", "name": "CRRC eT12 MAX", "maker": "CRRC", "electric": true, "decks": 1, "axles": 3, + "id": "et12", "wiki": ["CRRC eT12 MAX"], "key": "eT12", "name": "CRRC eT12 MAX", "maker": "CRRC", "electric": true, "decks": 1, "axles": 3, "specs": [["Length", "12.7 m"], ["Seats", "37–40"], ["Motor", "240 kW"], ["Top speed", "80 km/h"], ["In Auckland since", "2022"]], "about": "A long three-axle electric bus from Zhejiang CRRC, and the most common electric bus in Auckland. NZ Bus brought in the first ones in 2022–23, Tranzurban's arrived in late 2024 for the 11T and 11W, and Ritchies has been adding batches through 2025 and 2026. NZ Bus's NB5778–5786 wear the Tāmaki Link colours." }, { - "id": "ed12", "name": "CRRC eD12 MAX", "maker": "CRRC", "electric": true, "decks": 2, + "id": "ed12", "wiki": ["CRRC eD12 MAX"], "key": "eD12", "name": "CRRC eD12 MAX", "maker": "CRRC", "electric": true, "decks": 2, "specs": [["Length", "12.0 m"], ["Height", "4.25 m"], ["Motor", "350 kW"], ["Top speed", "90 km/h"], ["In Auckland since", "2024"]], "about": "Auckland's first fully electric double-decker. Kinetic unveiled it in March 2024 and NB5400 went into service on the 18, 25B and 25L that April. Tranzurban's fleet has run the 12 and WX1 since April 2025: the ocean-blue ones on the 12, the Shore-liveried ones on the WX1." }, { - "id": "es12", "name": "CRRC eS12 MAX", "maker": "CRRC", "electric": true, "decks": 1, "axles": 2, + "id": "es12", "wiki": ["CRRC eS12 MAX"], "key": "eS12", "name": "CRRC eS12 MAX", "maker": "CRRC", "electric": true, "decks": 1, "axles": 2, "specs": [["Length", "11.3 m"], ["Seats", "31–33"], ["Motor", "240 kW"], ["Top speed", "80 km/h"], ["In Auckland since", "2021"]], "about": "The shorter, two-axle CRRC electric. It started on the City Link with NZ Bus in 2021 (two of those now do the Inner Link), and Ritchies runs a big fleet of them." }, { - "id": "c13e", "name": "Geely C13E", "maker": "Geely", "electric": true, "decks": 1, "axles": 3, + "id": "c13e", "wiki": ["Geely C13E"], "key": "C13E", "name": "Geely C13E", "maker": "Geely", "electric": true, "decks": 1, "axles": 3, "specs": [["Built by", "Farizon (Geely)"], ["In Auckland since", "2024"]], "about": "A three-axle electric bus from Farizon, Geely's commercial-vehicle brand. Kinetic's NZ Bus put the first ones into service in June 2024, and a second batch with bigger batteries joined in 2025 on routes between the city and the North Shore." }, { - "id": "c10e", "name": "Geely C10E", "maker": "Geely", "electric": true, "decks": 1, + "id": "c10e", "wiki": ["Geely C10E"], "key": "C10E", "name": "Geely C10E", "maker": "Geely", "electric": true, "decks": 1, "specs": [["Built by", "Farizon (Geely)"], ["Runs", "Inner and Outer Link"]], "about": "The shorter sibling of the Geely C13E, from the same Farizon factory. Kinetic's NZ Bus runs them on the Inner Link and Outer Link; NB6103 was the first to carry passengers on the Outer Link." }, { - "id": "e12", "name": "Yutong E12", "maker": "Yutong", "electric": true, "decks": 1, "axles": 2, + "id": "e12", "wiki": ["Yutong E12"], "key": "Yutong E12", "name": "Yutong E12", "maker": "Yutong", "electric": true, "decks": 1, "axles": 2, "specs": [["Seats", "26 on the Airport Link"], ["In Auckland since", "2020"]], "about": "A two-axle electric bus from Zhengzhou Yutong. Go Bus brought them in for the Airport Link in 2020; luggage racks down both sides cut the seats to 26 (the city version seats 34–36)." }, { - "id": "e13", "name": "Yutong E13", "maker": "Yutong", "electric": true, "decks": 1, "axles": 3, + "id": "e13", "wiki": ["Yutong E13"], "key": "Yutong E13", "name": "Yutong E13", "maker": "Yutong", "electric": true, "decks": 1, "axles": 3, "specs": [["Seats", "36"], ["Carries", "up to 78"], ["In Auckland since", "2020"]], "about": "Yutong's three-axle electric. Howick & Eastern introduced it on route 70 in 2020; Ritchies and Tranzurban run a few as well." }, { - "id": "e200", "name": "Alexander Dennis Enviro200", "maker": "Alexander Dennis", "electric": false, "decks": 1, "axles": 2, + "id": "e200", "wiki": ["Alexander Dennis Enviro 200", "Alexander Dennis Enviro200"], "key": "Enviro200", "name": "Alexander Dennis Enviro200", "maker": "Alexander Dennis", "electric": false, "decks": 1, "axles": 2, "specs": [["Seats", "35–37"], ["Doors", "2"], ["In Auckland since", "2011"]], "about": "The diesel workhorse of NZ Bus. It arrived in 2011 on the City Link, and at its peak NZ Bus ran more than 300 of them across Auckland, Tauranga and Wellington. Bodies came from Kiwi Bus Builders and Alexander Dennis; the last ones for mainland Auckland were built in 2015." }, { - "id": "e200mmc", "name": "Alexander Dennis Enviro200 MMC", "maker": "Alexander Dennis", "electric": false, "decks": 1, "axles": 2, + "id": "e200mmc", "wiki": ["Alexander Dennis Enviro 200 MMC", "Alexander Dennis Enviro200 MMC", "Alexander Dennis Enviro 200"], "key": "Enviro200", "name": "Alexander Dennis Enviro200 MMC", "maker": "Alexander Dennis", "electric": false, "decks": 1, "axles": 2, "specs": [["In Auckland since", "2017"]], "about": "The second-generation Enviro200 (MMC stands for Major Model Change), in Auckland since 2017. Ritchies also runs a shorter single-door version, the Enviro200 Midi." }, { - "id": "e200evxlb", "name": "BYD ADL Enviro200EV XLB", "maker": "BYD", "electric": true, "decks": 1, "axles": 3, + "id": "e200evxlb", "wiki": ["BYD ADL Enviro200EV", "Alexander Dennis Enviro200EV", "Alexander Dennis Enviro 200 EV", "BYD ADL Enviro 200EV"], "key": "Enviro200EV", "name": "BYD ADL Enviro200EV XLB", "maker": "BYD", "electric": true, "decks": 1, "axles": 3, "specs": [["Length", "12.6 m"], ["Battery", "348 kWh"], ["Seats", "36"], ["Carries", "up to 78"], ["In Auckland since", "2021"]], "about": "A three-axle electric built as a partnership: BYD's batteries and driveline under an Alexander Dennis body. It seats 36 and carries up to 78 people, with two wheelchair spaces." }, { - "id": "e500", "name": "Alexander Dennis Enviro500", "maker": "Alexander Dennis", "electric": false, "decks": 2, "axles": 3, + "id": "e500", "wiki": ["Alexander Dennis Enviro 500", "Alexander Dennis Enviro500"], "key": "Enviro500", "name": "Alexander Dennis Enviro500", "maker": "Alexander Dennis", "electric": false, "decks": 2, "axles": 3, "specs": [["Length", "12.5 m"], ["Height", "4.2 m"], ["Seats", "55 up, 31–36 down"], ["Engine", "Cummins ISL9"]], "about": "The big three-axle diesel double-decker. NZ Bus's 5000 series work the busiest isthmus routes (the 18, 25B, 25L, 27H, 27W, 30 and 75), and their size makes them a favourite for full-body ads. Bodies were assembled by Kiwi Bus Builders or Zhuhai Granton." }, { - "id": "e500mmc", "name": "Alexander Dennis Enviro500 MMC", "maker": "Alexander Dennis", "electric": false, "decks": 2, "axles": 3, + "id": "e500mmc", "wiki": ["Alexander Dennis Enviro 500 MMC", "Alexander Dennis Enviro500 MMC", "Alexander Dennis Enviro 500"], "key": "Enviro500", "name": "Alexander Dennis Enviro500 MMC", "maker": "Alexander Dennis", "electric": false, "decks": 2, "axles": 3, "specs": [["Length", "12.5 m"], ["Height", "4.2 m"], ["Seats", "55 up, 31–36 down"], ["Engine", "Cummins ISL9"]], "about": "The updated Enviro500 body, bought by Ritchies (including Birkenhead Transport) and Bayes, mostly from 2018 on, and assembled by Zhuhai Granton. Same size as the original: 12.5 m long and 4.2 m tall." }, { - "id": "bci6123", "name": "BCI CitiRider FBC6123", "maker": "BCI", "electric": false, "decks": 2, "axles": 3, + "id": "bci6123", "wiki": ["BCI CitiRider", "BCI CitiRider FBC6123"], "key": "CitiRider", "name": "BCI CitiRider FBC6123", "maker": "BCI", "electric": false, "decks": 2, "axles": 3, "specs": [["In Auckland since", "2018"]], "about": "A three-axle double-decker from Australian builder BCI. Tranzurban brought them in July 2018 when it took over the Northern Express NX2." }, { - "id": "k320", "name": "Scania K320UB", "maker": "Scania", "electric": false, "decks": 1, "axles": 3, + "id": "k320", "wiki": ["Scania K320", "Scania K320UB"], "key": "K320", "name": "Scania K320UB", "maker": "Scania", "electric": false, "decks": 1, "axles": 3, "specs": [["Engine", "320 hp"]], "about": "A Swedish chassis with a 320 hp engine, always built three-axle in Auckland to keep road-user charges down. The body depends on the operator: Kiwi Bus Builders (46–49 seats) on Tranzurban's NX2 buses, and Bonluck on the ex-Pavlovich fleet, which has big screens inside showing the next stop." }, { - "id": "k270", "name": "Scania K270UB", "maker": "Scania", "electric": false, "decks": 1, "axles": 3, + "id": "k270", "wiki": ["Scania K270", "Scania K270UB"], "key": "K270", "name": "Scania K270UB", "maker": "Scania", "electric": false, "decks": 1, "axles": 3, "specs": [["Engine", "270 hp"]], "about": "Scania's 270 hp chassis, three-axle like most big diesels here. NZ Bus's 2100 series have DesignLine bodies (the last eleven have the Enviroline design); Ritchies' were bodied by Kiwi Bus Builders." }, { - "id": "man16250", "name": "MAN 16.250", "maker": "MAN", "electric": false, "decks": 1, "axles": 2, + "id": "man16250", "wiki": ["MAN 16.250"], "key": "16.250", "name": "MAN 16.250", "maker": "MAN", "electric": false, "decks": 1, "axles": 2, "specs": [["Seats", "37–43"], ["Body", "Enviroline"]], "about": "A two-axle MAN with the Enviroline body built in Rolleston, Canterbury, by Global Bus Ventures (formerly DesignLine). Go Bus runs most of them; Ritchies has some, partly from Birkenhead Transport." }, { - "id": "man18290", "name": "MAN 18.290", "maker": "MAN", "electric": false, "decks": 1, "axles": 3, + "id": "man18290", "wiki": ["MAN 18.290"], "key": "18.290", "name": "MAN 18.290", "maker": "MAN", "electric": false, "decks": 1, "axles": 3, "specs": [["Engine", "290 hp"], ["Weight rating", "18 t"], ["Body", "Enviroline"]], "about": "A three-axle MAN (the name means 18 tonnes and 290 hp), mostly with Global Bus Ventures' Enviroline body; one Ritchies bus has a Malaysian Gemilang body instead. Go Bus and Ritchies run them." }, { - "id": "man17223", "name": "MAN 17.223", "maker": "MAN", "electric": false, "decks": 1, "axles": 3, + "id": "man17223", "wiki": ["MAN 17.223"], "key": "17.223", "name": "MAN 17.223", "maker": "MAN", "electric": false, "decks": 1, "axles": 3, "specs": [["Built", "2002–2007"]], "about": "A tag-axle bus built by DesignLine in Ashburton between 2002 and 2007, first bought by Stagecoach for Auckland. Most of NZ Bus's have been retired in favour of electrics, but a handful are still running." } diff --git a/app/src/main/java/nz/aryan/akllive/AppViewModel.kt b/app/src/main/java/nz/aryan/akllive/AppViewModel.kt index 09bc851..0b55503 100644 --- a/app/src/main/java/nz/aryan/akllive/AppViewModel.kt +++ b/app/src/main/java/nz/aryan/akllive/AppViewModel.kt @@ -31,6 +31,7 @@ import nz.aryan.akllive.data.BusDeparture import nz.aryan.akllive.data.BusRepo import nz.aryan.akllive.data.BusTrip import nz.aryan.akllive.data.Fleet +import nz.aryan.akllive.data.Photos import nz.aryan.akllive.data.FleetState import nz.aryan.akllive.data.LiveRepo import nz.aryan.akllive.data.Nz @@ -158,6 +159,8 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { init { Fleet.load(app) Timetable.init(app) + // every model's photo onto the phone early, so a bus's photo is there the moment it shows up + viewModelScope.launch { delay(5_000); try { Photos.prefetch(app) } catch (_: Exception) { } } viewModelScope.launch { loop(30_000) { refreshBuses() } } viewModelScope.launch { while (true) { diff --git a/app/src/main/java/nz/aryan/akllive/data/Fleet.kt b/app/src/main/java/nz/aryan/akllive/data/Fleet.kt index 58c9d47..dd8f36c 100644 --- a/app/src/main/java/nz/aryan/akllive/data/Fleet.kt +++ b/app/src/main/java/nz/aryan/akllive/data/Fleet.kt @@ -15,6 +15,10 @@ class BusModel( /** label -> value, e.g. "Length" -> "12.7 m" */ val specs: List>, val about: String, + /** its page(s) on the AT Metro Wiki, best first */ + val wiki: List = emptyList(), + /** what a photo or page title has to mention to be this bus, e.g. "eT12" */ + val key: String = "", ) { val doubleDeck: Boolean get() = decks >= 2 @@ -59,6 +63,8 @@ object Fleet { axles = if (o.has("axles")) o.getInt("axles") else null, specs = (0 until specs.length()).map { k -> specs.getJSONArray(k).let { it.getString(0) to it.getString(1) } }, about = o.optString("about"), + wiki = o.optJSONArray("wiki")?.let { w -> (0 until w.length()).map { w.getString(it) } } ?: emptyList(), + key = o.optString("key"), ) } val byId = ms.associateBy { it.id } diff --git a/app/src/main/java/nz/aryan/akllive/data/Photos.kt b/app/src/main/java/nz/aryan/akllive/data/Photos.kt index 2062be9..5eb1105 100644 --- a/app/src/main/java/nz/aryan/akllive/data/Photos.kt +++ b/app/src/main/java/nz/aryan/akllive/data/Photos.kt @@ -14,22 +14,24 @@ import java.net.HttpURLConnection import java.net.URL import java.net.URLEncoder -/** A real photo of a bus model: the image, its page on Wikimedia Commons, and who to credit. */ +/** A real photo of a bus model: the image, the page it's from, and who to credit. */ class Photo(val url: String, val page: String, val credit: String) /** - * Photos of each bus model from Wikimedia Commons (freely licensed, credited - * under the photo), found by searching for the model in Auckland. Looked up - * once a fortnight per model and kept on the phone. + * Photos of each bus model, from its page on the AT Metro Wiki (the lead photo, + * CC BY-SA), or failing that a Wikimedia Commons photo whose name says it's this + * very model. Looked up once a fortnight per model and kept on the phone, and + * fetched ahead of time so they're there when a bus turns up. */ object Photos { private const val KEEP_MS = 14L * 24 * 3600 * 1000 + private const val MISS_MS = 24L * 3600 * 1000 + // bumped when the lookup changes, so a wrong photo from before isn't kept + private const val VERSION = 3 + private const val WIKI = "https://atmetro.fandom.com" private val mem = HashMap() private val lock = Mutex() - private const val MISS_MS = 24L * 3600 * 1000 - private const val VERSION = 2 - suspend fun forModel(ctx: Context, m: BusModel): Photo? = lock.withLock { if (mem.containsKey(m.id)) return@withLock mem[m.id] val prefs = ctx.getSharedPreferences("photos", Context.MODE_PRIVATE) @@ -50,38 +52,94 @@ object Photos { found } - /** The model itself, however it's been written up; failing that, a bus by the same maker. */ - private suspend fun find(m: BusModel): Photo? { - val bare = m.short.removeSuffix(" MAX").trim() // "eT12 MAX" -> "eT12" - val exact = listOf("${m.name} Auckland", "${m.short} Auckland", "$bare Auckland", "${m.maker} $bare", - m.name, "$bare bus") - for (q in exact.distinct()) search(q)?.let { return it } - for (c in listOf(m.name, "${m.maker} $bare", "$bare").distinct()) category("Category:$c")?.let { return it } - // no photo of the model itself: one of the same maker's buses, and say so - for (q in listOf("${m.maker} bus Auckland", "${m.maker} bus")) { - search(q)?.let { return Photo(it.url, it.page, it.credit.replace("Photo:", "A ${m.maker} bus. Photo:")) } + /** Looks up and downloads every model's photo in the background, so they show straight away later. */ + suspend fun prefetch(ctx: Context) { + for (m in Fleet.models) { + val p = try { forModel(ctx, m) } catch (e: Exception) { null } ?: continue + Images.download(ctx, p.url) } + } + + private fun norm(s: String) = s.lowercase().filter { it.isLetterOrDigit() } + private fun enc(s: String) = URLEncoder.encode(s, "UTF-8") + private fun wikiPage(title: String) = "$WIKI/wiki/" + enc(title.replace(' ', '_')).replace("%2F", "/") + private fun api(q: String) = "$WIKI/api.php?action=query&format=json&redirects=1&$q" + + /** Its own page on the wiki; a page the wiki's search turns up for it; then Commons, only if the file is plainly this model. */ + private suspend fun find(m: BusModel): Photo? { + val key = norm(m.key.ifEmpty { m.short.substringBefore(' ') }) + val titles = m.wiki.ifEmpty { listOf(m.name) } + leadPhoto(titles)?.let { return it } + val found = (httpJson(api("list=search&srnamespace=0&srlimit=8&srsearch=" + enc(m.name))) as? JSONObject) + ?.optJSONObject("query")?.optJSONArray("search") + ?.let { r -> (0 until r.length()).map { r.getJSONObject(it).optString("title") } } + ?.filter { norm(it).contains(key) }.orEmpty() + if (found.isNotEmpty()) leadPhoto(found)?.let { return it } + for (t in (titles + found).distinct()) pagePhoto(t, key)?.let { return it } + for (q in listOf("${m.name} Auckland", m.name, "${m.short} bus")) commons(q, key)?.let { return it } return null } - /** Files in a Commons category. */ - private suspend fun category(title: String): Photo? { - val url = "https://commons.wikimedia.org/w/api.php?action=query&format=json&generator=categorymembers&gcmtype=file&gcmlimit=10" + - "&gcmtitle=" + URLEncoder.encode(title, "UTF-8") + "&prop=imageinfo&iiprop=url%7Cextmetadata%7Cmime&iiurlwidth=800" - return pick(httpJson(url) as? JSONObject ?: throw java.io.IOException("Commons didn't answer")) + /** The lead photo of the first of these pages that has one. */ + private suspend fun leadPhoto(titles: List): Photo? { + val j = httpJson(api("prop=pageimages&piprop=thumbnail&pithumbsize=800&titles=" + enc(titles.joinToString("|")))) + as? JSONObject ?: throw java.io.IOException("The wiki didn't answer") + val q = j.optJSONObject("query") ?: return null + // follow "normalized" and "redirects" so the answer lines up with what was asked + val moved = HashMap() + for (k in listOf("normalized", "redirects")) q.optJSONArray(k)?.let { a -> + for (i in 0 until a.length()) a.getJSONObject(i).let { moved[it.optString("from")] = it.optString("to") } + } + fun resolve(t: String): String { var x = t; repeat(3) { x = moved[x] ?: return x }; return x } + val pages = q.optJSONObject("pages") ?: return null + val byTitle = pages.keys().asSequence().mapNotNull { pages.optJSONObject(it) }.associateBy { it.optString("title") } + for (t in titles) { + val page = byTitle[resolve(t)] ?: continue + val src = page.optJSONObject("thumbnail")?.optString("source").orEmpty() + if (src.isNotEmpty() && !skip(src)) return Photo(src, wikiPage(page.optString("title")), "Photo: AT Metro Wiki, CC BY-SA") + } + return null } - private suspend fun search(q: String): Photo? { - val url = "https://commons.wikimedia.org/w/api.php?action=query&format=json&generator=search&gsrnamespace=6&gsrlimit=8" + - "&gsrsearch=" + URLEncoder.encode("$q filetype:bitmap", "UTF-8") + - "&prop=imageinfo&iiprop=url%7Cextmetadata%7Cmime&iiurlwidth=800" - return pick(httpJson(url) as? JSONObject ?: throw java.io.IOException("Commons didn't answer")) + private val NOT_A_BUS = Regex("(?i)logo|icon|wordmark|favicon|placeholder|\\bmap\\b|route_?map|diagram|\\.svg|\\.gif") + + private fun skip(name: String) = NOT_A_BUS.containsMatchIn(name) + + /** When the wiki doesn't mark a lead photo: the photos on the model's page, ones naming the model first. */ + private suspend fun pagePhoto(title: String, key: String): Photo? { + val j = httpJson(api("prop=images&imlimit=50&titles=" + enc(title))) as? JSONObject ?: return null + val pages = j.optJSONObject("query")?.optJSONObject("pages") ?: return null + val page = pages.keys().asSequence().mapNotNull { pages.optJSONObject(it) }.firstOrNull { !it.has("missing") } ?: return null + val files = page.optJSONArray("images")?.let { a -> (0 until a.length()).map { a.getJSONObject(it).optString("title") } } + ?.filter { !skip(it) && Regex("(?i)\\.(jpe?g|png|webp)$").containsMatchIn(it) }.orEmpty() + if (files.isEmpty()) return null + val ordered = files.sortedByDescending { norm(it).contains(key) }.take(20) + val info = httpJson(api("prop=imageinfo&iiprop=url%7Cmime&iiurlwidth=800&titles=" + enc(ordered.joinToString("|")))) + as? JSONObject ?: return null + val ip = info.optJSONObject("query")?.optJSONObject("pages") ?: return null + val urls = ip.keys().asSequence().mapNotNull { ip.optJSONObject(it) }.associate { p -> + p.optString("title") to p.optJSONArray("imageinfo")?.optJSONObject(0)?.let { it.optString("thumburl").ifEmpty { it.optString("url") } } + } + val best = ordered.firstNotNullOfOrNull { t -> urls[t]?.takeIf { it.isNotEmpty() } } ?: return null + return Photo(best, wikiPage(page.optString("title")), "Photo: AT Metro Wiki, CC BY-SA") } - private fun pick(j: JSONObject): Photo? { + // things that come up searching for a model number and are anything but a bus + private val OFF_TOPIC = Regex("(?i)ship|boat|launch|ferry|yacht|vessel|aircraft|plane|locomotive|tram|truck|lorry|car\\b|tractor|map|logo") + + /** A Commons photo, only when its file name has the model in it and says bus or Auckland. */ + private suspend fun commons(q: String, key: String): Photo? { + val url = "https://commons.wikimedia.org/w/api.php?action=query&format=json&generator=search&gsrnamespace=6&gsrlimit=15" + + "&gsrsearch=" + enc("$q filetype:bitmap") + "&prop=imageinfo&iiprop=url%7Cextmetadata%7Cmime&iiurlwidth=800" + val j = httpJson(url) as? JSONObject ?: return null val pages = j.optJSONObject("query")?.optJSONObject("pages") ?: return null val best = pages.keys().asSequence().mapNotNull { pages.optJSONObject(it) } .filter { p -> p.optJSONArray("imageinfo")?.optJSONObject(0)?.optString("mime")?.let { it == "image/jpeg" || it == "image/png" } == true } + .filter { p -> + val t = p.optString("title") + norm(t).contains(key) && !OFF_TOPIC.containsMatchIn(t) && + Regex("(?i)bus|auckland|nz|metro|link|kinetic|ritchies|go ?bus|tranzurban|howick").containsMatchIn(t) + } .minByOrNull { it.optInt("index", 99) } ?: return null val info = best.getJSONArray("imageinfo").getJSONObject(0) val meta = info.optJSONObject("extmetadata") @@ -96,25 +154,37 @@ object Photos { object Images { private val mem = LruCache(24) + /** Kept in files, not the cache folder, so Android doesn't clear them out from under us. */ + private fun fileFor(ctx: Context, url: String): File = + File(File(ctx.filesDir, "photos").also { it.mkdirs() }, Integer.toHexString(url.hashCode()) + ".img") + + /** Makes sure the image is on the phone; true when it is. */ + suspend fun download(ctx: Context, url: String): Boolean = withContext(Dispatchers.IO) { + val file = fileFor(ctx, url) + if (file.exists()) return@withContext true + try { + val c = URL(url).openConnection() as HttpURLConnection + c.connectTimeout = 15_000 + c.readTimeout = 30_000 + c.setRequestProperty("User-Agent", "AKL-Live-Android/1.0 (github.com/intelarc/akl-live-android)") + try { + if (c.responseCode != 200) return@withContext false + val part = File(file.parentFile, file.name + ".part") + c.inputStream.use { i -> part.outputStream().use { o -> i.copyTo(o) } } + part.renameTo(file) + } finally { + c.disconnect() + } + } catch (e: Exception) { + false + } + } + suspend fun load(ctx: Context, url: String): Bitmap? = withContext(Dispatchers.IO) { mem.get(url)?.let { return@withContext it } - val dir = File(ctx.cacheDir, "img").also { it.mkdirs() } - val file = File(dir, Integer.toHexString(url.hashCode()) + ".img") + val file = fileFor(ctx, url) try { - if (!file.exists()) { - val c = URL(url).openConnection() as HttpURLConnection - c.connectTimeout = 15_000 - c.readTimeout = 30_000 - c.setRequestProperty("User-Agent", "AKL-Live-Android/1.0 (github.com/intelarc/akl-live-android)") - try { - if (c.responseCode != 200) return@withContext null - val part = File(dir, file.name + ".part") - c.inputStream.use { i -> part.outputStream().use { o -> i.copyTo(o) } } - part.renameTo(file) - } finally { - c.disconnect() - } - } + if (!download(ctx, url)) return@withContext null val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } BitmapFactory.decodeFile(file.path, bounds) var sample = 1 diff --git a/app/src/main/java/nz/aryan/akllive/ui/BusScreen.kt b/app/src/main/java/nz/aryan/akllive/ui/BusScreen.kt index f567861..4394ff4 100644 --- a/app/src/main/java/nz/aryan/akllive/ui/BusScreen.kt +++ b/app/src/main/java/nz/aryan/akllive/ui/BusScreen.kt @@ -310,7 +310,7 @@ fun VehicleInfo(v: Vehicle, big: Boolean = false) { .clickable { open(model?.id ?: UNKNOWN_MODEL) } .padding(horizontal = 12.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) { - BusGlyph(model, Modifier.size(width = if (big) 56.dp else 48.dp, height = 34.dp)) + ModelThumb(model, Modifier.size(width = if (big) 96.dp else 84.dp, height = if (big) 64.dp else 56.dp)) Spacer(Modifier.width(12.dp)) Column(Modifier.weight(1f)) { // "Enviro500" up top, "Alexander Dennis · NZ Bus · NB5028" under it, so nothing gets cut off diff --git a/app/src/main/java/nz/aryan/akllive/ui/FleetScreen.kt b/app/src/main/java/nz/aryan/akllive/ui/FleetScreen.kt index 3b8e710..21b7961 100644 --- a/app/src/main/java/nz/aryan/akllive/ui/FleetScreen.kt +++ b/app/src/main/java/nz/aryan/akllive/ui/FleetScreen.kt @@ -172,7 +172,7 @@ private fun BusPortrait(model: BusModel?, modifier: Modifier, frameT: State? = } } +/** A small photo of the model for a list row; the drawn bus until (or unless) there's a photo. */ +@Composable +internal fun ModelThumb(m: BusModel?, modifier: Modifier) { + val ctx = LocalContext.current + val photo by produceState(null, m?.id) { value = m?.let { Photos.forModel(ctx, it) } } + val bmp by produceState(null, photo?.url) { value = photo?.url?.let { Images.load(ctx, it)?.asImageBitmap() } } + Box(modifier.clip(RoundedCornerShape(10.dp))) { + val b = bmp + if (b != null) Image(b, m?.name, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) + else BusGlyph(m, Modifier.fillMaxSize().padding(horizontal = 4.dp, vertical = 6.dp)) + } +} + @OptIn(ExperimentalLayoutApi::class) @Composable private fun ModelCard(m: BusModel, live: List, click: () -> Unit) {