Bimba.git

commit 9cbb301b78ce6c1767a1199c85d3d1e48c413edd

Author: Adam Evyčędo <git@apiote.xyz>

merge develop into master for version 3.8.0

%!v(PANIC=String method: strings: negative Repeat count)


diff --git a/README.adoc b/README.adoc
index 9fd6689891dbe5915ae419d699d378944a4df0c4..fe4569a3272a921f38a58dc15f2bb9419b79f70b 100644
--- a/README.adoc
+++ b/README.adoc
@@ -4,7 +4,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later
 
 = Bimba
 Adam Evyčędo <me@apiote.xyz>
-v3.7.1 2024-11-30
+v3.8.0 2025-01-21
 :toc:
 
 Bimba is a FLOSS public transport passenger companion; a timetable in your pocket.




diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index fa1aa641706e77412d05ae850be974e2a8165252..fb4153400d1c18791feafd90c302dae996120efe 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,3 +1,10 @@
+
+import com.android.build.gradle.internal.tasks.factory.dependsOn
+import com.android.build.gradle.tasks.ExtractDeepLinksTask
+import com.android.build.gradle.tasks.MergeResources
+import de.undercouch.gradle.tasks.download.Download
+import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
+
 // SPDX-FileCopyrightText: Adam Evyčędo
 //
 // SPDX-License-Identifier: GPL-3.0-or-later
@@ -7,6 +14,8 @@ 	id("com.android.application")
 	kotlin("android")
 	kotlin("plugin.parcelize")
 	kotlin("plugin.serialization")
+	id("org.openapi.generator")
+	id("de.undercouch.download")
 }
 
 android {
@@ -19,8 +28,8 @@ 	defaultConfig {
 		applicationId = "xyz.apiote.bimba.czwek"
 		minSdk = 21
 		targetSdk = 35
-		versionCode = 33
-		versionName = "3.7.1"
+		versionCode = 34
+		versionName = "3.8.0"
 
 		testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
 		resourceConfigurations += listOf("en", "de", "en-rGB", "en-rUS", "et", "fr", "it", "pl")
@@ -49,6 +58,35 @@ 		viewBinding = true
 	}
 }
 
+tasks.register<Download>("downloadFile") {
+	src("https://bimba.app/transitous/openapi.yml")
+	dest("${layout.buildDirectory.get()}/motis2.yml")
+	overwrite(false)
+}
+
+tasks.named("openApiGenerate").dependsOn("downloadFile")
+
+openApiGenerate {
+	generatorName.set("kotlin")
+	inputSpec.set("${layout.buildDirectory.get()}/motis2.yml")
+	outputDir.set("${layout.buildDirectory.get()}/generated")
+	apiPackage.set("xyz.apiote.bimba.czwek.api.transitous.api")
+	invokerPackage.set("xyz.apiote.bimba.czwek.api.transitous.invoker")
+	modelPackage.set("xyz.apiote.bimba.czwek.api.transitous.model")
+}
+
+kotlin.sourceSets["main"].kotlin.srcDir("${layout.buildDirectory.get()}/generated/src/main/kotlin")
+
+tasks.withType<KotlinCompile> {
+	dependsOn("openApiGenerate")
+}
+tasks.withType<MergeResources> {
+	dependsOn("openApiGenerate")
+}
+tasks.withType<ExtractDeepLinksTask> {
+	dependsOn("openApiGenerate")
+}
+
 dependencies {
 	implementation("androidx.core:core-ktx:1.15.0")
 	implementation("androidx.appcompat:appcompat:1.7.0")
@@ -76,10 +114,11 @@ 	implementation("com.google.guava:guava:33.3.1-android")
 	implementation(project(":fruchtfleisch"))
 	implementation("ch.acra:acra-http:5.11.4")
 	implementation("ch.acra:acra-notification:5.11.4")
+	implementation("com.squareup.okhttp3:okhttp:4.12.0")
+	implementation("com.squareup.moshi:moshi-kotlin:1.15.1")
 
 	coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.3")
 
-	testImplementation("junit:junit:4.13.2")
 	androidTestImplementation("androidx.test.ext:junit:1.2.1")
 	androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
 }




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/api/Api.kt b/app/src/main/java/xyz/apiote/bimba/czwek/api/Api.kt
index bdb383c7bb383976507ceeed0a8db083413fef7f..4ff3d6d839d708b8e84d21b774678bd6a4e2dc97 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/api/Api.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/api/Api.kt
@@ -11,6 +11,8 @@ import android.net.NetworkCapabilities
 import android.os.Build
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.withContext
+import org.openapitools.client.infrastructure.ServerError
+import org.openapitools.client.infrastructure.ServerException
 import xyz.apiote.bimba.czwek.R
 import xyz.apiote.bimba.czwek.settings.feeds.FeedsSettings
 import java.io.IOException
@@ -52,7 +54,11 @@ }
 
 data class Result(val stream: InputStream?, val error: Error?)
 
-data class Error(val statusCode: Int, val stringResource: Int, val imageResource: Int)
+data class Error(val statusCode: Int, val stringResource: Int, val imageResource: Int) {
+	companion object {
+		fun fromTransitous(e: ServerException): Error = Error(e.statusCode, R.string.error, R.drawable.error_other)
+	}
+}
 
 suspend fun getBimba(context: Context, server: Server): Result {
 	return try {




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/api/Structs.kt b/app/src/main/java/xyz/apiote/bimba/czwek/api/Structs.kt
index 62d9c8a13101ed601d9234c19ba9b2b8551b6f9b..ae6cebc06ca9353f7d885b38d8fa055eb25c6ec0 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/api/Structs.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/api/Structs.kt
@@ -11,6 +11,10 @@ import org.yaml.snakeyaml.Yaml
 import xyz.apiote.bimba.czwek.api.structs.VehicleStatusV1
 import xyz.apiote.fruchtfleisch.Reader
 import java.io.InputStream
+import java.time.OffsetDateTime
+import java.time.Period
+import java.time.ZoneId
+import java.time.ZonedDateTime
 import kotlin.reflect.KClass
 
 class TrafficFormatException(override val message: String) : IllegalArgumentException()
@@ -185,6 +189,11 @@ data class Time(
 	val Hour: UInt, val Minute: UInt, val Second: UInt, val DayOffset: Byte, val Zone: String
 ) {
 	companion object {
+		fun fromOffsetTime(t: OffsetDateTime, zone: ZoneId): Time {
+			val now = ZonedDateTime.now()
+			val zonedTime = t.atZoneSameInstant(zone)
+			return Time(zonedTime.hour.toUInt(), zonedTime.minute.toUInt(), zonedTime.second.toUInt(), Period.between(now.toLocalDate(), zonedTime.toLocalDate()).days.toByte(), zone.id)
+		}
 		fun unmarshal(stream: InputStream): Time {
 			val reader = Reader(stream)
 			return Time(




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/api/transitous.kt b/app/src/main/java/xyz/apiote/bimba/czwek/api/transitous.kt
deleted file mode 100644
index e566210b9774cd999d009f32c9b501433dfdf403..0000000000000000000000000000000000000000
--- a/app/src/main/java/xyz/apiote/bimba/czwek/api/transitous.kt
+++ /dev/null
@@ -1,40 +0,0 @@
-// SPDX-FileCopyrightText: Adam Evyčędo
-//
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-package xyz.apiote.bimba.czwek.api
-
-import android.content.Context
-import android.os.Build
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
-import xyz.apiote.bimba.czwek.R
-import java.io.IOException
-import java.net.HttpURLConnection
-import java.net.URL
-
-suspend fun askTransitous(context: Context, json: String): Result {
-	return withContext(Dispatchers.IO) {
-		val url = URL("https://routing.spline.de/api/")
-		val c = (url.openConnection() as HttpURLConnection).apply {
-			setRequestProperty(
-				"User-Agent",
-				"${context.getString(R.string.applicationId)}/${context.getString(R.string.versionName)} (${Build.VERSION.SDK_INT})"
-			)
-			addRequestProperty("Content-Type", "application/json")
-			requestMethod = "POST"
-			doOutput = true
-			outputStream.write(json.toByteArray())
-		}
-		try {
-			if (c.responseCode == 200) {
-				Result(c.inputStream, null)
-			} else {
-				val (string, image) = mapHttpError(c.responseCode)
-				Result(c.errorStream, Error(c.responseCode, string, image))
-			}
-		} catch (e: IOException) {
-			Result(null, Error(0, R.string.error_connecting, R.drawable.error_server))
-		}
-	}
-}




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/api/transitousDepartures.kt b/app/src/main/java/xyz/apiote/bimba/czwek/api/transitousDepartures.kt
index 3c52446a49d0db59620b89fa7ac64a29bd774cf5..67bbcb416081b6771ed5de98a157adff8460c249 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/api/transitousDepartures.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/api/transitousDepartures.kt
@@ -5,13 +5,11 @@
 package xyz.apiote.bimba.czwek.api
 
 import android.content.Context
-import android.util.JsonReader
-import android.util.JsonToken
 import android.util.Log
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.withContext
 import xyz.apiote.bimba.czwek.R
-import xyz.apiote.bimba.czwek.api.responses.ErrorResponse
+import xyz.apiote.bimba.czwek.api.transitous.api.TimetableApi
 import xyz.apiote.bimba.czwek.repo.Colour
 import xyz.apiote.bimba.czwek.repo.CongestionLevel
 import xyz.apiote.bimba.czwek.repo.Event
@@ -24,264 +22,87 @@ import xyz.apiote.bimba.czwek.repo.StopEvents
 import xyz.apiote.bimba.czwek.repo.TrafficResponseException
 import xyz.apiote.bimba.czwek.repo.Vehicle
 import xyz.apiote.bimba.czwek.units.Mps
-import java.security.MessageDigest
+import java.math.BigDecimal
 import java.time.Instant
 import java.time.LocalDate
+import java.time.OffsetDateTime
 import java.time.ZoneId
-import java.time.ZonedDateTime
 
-@OptIn(ExperimentalStdlibApi::class)
 suspend fun getTransitousDepartures(
 	context: Context,
 	stop: String,
 	date: LocalDate?,
-	limit: Int?
+	limit: Int?,
+	exact: Boolean
 ): StopEvents {
 	if (!isNetworkAvailable(context)) {
 		throw TrafficResponseException(0, "", Error(0, R.string.error_offline, R.drawable.error_net))
 	}
 
-	// TODO shouldn't it be start-of-day in stop's timezone?
-	val timestamp =
-		date?.atStartOfDay(ZoneId.systemDefault())?.toEpochSecond() ?: ZonedDateTime.now()
-			.toEpochSecond()
-
-	val json = """
-		{
-			"destination": {
-				"type": "Module",
-				"target": "/railviz/get_station"
-			},
-			"content_type": "RailVizStationRequest",
-			"content": {
-				"station_id": "$stop",
-				"time": $timestamp,
-				"event_count": ${limit ?: 12},
-				"direction": "LATER",
-				"by_schedule_time": false
-			}
+	return withContext(Dispatchers.IO) {
+		// TODO shouldn't it be start-of-day in stop's timezone?
+		val datetime = date?.let {
+			OffsetDateTime.of(date.atStartOfDay(), ZoneId.systemDefault().rules.getOffset(Instant.now()))
 		}
-	"""
-
-	val result = askTransitous(context, json)
-
-	if (result.error != null) {
-		if (result.stream != null) {
-			val response = withContext(Dispatchers.IO) { ErrorResponse.unmarshal(result.stream) }
-			throw TrafficResponseException(result.error.statusCode, response.message, result.error)
-		} else {
-			throw TrafficResponseException(result.error.statusCode, "", result.error)
-		}
-	} else {
-		return withContext(Dispatchers.IO) {
-			val events = mutableListOf<Event>()
-			var stopID = ""
-			var stopName = ""
-			var latitude = 0.0
-			var longitude = 0.0
-			val r = JsonReader(result.stream!!.bufferedReader())
-			r.withObject { name ->
-				if (name != "content") {
-					r.skipValue()
-					return@withObject
-				}
-				r.withObject { name ->
-					when (name) {
-						"events" -> {
-							r.withArray {
-								var reason = ""
-								var eventTimestamp: Long = 0
-								var valid = false
-								var tripID = ""
-								var idLineID = ""
-								var stationID = ""
-								var targetStationID = ""
-								var targetTimestamp = 0
-								var idTimestamp = 0
-								var trainNr = 0
-								var clasz = -1
-								var direction = ""
-								var lineID = ""
-								var lineName = ""
-								var lineColour = ""
-								var eventType = ""
-								r.withObject { name ->
-									when (name) {
-										"event" -> {
-											r.withObject { name ->
-												when (name) {
-													"reason" -> reason = r.nextString()
-													"schedule_time" -> r.skipValue()
-													"schedule_track" -> r.skipValue()
-													"time" -> eventTimestamp = r.nextLong()
-													"track" -> r.skipValue()
-													"valid" -> valid = r.nextBoolean()
-													else -> r.skipValue()
-												}
-											}
-										}
-
-										"trips" -> {
-											r.withArray { i ->
-												if (i == 0) {
-													r.withObject { name ->
-														when (name) {
-															"id" -> {
-																r.withObject { name ->
-																	when (name) {
-																		"id" -> tripID = r.nextString()
-																		"line_id" -> idLineID = r.nextString()
-																		"station_id" -> stationID = r.nextString()
-																		"target_station_id" -> targetStationID = r.nextString()
-																		"target_time" -> targetTimestamp = r.nextInt()
-																		"time" -> idTimestamp = r.nextInt()
-																		"train_nr" -> trainNr = r.nextInt()
-																		else -> r.skipValue()
-																	}
-																}
-															}
-
-															"transport" -> {
-																r.withObject { name ->
-																	Log.d("WithObject", "next is ${name}")
-																	when (name) {
-																		"clasz" -> clasz = r.nextInt()
-																		"direction" -> direction = r.nextString()
-																		"line_id" -> lineID = r.nextString()
-																		"name" -> lineName = r.nextString()
-																		"provider" -> r.skipValue()
-																		"provider_url" -> r.skipValue()
-																		"range" -> r.skipValue()
-																		"route_color" -> lineColour = r.nextString()
-																		"route_text_color" -> r.skipValue()
-																		else -> r.skipValue()
-																	}
-																}
-															}
-															else -> r.skipValue()
-														}
-													}
-												} else {
-													r.skipValue()
-												}
-											}
-										}
-
-										"type" -> {
-											eventType = r.nextString()
-										}
-										else -> r.skipValue()
-									}
-								}
-								if (eventType == "ARR") {
-									val hash = MessageDigest.getInstance("SHA-256").let {
-										it.update(tripID.toByteArray())
-										it.update(idLineID.toByteArray())
-										it.update(stationID.toByteArray())
-										it.update(targetStationID.toByteArray())
-										it.update(targetTimestamp.toHexString().toByteArray())
-										it.update(idTimestamp.toHexString().toByteArray())
-										it.update(trainNr.toHexString().toByteArray())
-										it.digest()
-									}.toHexString()
-									val t =
-										ZonedDateTime.ofInstant(
-											Instant.ofEpochSecond(eventTimestamp),
-											ZoneId.systemDefault()
-										)
-									events.add(
-										Event(
-											id = hash,
-											arrivalTime = Time(
-												t.hour.toUInt(),
-												t.minute.toUInt(),
-												t.second.toUInt(),
-												(t.dayOfYear - ZonedDateTime.now().dayOfYear).toByte(),
-												ZoneId.systemDefault().id
-											),
-											departureTime = Time(
-												t.hour.toUInt(),
-												t.minute.toUInt(),
-												t.second.toUInt(),
-												(t.dayOfYear - ZonedDateTime.now().dayOfYear).toByte(),
-												ZoneId.systemDefault().id
-											),
-											status = 0u,
-											isRealtime = reason in setOf("IS", "PROPAGATION", "FORECAST"),
-											vehicle = Vehicle(
-												"",
-												Position(0.0, 0.0),
-												0u,
-												Mps(0),
-												LineStub(
-													lineName,
-													LineType.fromTransitous(clasz),
-													Colour.fromHex(lineColour)
-												),
-												direction,
-												CongestionLevel.UNKNOWN,
-												OccupancyStatus.UNKNOWN
-											),
-											boarding = 0xffu,
-											alerts = emptyList(),
-											exact = true,
-											terminusArrival = !valid
-										)
-									)
-								}
-							}
-						}
-
-						"station" -> {
-							r.withObject { name ->
-								when (name) {
-									"id" -> stopID = r.nextString()
-									"name" -> stopName = r.nextString()
-									"pos" -> {
-										r.withObject { posName ->
-											when (posName) {
-												"lat" -> latitude = r.nextDouble()
-												"lng" -> longitude = r.nextDouble()
-											}
-										}
-									}
-								}
-							}
-						}
-						else -> r.skipValue()
-					}
-				}
+		val times = TimetableApi().stoptimes(stop, limit ?: 12, datetime)
+		var stopName = ""
+		var latitude: BigDecimal = BigDecimal(0)
+		var longitude: BigDecimal = BigDecimal(0)
+		val departures = times.stopTimes.filter {
+			!exact || stop == it.place.stopId
+		}.map {
+			Log.i("stop", "stopID recvd: ${it.place.stopId}")
+			if ((it.place.departure ?: it.place.arrival) == null) {
+				null
+			} else {
+				latitude = it.place.lat
+				longitude = it.place.lon
+				stopName = it.place.name
+				Event(
+					it.tripId + it.source,
+					it.place.arrival?.let {
+						Time.fromOffsetTime(it, ZoneId.systemDefault())
+					},
+					it.place.departure?.let {
+						Time.fromOffsetTime(it, ZoneId.systemDefault())
+					},
+					0u,
+					it.realTime,
+					Vehicle(
+						it.tripId,
+						Position(0.0, 0.0),
+						0u,
+						Mps(0),
+						LineStub(
+							it.routeShortName,
+							LineType.fromTransitous2(it.mode),
+							Colour.fromHex(it.routeColor)
+						),
+						it.headsign,
+						CongestionLevel.UNKNOWN,
+						OccupancyStatus.UNKNOWN
+					),
+					boarding = 0xffu,
+					alerts = emptyList(),
+					exact = true,
+					terminusArrival = it.place.departure == null
+				)
 			}
-
-			return@withContext StopEvents(
-				events,
-				Stop(stopID, stopName, stopName, "", "transitous", Position(latitude, longitude), listOf()),
-				listOf()
-			)
-		}
-	}
-}
-
-fun JsonReader.withObject(f: (String) -> Unit) {
-	beginObject()
-	while (hasNext()) {
-		try {
-			f(nextName())
-		} catch (e: IllegalStateException) {
-			val a: JsonToken = this.peek()
-			Log.d("WithObject", "next is ${a.name} ${nextString()}")
-			throw e
-		}
+		}.filterNotNull()
+		Log.i("stop", "stopID asked: $stop")
+		StopEvents(
+			departures,
+			Stop(
+				stop,
+				stopName,
+				stopName,
+				"",
+				"transitous",
+				Position(latitude.toDouble(), longitude.toDouble()),
+				listOf(),
+				null
+			),
+			listOf()
+		)
 	}
-	endObject()
-}
-
-fun JsonReader.withArray(f: (Int) -> Unit) {
-	beginArray()
-	var i = 0
-	while (hasNext()) {
-		f(i)
-		i++
-	}
-	endArray()
 }




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/api/transitousQueryables.kt b/app/src/main/java/xyz/apiote/bimba/czwek/api/transitousQueryables.kt
index 71d32872c65e42a1c27a8115ccf99bb3e4cdb819..a3cbf1b7295607079700a2e1071fee8704558e31 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/api/transitousQueryables.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/api/transitousQueryables.kt
@@ -5,192 +5,82 @@
 package xyz.apiote.bimba.czwek.api
 
 import android.content.Context
-import android.util.JsonReader
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.withContext
 import xyz.apiote.bimba.czwek.R
-import xyz.apiote.bimba.czwek.api.responses.ErrorResponse
+import xyz.apiote.bimba.czwek.api.transitous.api.GeocodeApi
+import xyz.apiote.bimba.czwek.api.transitous.api.MapApi
+import xyz.apiote.bimba.czwek.api.transitous.model.Match
 import xyz.apiote.bimba.czwek.repo.Position
 import xyz.apiote.bimba.czwek.repo.Queryable
 import xyz.apiote.bimba.czwek.repo.Stop
 import xyz.apiote.bimba.czwek.repo.TrafficResponseException
 import xyz.apiote.bimba.czwek.units.DistanceUnit
+import xyz.apiote.bimba.czwek.units.Km
 import xyz.apiote.bimba.czwek.units.Metre
+import java.util.Locale
+import kotlin.math.PI
+import kotlin.math.abs
+import kotlin.math.cos
+
+val MetresPerDegreeLatitude = Metre(111320.0)
 
 suspend fun getTransitousQueryables(query: String, context: Context): List<Queryable> {
 	if (!isNetworkAvailable(context)) {
 		throw TrafficResponseException(0, "", Error(0, R.string.error_offline, R.drawable.error_net))
 	}
 
-	val json = """
-		{
-			"destination": {
-				"type": "Module",
-				"target": "/guesser"
-			},
-			"content_type": "StationGuesserRequest",
-			"content": {
-				"input": "$query",
-				"guess_count": 24
-			}
-		}
-	"""
-	val result = askTransitous(context, json)
+	return withContext(Dispatchers.IO) {
+		GeocodeApi().geocode(query, Locale.getDefault().language).filter { it.type == Match.Type.STOP }
+			.map { Stop(it) }
+	}
+}
 
-	if (result.error != null) {
-		if (result.stream != null) {
-			val response = withContext(Dispatchers.IO) { ErrorResponse.unmarshal(result.stream) }
-			throw TrafficResponseException(result.error.statusCode, response.message, result.error)
-		} else {
-			throw TrafficResponseException(result.error.statusCode, "", result.error)
-		}
+suspend fun locateTransitousQueryables(
+	br: Position,
+	tl: Position,
+	context: Context
+): List<Queryable> {
+	if (!isNetworkAvailable(context)) {
+		throw TrafficResponseException(0, "", Error(0, R.string.error_offline, R.drawable.error_net))
+	}
+
+	val dLat = abs(br.latitude - tl.latitude) / 2
+	var dLon = abs(br.longitude - tl.longitude) / 2
+	val centre = Position(abs(br.latitude + tl.latitude) / 2, abs(br.longitude + tl.longitude) / 2)
+
+	val latitudeLimit = Km(10.0).meters()/MetresPerDegreeLatitude.meters() // ~radius in degrees latitude
+	val corners = if (dLat > latitudeLimit) {
+		dLon = dLon * latitudeLimit / dLat
+		val newBr = Position(centre.latitude-latitudeLimit, centre.longitude+dLon)
+		val newTL = Position(centre.latitude+latitudeLimit, centre.longitude-dLon)
+		Pair(newBr.toString(), newTL.toString())
 	} else {
-		return withContext(Dispatchers.IO) {
-			val queryables = mutableListOf<Queryable>()
-			val r = JsonReader(result.stream!!.bufferedReader())
-			r.beginObject()
-			while (r.hasNext()) {
-				val rootFieldName = r.nextName() // content
-				if (rootFieldName == "content") {
-					r.beginObject()
-					r.nextName() // guesses
-					r.beginArray()
-					while (r.hasNext()) {
-						var id = ""
-						var name = ""
-						var latitude = 0.0
-						var longitude = 0.0
-						r.beginObject()
-						while (r.hasNext()) {
-							val fieldName = r.nextName()
-							when (fieldName) {
-								"id" -> id = r.nextString()
-								"name" -> name = r.nextString()
-								"pos" -> {
-									r.beginObject()
-									while (r.hasNext()) {
-										val positionFieldName = r.nextName()
-										when (positionFieldName) {
-											"lat" -> latitude = r.nextDouble()
-											"lng" -> longitude = r.nextDouble()
-										}
-									}
-									r.endObject()
-								}
-							}
-						}
-						r.endObject()
-						queryables.add(
-							Stop(
-								code = id,
-								name = name,
-								nodeName = name,
-								zone = "",
-								feedID = "transitous",
-								position = Position(latitude = latitude, longitude = longitude),
-								changeOptions = emptyList()
-							)
-						)
-					}
-					r.endArray()
-					r.endObject()
-				} else {
-					r.skipValue()
-				}
-			}
-			r.endObject()
+		Pair(br.toString(), tl.toString())
+	}
 
-			return@withContext queryables
-		}
+	return withContext(Dispatchers.IO) {
+		MapApi().stops(corners.first, corners.second).filter { it.stopId != null }.map { Stop(it) }
 	}
 }
 
-suspend fun locateTransitousQueryables(position: Position, context: Context, radius: DistanceUnit = Metre(500.0)): List<Queryable> {
+suspend fun locateTransitousQueryables(
+	position: Position,
+	context: Context,
+	radius: DistanceUnit = Metre(500.0)
+): List<Queryable> {
 	if (!isNetworkAvailable(context)) {
 		throw TrafficResponseException(0, "", Error(0, R.string.error_offline, R.drawable.error_net))
 	}
 
-	val json = """
-		{
-			"destination": {
-				"type": "Module",
-				"target": "/lookup/geo_station"
-			},
-			"content_type": "LookupGeoStationRequest",
-			"content": {
-				"max_radius": ${radius.meters().toInt()},
-				"pos": {
-					"lat": ${position.latitude},
-					"lng": ${position.longitude}
-				}
-			}
+	val deltaLatitude = radius.meters() / MetresPerDegreeLatitude.meters()
+	val deltaLongitude = radius.meters() / (cos(position.latitude * PI / 180) / 360 * 40075000)
+	val br = Position(position.latitude - deltaLatitude, position.longitude + deltaLongitude)
+	val tl = Position(position.latitude + deltaLatitude, position.longitude - deltaLongitude)
+	return locateTransitousQueryables(br, tl, context).map {
+		when (it) {
+			is Stop -> it
+			else -> null
 		}
-	"""
-	val result = askTransitous(context, json)
-
-	if (result.error != null) {
-		if (result.stream != null) {
-			val response = withContext(Dispatchers.IO) { ErrorResponse.unmarshal(result.stream) }
-			throw TrafficResponseException(result.error.statusCode, response.message, result.error)
-		} else {
-			throw TrafficResponseException(result.error.statusCode, "", result.error)
-		}
-	} else {
-		return withContext(Dispatchers.IO) {
-			val queryables = mutableListOf<Queryable>()
-			val r = JsonReader(result.stream!!.bufferedReader())
-			r.beginObject()
-			while (r.hasNext()) {
-				val rootFieldName = r.nextName() // content
-				if (rootFieldName == "content") {
-					r.beginObject()
-					r.nextName() // guesses
-					r.beginArray()
-					while (r.hasNext()) {
-						var id = ""
-						var name = ""
-						var latitude = 0.0
-						var longitude = 0.0
-						r.beginObject()
-						while (r.hasNext()) {
-							val fieldName = r.nextName()
-							when (fieldName) {
-								"id" -> id = r.nextString()
-								"name" -> name = r.nextString()
-								"pos" -> {
-									r.beginObject()
-									while (r.hasNext()) {
-										val positionFieldName = r.nextName()
-										when (positionFieldName) {
-											"lat" -> latitude = r.nextDouble()
-											"lng" -> longitude = r.nextDouble()
-										}
-									}
-									r.endObject()
-								}
-							}
-						}
-						r.endObject()
-						queryables.add(
-							Stop(
-								code = id,
-								name = name,
-								nodeName = name,
-								zone = "",
-								feedID = "transitous",
-								position = Position(latitude = latitude, longitude = longitude),
-								changeOptions = emptyList()
-							)
-						)
-					}
-					r.endArray()
-					r.endObject()
-				} else {
-					r.skipValue()
-				}
-			}
-			r.endObject()
-			return@withContext queryables
-		}
-	}
+	}.filterNotNull().sortedWith(Stop.distanceComparator(position))
 }




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/api/transitousSettings.kt b/app/src/main/java/xyz/apiote/bimba/czwek/api/transitousSettings.kt
deleted file mode 100644
index b135a0c210d068dcde94ba25b825e54ded1ea350..0000000000000000000000000000000000000000
--- a/app/src/main/java/xyz/apiote/bimba/czwek/api/transitousSettings.kt
+++ /dev/null
@@ -1,12 +0,0 @@
-// SPDX-FileCopyrightText: Adam Evyčędo
-//
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-package xyz.apiote.bimba.czwek.api
-
-import android.content.Context
-import androidx.preference.PreferenceManager
-
-fun isTransitousEnabled(context: Context): Boolean {
-	return PreferenceManager.getDefaultSharedPreferences(context).getBoolean("transitous_enabled", false)
-}




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/home/HomeViewModel.kt b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/home/HomeViewModel.kt
index def65cf9bf6e3e4d1f37c37e65d5121123a5824d..361ea8f9903ab8ab086365dd4bcaf666b0020aef 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/home/HomeViewModel.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/home/HomeViewModel.kt
@@ -18,6 +18,7 @@ import kotlinx.coroutines.async
 import kotlinx.coroutines.awaitAll
 import kotlinx.coroutines.coroutineScope
 import kotlinx.coroutines.launch
+import org.openapitools.client.infrastructure.ServerException
 import xyz.apiote.bimba.czwek.repo.Event
 import xyz.apiote.bimba.czwek.repo.Favourite
 import xyz.apiote.bimba.czwek.repo.FeedInfo
@@ -45,6 +46,9 @@ 			try {
 				getFeeds(context)
 				mutableQueryables.value = OnlineRepository().queryQueryables(query, context) ?: emptyList()
 			} catch (e: TrafficResponseException) {
+				// XXX intentionally no error showing in suggestions
+				Log.e("Suggestion", "$e")
+			} catch (e: ServerException) {
 				// XXX intentionally no error showing in suggestions
 				Log.e("Suggestion", "$e")
 			}
@@ -86,22 +90,29 @@ 								favourite.feedID,
 								favourite.stopCode,
 								null,
 								context,
-								12  // XXX heuristics
+								12,  // XXX heuristics
+								favourite.exact
 							)
-							stopDepartures?.let { sDs ->
-								if (sDs.events.isEmpty()) {
-									Pair(favourite.feedID+favourite.stopCode, Optional.empty())
-								} else {
-									Pair(favourite.feedID+favourite.stopCode, Optional.ofNullable(sDs.events.find { departure ->
-											favourite.lines.isEmpty() or favourite.lines.contains(
-												departure.vehicle.Line.name
-											)
-										}))
-								}
-						} ?: Pair(favourite.feedID+favourite.stopCode, Optional.empty())
+						stopDepartures?.let { sDs ->
+							if (sDs.events.isEmpty()) {
+								Pair(favourite.feedID + favourite.stopCode, Optional.empty())
+							} else {
+								Pair(
+									favourite.feedID + favourite.stopCode,
+									Optional.ofNullable(sDs.events.find { departure ->
+										favourite.lines.isEmpty() or favourite.lines.contains(
+											departure.vehicle.Line.name
+										)
+									})
+								)
+							}
+						} ?: Pair(favourite.feedID + favourite.stopCode, Optional.empty())
 					} catch (e: TrafficResponseException) {
 						Log.w("DeparturesForFavourite", "$e")
-						Pair(favourite.feedID+favourite.stopCode, Optional.empty())
+						Pair(favourite.feedID + favourite.stopCode, Optional.empty())
+					} catch (e: ServerException) {
+						Log.w("DeparturesForFavourite", "Transitous returned ${e.statusCode}, ${e.message}")
+						Pair(favourite.feedID + favourite.stopCode, Optional.empty())
 					}
 				}
 			}.awaitAll().associate { it }




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/map/MapViewModel.kt b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/map/MapViewModel.kt
index a9ff0719d69c398d56d1f3e06ad2aa3cc8072187..ceeb223f0fc4e4f66c0e9c81fda2a79c08c586f0 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/map/MapViewModel.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/map/MapViewModel.kt
@@ -168,7 +168,7 @@ 	private fun showStop(content: View, stop: Stop) {
 		context?.let { ctx ->
 			content.findViewById<TextView>(R.id.stop_name).text = stop.name
 			content.findViewById<Button>(R.id.departures_button).setOnClickListener {
-				startActivity(DeparturesActivity.getIntent(requireContext(), stop.code, stop.name, stop.feedID!!))
+				startActivity(DeparturesActivity.getIntent(requireContext(), stop.code, stop.name, stop.feedID!!, true))
 			}
 			content.findViewById<Button>(R.id.navigation_button).setOnClickListener {
 				try {




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/departures/Departures.kt b/app/src/main/java/xyz/apiote/bimba/czwek/departures/Departures.kt
index 01f592aa9c53a29de8ef478f6d1ff9f504d97335..a5d676e2b8392b6aca5f6efb899b86d44fff2a97 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/departures/Departures.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/departures/Departures.kt
@@ -381,37 +381,41 @@ 	}
 
 	private fun setContent(view: View, ctx: Context, updating: Boolean = false) {
 		view.apply {
+			val arrivalStatus = findViewById<TextView>(R.id.arrival_status)
+			val arrivalTime = findViewById<TextView>(R.id.arrival_time)
+			val departureStatus = findViewById<TextView>(R.id.departure_status)
+			val departureTime = findViewById<TextView>(R.id.departure_time)
 			if (event.arrivalTime == event.departureTime) {
 				if (!event.exact) {
-					findViewById<TextView>(R.id.arrival_status).apply {
+					arrivalStatus.apply {
 						visibility = View.VISIBLE
 						text = context.getString(R.string.approximately)
 					}
 				} else {
-					findViewById<TextView>(R.id.arrival_status).visibility = View.GONE
+					arrivalStatus.visibility = View.GONE
 				}
 				findViewById<TextView>(R.id.arrival_time).apply {
 					text = event.arrivalTimeString(ctx)
 					visibility = View.VISIBLE
 				}
-				findViewById<TextView>(R.id.departure_status).visibility = View.GONE
-				findViewById<TextView>(R.id.departure_time).visibility = View.GONE
+				departureStatus.visibility = View.GONE
+				departureTime.visibility = View.GONE
 			} else {
 				if (event.arrivalTime != null) {
-					findViewById<TextView>(R.id.arrival_time).visibility = View.VISIBLE
-					findViewById<TextView>(R.id.arrival_time).text = event.arrivalTimeString(ctx)
-					findViewById<TextView>(R.id.arrival_status).visibility = View.VISIBLE
-					findViewById<TextView>(R.id.arrival_status).text = if (!event.exact) {
+					arrivalTime.visibility = View.VISIBLE
+					arrivalTime.text = event.arrivalTimeString(ctx)
+					arrivalStatus.visibility = View.VISIBLE
+					arrivalStatus.text = if (!event.exact) {
 						context?.getString(R.string.arrival_approximate)
 					} else {
 						context?.getString(R.string.arrival)
 					}
 				}
 				if (event.departureTime != null) {
-					findViewById<TextView>(R.id.departure_time).visibility = View.VISIBLE
-					findViewById<TextView>(R.id.departure_time).text = event.departureTimeString(ctx)
-					findViewById<TextView>(R.id.departure_status).visibility = View.VISIBLE
-					findViewById<TextView>(R.id.departure_status).text = if (!event.exact) {
+					departureTime.visibility = View.VISIBLE
+					departureTime.text = event.departureTimeString(ctx)
+					departureStatus.visibility = View.VISIBLE
+					departureStatus.text = if (!event.exact) {
 						context?.getString(R.string.departure_approximate)
 					} else {
 						context?.getString(R.string.departure)
@@ -633,4 +637,4 @@ 			}
 		}
 		return content
 	}
-}
\ No newline at end of file
+}




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/departures/DeparturesActivity.kt b/app/src/main/java/xyz/apiote/bimba/czwek/departures/DeparturesActivity.kt
index 8ca35eb6214c46644d0d28ee8f635bd4054a37d5..33e28dd4a73142f2adca069d4055549364e04223 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/departures/DeparturesActivity.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/departures/DeparturesActivity.kt
@@ -64,16 +64,19 @@ 		const val NAME_PARAM = "name"
 		const val FEED_PARAM = "feedID"
 		const val LINES_FILTER_PARAM = "linesFilter"
 		const val LINE_PARAM = "line"
+		const val EXACT_PARAM = "exact"
 
 		fun getIntent(
 			context: Context,
 			code: String,
 			name: String,
 			feedID: String,
-		) = Intent(context, DeparturesActivity::class.java).apply {
+			exact: Boolean = false,
+		)  = Intent(context, DeparturesActivity::class.java).apply {
 			putExtra(CODE_PARAM, code)
 			putExtra(NAME_PARAM, name)
 			putExtra(FEED_PARAM, feedID)
+			putExtra(EXACT_PARAM, exact)
 		}
 
 		fun getIntent(
@@ -342,9 +345,7 @@ 					timePickerStart!!.show(supportFragmentManager, "timePickerStart")
 					true
 				}
 
-				/* TODO elizabeth
 				R.id.terminus_arrival_showing -> {
-					// TODO get array from R.arrays
 					val options = arrayOf(
 						TERMINUS_ARRIVAL_GREY_OUT,
 						TERMINUS_ARRIVAL_HIDE,
@@ -368,7 +369,7 @@ 						}
 						.setNegativeButton(R.string.cancel) { _, _ -> }
 						.show()
 					true
-				}*/
+				}
 
 				else -> super.onOptionsItemSelected(it)
 			}
@@ -465,6 +466,8 @@ 		}
 	}
 
 	private fun getCode() = intent?.extras?.getString(CODE_PARAM)
+
+	private fun getExact() = intent?.extras?.getBoolean(EXACT_PARAM) == true
 
 	fun getDepartures(force: Boolean = false) {
 		binding.departuresUpdatesProgress.isIndeterminate = true
@@ -474,7 +477,7 @@ 		} else {
 			adapter.refreshItems()
 			setupSnackbar()
 		}
-		viewModel.getDepartures(this, viewModel.date, force)
+		viewModel.getDepartures(this, viewModel.date, force, getExact())
 	}
 
 	private fun setupSnackbar() {
@@ -587,7 +590,8 @@ 				feedID,
 				feedName,
 				code,
 				getName(),
-				linesFilter.toList()
+				linesFilter.toList(),
+				getExact()
 			)).copy(lines = linesFilter.toList())
 			repo.saveFavourite(favourite)
 			repo.close()




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/departures/DeparturesViewModel.kt b/app/src/main/java/xyz/apiote/bimba/czwek/departures/DeparturesViewModel.kt
index 21dbd04eefa862f93ad0f5ad95a0fc1ef48e80db..613aca9107d7bfebb31aed2d6189f045e0625f1b 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/departures/DeparturesViewModel.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/departures/DeparturesViewModel.kt
@@ -5,6 +5,7 @@
 package xyz.apiote.bimba.czwek.departures
 
 import android.app.Activity
+import xyz.apiote.bimba.czwek.R
 import android.content.Context
 import android.content.Intent
 import android.net.Uri
@@ -14,12 +15,14 @@ import androidx.lifecycle.MutableLiveData
 import androidx.lifecycle.ViewModel
 import kotlinx.coroutines.MainScope
 import kotlinx.coroutines.launch
+import org.openapitools.client.infrastructure.ServerException
 import xyz.apiote.bimba.czwek.api.Error
 import xyz.apiote.bimba.czwek.api.mapHttpError
 import xyz.apiote.bimba.czwek.repo.FeedInfo
 import xyz.apiote.bimba.czwek.repo.OfflineRepository
 import xyz.apiote.bimba.czwek.repo.OnlineRepository
 import xyz.apiote.bimba.czwek.repo.QrLocation
+import xyz.apiote.bimba.czwek.repo.Queryable
 import xyz.apiote.bimba.czwek.repo.StopEvents
 import xyz.apiote.bimba.czwek.repo.TrafficResponseException
 import java.time.LocalDate
@@ -46,7 +49,7 @@
 	// TODO observe in activity, refreshing is not enough
 	var date: LocalDate? = null
 
-	fun getDepartures(context: Context, date: LocalDate?, force: Boolean) {
+	fun getDepartures(context: Context, date: LocalDate?, force: Boolean, exact: Boolean) {
 		MainScope().launch {
 			try {
 				if (feed == null) {
@@ -61,7 +64,8 @@ 						feed!!.id,
 						code,
 						date,
 						context,
-						requestedItemsNumber
+						requestedItemsNumber,
+						exact
 					)
 				stopDepartures?.let {
 					if (stopDepartures.events.isEmpty()) {
@@ -75,6 +79,11 @@ 				if (!departures.isInitialized || force) {
 					_error.value = e.error
 				}
 				Log.w("Departures", "$e")
+			} catch (e: ServerException) {
+				if (!departures.isInitialized || force) {
+					_error.value = Error.fromTransitous(e)
+				}
+				Log.w("Departures", "Transitous returned ${e.statusCode}, ${e.message}")
 			}
 		}
 	}
@@ -146,7 +155,9 @@ 					}
 				} ?: throw TrafficResponseException(41)
 			}
 
-			null -> intent?.extras?.getString(DeparturesActivity.CODE_PARAM) ?: throw TrafficResponseException(41)
+			null -> intent?.extras?.getString(DeparturesActivity.CODE_PARAM)
+				?: throw TrafficResponseException(41)
+
 			else -> throw TrafficResponseException(41)
 		}
 	}




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/repo/Colour.kt b/app/src/main/java/xyz/apiote/bimba/czwek/repo/Colour.kt
index 4b740bd0b13df60606a1ab0eec3e0f1e884a2a39..01fea09a4f4c194afb4d5092864db448f821989b 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/repo/Colour.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/Colour.kt
@@ -18,7 +18,8 @@ 		return rgb
 	}
 
 	companion object {
-		fun fromHex(hex: String): Colour {
+		fun fromHex(hex: String?): Colour {
+			if (hex == null) return Colour(255u, 255u, 255u)
 			return hex.removePrefix("#").let {
 				if (it.isEmpty()) {
 					Colour(255u, 255u, 255u)




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/repo/Favourite.kt b/app/src/main/java/xyz/apiote/bimba/czwek/repo/Favourite.kt
index fa0a08da12719f5e4c70a44f5f002a995adbeb45..e646289182e4bc43f395baa22349625c864941a3 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/repo/Favourite.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/Favourite.kt
@@ -4,4 +4,12 @@ // SPDX-License-Identifier: AGPL-3.0-or-later
 
 package xyz.apiote.bimba.czwek.repo
 
-data class Favourite(val sequence: Int?, val feedID: String, val feedName: String, val stopCode: String, val stopName: String, val lines: List<String>)
\ No newline at end of file
+data class Favourite(
+	val sequence: Int?,
+	val feedID: String,
+	val feedName: String,
+	val stopCode: String,
+	val stopName: String,
+	val lines: List<String>,
+	val exact: Boolean
+)
\ No newline at end of file




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/repo/Interfaces.kt b/app/src/main/java/xyz/apiote/bimba/czwek/repo/Interfaces.kt
index e612503b10f270a49961d1482475da55fb3f47db..9b8b1da3afd0b34a1f9fb2d2812be70543d2c998 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/repo/Interfaces.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/Interfaces.kt
@@ -35,7 +35,8 @@ 		feedID: String,
 		stop: String,
 		date: LocalDate?,
 		context: Context,
-		limit: Int?
+		limit: Int?,
+		exact: Boolean
 	): StopEvents?
 
 	suspend fun getLocatablesIn(




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/repo/LineType.kt b/app/src/main/java/xyz/apiote/bimba/czwek/repo/LineType.kt
index 46be84493816f7bcf0e5e8d25416344dc81b6172..be76479081cc2763d596e2271e83e03096730d91 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/repo/LineType.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/LineType.kt
@@ -7,6 +7,7 @@
 import xyz.apiote.bimba.czwek.api.LineTypeV1
 import xyz.apiote.bimba.czwek.api.LineTypeV2
 import xyz.apiote.bimba.czwek.api.LineTypeV3
+import xyz.apiote.bimba.czwek.api.transitous.model.Mode
 
 enum class LineType {
 	UNKNOWN, TRAM, BUS, TROLLEYBUS, METRO, RAIL, FERRY, CABLE_TRAM, CABLE_CAR, FUNICULAR, MONORAIL, PLANE;
@@ -43,22 +44,28 @@ 				LineTypeV3.MONORAIL -> valueOf("MONORAIL")
 			}
 		}
 
-		fun fromTransitous(clasz: Int): LineType {
-			return when (clasz) {
-				0 -> PLANE
-				1 -> RAIL
-				2 -> RAIL
-				3 -> BUS
-				4 -> RAIL
-				5 -> RAIL
-				6 -> RAIL
-				7 -> METRO
-				8 -> METRO
-				9 -> TRAM
-				10 -> BUS
-				11 -> FERRY
-				12 -> UNKNOWN // other
-				else -> UNKNOWN
+		fun fromTransitous2(mode: Mode): LineType {
+			return when(mode) {
+				Mode.AIRPLANE -> PLANE
+				Mode.WALK -> UNKNOWN
+				Mode.BIKE -> UNKNOWN
+				Mode.CAR -> UNKNOWN
+				Mode.RENTAL -> UNKNOWN
+				Mode.TRANSIT -> UNKNOWN
+				Mode.TRAM -> TRAM
+				Mode.SUBWAY -> METRO
+				Mode.FERRY -> FERRY
+				Mode.METRO -> METRO
+				Mode.BUS -> BUS
+				Mode.COACH -> BUS
+				Mode.RAIL -> RAIL
+				Mode.HIGHSPEED_RAIL -> RAIL
+				Mode.LONG_DISTANCE -> RAIL
+				Mode.NIGHT_RAIL -> RAIL
+				Mode.REGIONAL_FAST_RAIL -> RAIL
+				Mode.REGIONAL_RAIL -> RAIL
+				Mode.OTHER -> UNKNOWN
+				Mode.CAR_PARKING -> UNKNOWN
 			}
 		}
 	}




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/repo/OfflineRepository.kt b/app/src/main/java/xyz/apiote/bimba/czwek/repo/OfflineRepository.kt
index 309d6ec0f00248d543db51e92389dfddb1457a3d..07850536570dac297712700dc046d3805f2912c7 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/repo/OfflineRepository.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/OfflineRepository.kt
@@ -55,7 +55,8 @@ 			cursor.getString(2),
 			cursor.getString(3),
 			stopCode,
 			cursor.getString(1),
-			cursor.getString(4).split("||").filter { it != "" }
+			cursor.getString(4).split("||").filter { it != "" },
+			false  // TODO get exact from database
 		)
 		cursor.close()
 		return f
@@ -81,7 +82,8 @@ 					cursor.getString(2),
 					cursor.getString(3),
 					cursor.getString(4),
 					cursor.getString(1),
-					cursor.getString(5).split("||").filter { it != "" }
+					cursor.getString(5).split("||").filter { it != "" },
+					false // TODO get exact from database
 				)
 			)
 		}
@@ -199,7 +201,8 @@ 		feedID: String,
 		stop: String,
 		date: LocalDate?,
 		context: Context,
-		limit: Int?
+		limit: Int?,
+		exact: Boolean
 	): StopEvents? {
 		TODO("Not yet implemented")
 	}




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/repo/OnlineRepository.kt b/app/src/main/java/xyz/apiote/bimba/czwek/repo/OnlineRepository.kt
index 0b47623ff0b1f191b90a9e19f308f9a50878117f..3f078daac0a5c69f2679a0e00142d3172234aae9 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/repo/OnlineRepository.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/OnlineRepository.kt
@@ -50,7 +50,6 @@ import xyz.apiote.bimba.czwek.api.responses.QueryablesResponseV1
 import xyz.apiote.bimba.czwek.api.responses.QueryablesResponseV2
 import xyz.apiote.bimba.czwek.api.responses.QueryablesResponseV3
 import xyz.apiote.bimba.czwek.api.responses.QueryablesResponseV4
-import xyz.apiote.bimba.czwek.units.Metre
 import java.time.LocalDate
 
 // todo [3.2] in Repository check if responses are BARE or HTML
@@ -113,10 +112,11 @@ 		feedID: String,
 		stop: String,
 		date: LocalDate?,
 		context: Context,
-		limit: Int?
+		limit: Int?,
+		exact: Boolean
 	): StopEvents? {
 		return if (feedID == "transitous") {
-			getTransitousDepartures(context, stop, date, limit)
+			getTransitousDepartures(context, stop, date, limit, exact)
 		} else {
 			val result = xyz.apiote.bimba.czwek.api.getDepartures(
 				context,
@@ -174,12 +174,10 @@ 		bl: Position,
 		tr: Position,
 	): List<Locatable>? {
 		val transitousQueryables = if (Server.get(context).feeds.transitousEnabled()) {
-			val centre = Position(
-				latitude = (bl.latitude + tr.latitude) / 2,
-				longitude = (bl.longitude + tr.longitude) / 2
-			)
 			locateTransitousQueryables(
-				centre, context, Metre(centre.distanceTo(bl).coerceAtMost(5000f).toDouble())
+				Position(bl.latitude, tr.longitude),
+				Position(tr.latitude, bl.longitude),
+				context
 			).map {
 				when (it) {
 					is Stop -> it




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/repo/Position.kt b/app/src/main/java/xyz/apiote/bimba/czwek/repo/Position.kt
index d03cf34ae8cb789df7e078f0f24c509e63343a12..39ec428637d1165e0c41b12da12f7420de27a8e9 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/repo/Position.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/Position.kt
@@ -9,6 +9,7 @@ import xyz.apiote.bimba.czwek.api.PositionV1
 
 data class Position(val latitude: Double, val longitude: Double) {
 	constructor(p: PositionV1) : this(p.latitude, p.longitude)
+
 	fun isZero(): Boolean {
 		return latitude == 0.0 && longitude == 0.0
 	}
@@ -22,4 +23,23 @@ 			latitude = other.latitude
 			longitude = other.longitude
 		})
 	}
-}
\ No newline at end of file
+
+	override fun toString(): String {
+		return "$latitude,$longitude"
+	}
+
+	companion object {
+		fun comparator(centre: Position) = object : Comparator<Position> {
+			override fun compare(
+				o1: Position?,
+				o2: Position?
+			): Int {
+				if (o1 == null || o2 == null) {
+					throw NullPointerException()
+				}
+
+				return o1.distanceTo(centre).compareTo(o2.distanceTo(centre))
+			}
+		}
+	}
+}




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/repo/Stop.kt b/app/src/main/java/xyz/apiote/bimba/czwek/repo/Stop.kt
index af81fbf17847b928c5c2ee0c674cb4a451bf858a..8448bc7210a18e9e0d352a657e4ef0db6139848a 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/repo/Stop.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/Stop.kt
@@ -19,6 +19,8 @@ import xyz.apiote.bimba.czwek.RoundedBackgroundSpan
 import xyz.apiote.bimba.czwek.api.StopV1
 import xyz.apiote.bimba.czwek.api.StopV2
 import xyz.apiote.bimba.czwek.api.StopV3
+import xyz.apiote.bimba.czwek.api.transitous.model.Match
+import xyz.apiote.bimba.czwek.api.transitous.model.Place
 
 
 data class Stop(
@@ -28,8 +30,20 @@ 	val nodeName: String,
 	val zone: String,
 	val feedID: String?,
 	val position: Position,
-	val changeOptions: List<ChangeOption>
+	val changeOptions: List<ChangeOption>,
+	val description: String?
 ) : Queryable, Locatable, StopAbstract {
+	companion object {
+		fun distanceComparator(centre: Position) = object : Comparator<Stop> {
+			override fun compare(
+				o1: Stop?,
+				o2: Stop?
+			): Int {
+				return Position.comparator(centre).compare(o1?.location(), o2?.location())
+			}
+
+		}
+	}
 
 	override fun icon(context: Context, scale: Float): Drawable {
 		return super.icon(context, nodeName, scale)
@@ -46,7 +60,8 @@ 		s.name,
 		s.zone,
 		null,
 		Position(s.position),
-		s.changeOptions.map { ChangeOption(it) })
+		s.changeOptions.map { ChangeOption(it) },
+		null)
 
 	constructor(s: StopV2) : this(
 		s.code,
@@ -55,7 +70,8 @@ 		s.nodeName,
 		s.zone,
 		s.feedID,
 		Position(s.position),
-		s.changeOptions.map { ChangeOption(it) })
+		s.changeOptions.map { ChangeOption(it) },
+		null)
 
 	constructor(s: StopV3) : this(
 		s.code,
@@ -64,7 +80,30 @@ 		s.nodeName,
 		s.zone,
 		s.feedID,
 		Position(s.position),
-		s.changeOptions.map { ChangeOption(it) })
+		s.changeOptions.map { ChangeOption(it) },
+		null)
+
+	constructor(s: Match): this(
+		s.id,
+		s.name,
+		s.areas.sortedBy { it.adminLevel }.map { it.name }.distinct().joinToString() + s.name,
+		"",
+		"transitous",
+		Position(s.lat.toDouble(), s.lon.toDouble()),
+		emptyList(),
+		s.areas.sortedBy { it.adminLevel }.map { it.name }.distinct().joinToString()
+	)
+
+	constructor(s: Place): this(
+		s.stopId!!,
+		s.name,
+		s.name,
+		"",
+		"transitous",
+		Position(s.lat.toDouble(), s.lon.toDouble()),
+		emptyList(),
+		""
+	)
 
 	fun changeOptions(context: Context, decoration: LineDecoration): Pair<Spannable, String> {
 		return Pair(changeOptions.groupBy { it.line }




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/search/Results.kt b/app/src/main/java/xyz/apiote/bimba/czwek/search/Results.kt
index 6edad804085f30265578b89d4abe728cf3c147c8..2df5391da9da2b9bb1eddecfc5ba8bc2c4568ef9 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/search/Results.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/search/Results.kt
@@ -30,6 +30,7 @@ class BimbaViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
 	val root: View = itemView.findViewById(R.id.suggestion)
 	val icon: ImageView = itemView.findViewById(R.id.suggestion_image)
 	val title: TextView = itemView.findViewById(R.id.suggestion_title)
+	val changeOptions: TextView = itemView.findViewById(R.id.suggestion_change_options)
 	val description: TextView = itemView.findViewById(R.id.suggestion_description)
 	val feedName: TextView = itemView.findViewById(R.id.feed_name)
 	val distance: TextView = itemView.findViewById(R.id.distance)
@@ -77,7 +78,7 @@ 			holder?.icon?.apply {
 				setImageDrawable(stopStub.icon(context!!))
 				contentDescription = context.getString(R.string.stop_content_description)
 			}
-			holder?.description?.text = when {
+			holder?.changeOptions?.text = when {
 				stopStub.zone != "" && stopStub.onDemand -> context?.getString(
 					R.string.stop_stub_on_demand_in_zone,
 					stopStub.zone
@@ -119,7 +120,7 @@ 					holder?.arrow?.apply {
 						setImageResource(R.drawable.arrow)
 						rotation = angle
 						visibility = View.VISIBLE
-						contentDescription = "Arrow" // TODO
+						contentDescription = context?.getString(R.string.arrow) ?: "Arrow pointing to the stop" // TODO add angle
 					}
 					holder?.distance?.apply {
 						val us = UnitSystem.getSelected(context!!)
@@ -147,12 +148,23 @@ 				holder?.feedName?.visibility = View.GONE
 			}
 			context?.let {
 				stop.changeOptions(it, Stop.LineDecoration.fromPreferences(context)).let { changeOptions ->
-					holder?.description?.apply {
-						text = changeOptions.first
-						contentDescription = changeOptions.second
+					if (changeOptions.first.isEmpty()) {
+						holder?.changeOptions?.visibility = View.GONE
+					} else {
+						holder?.changeOptions?.apply {
+							text = changeOptions.first
+							contentDescription = changeOptions.second
+							visibility = View.VISIBLE
+						}
 					}
 				}
 			}
+			if (stop.description.isNullOrBlank()) {
+				holder?.description?.visibility = View.GONE
+			} else {
+				holder?.description?.visibility = View.VISIBLE
+				holder?.description?.text = stop.description
+			}
 		}
 
 		private fun bindLine(
@@ -172,7 +184,7 @@ 				holder?.feedName?.visibility = View.VISIBLE
 				holder?.feedName?.text = feeds?.get(line.feedID)?.name ?: ""
 			}
 			holder?.title?.text = line.name
-			holder?.description?.text = if (line.headsigns.size == 1) {
+			holder?.changeOptions?.text = if (line.headsigns.size == 1) {
 				context?.getString(
 					R.string.line_headsign,
 					line.headsigns[0].joinToString { it })
@@ -182,7 +194,7 @@ 					R.string.line_headsigns,
 					line.headsigns[0].joinToString { it },
 					line.headsigns[1].joinToString { it })
 			}
-			holder?.description?.contentDescription = if (line.headsigns.size == 1) {
+			holder?.changeOptions?.contentDescription = if (line.headsigns.size == 1) {
 				context?.getString(
 					R.string.line_headsign_content_description,
 					line.headsigns[0].joinToString { it })




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/search/ResultsActivity.kt b/app/src/main/java/xyz/apiote/bimba/czwek/search/ResultsActivity.kt
index b29210b31e39602eb0a83b32b219f1d55c41ee7b..0c87cba970f5cce61d4d0d45aec18eef3015dfa7 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/search/ResultsActivity.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/search/ResultsActivity.kt
@@ -32,6 +32,7 @@ import com.google.openlocationcode.OpenLocationCode
 import kotlinx.coroutines.MainScope
 import kotlinx.coroutines.Runnable
 import kotlinx.coroutines.launch
+import org.openapitools.client.infrastructure.ServerException
 import xyz.apiote.bimba.czwek.R
 import xyz.apiote.bimba.czwek.api.Error
 import xyz.apiote.bimba.czwek.databinding.ActivityResultsBinding
@@ -114,20 +115,20 @@ 		binding.resultsRecycler.adapter = adapter
 
 		when (getMode()) {
 			Mode.MODE_LOCATION -> {
-				supportActionBar?.title = getString(R.string.stops_nearby)
+				binding.topAppBar.title = getString(R.string.stops_nearby)
 				locate()
 			}
 
 			Mode.MODE_SHORT_CODE_LOCATION -> {
 				val query = intent.extras?.getString(QUERY_KEY)
-				supportActionBar?.title = getString(R.string.stops_near_code, query)
+				binding.topAppBar.title = getString(R.string.stops_near_code, query)
 				shortOLC = OpenLocationCode(query)
 				locate()
 			}
 
 			Mode.MODE_SHORT_CODE -> {
 				val query = intent.extras?.getString(QUERY_KEY)
-				supportActionBar?.title = getString(R.string.stops_near_code, query)
+				binding.topAppBar.title = getString(R.string.stops_near_code, query)
 				val split = query!!.trim().split(" ")
 				val code = split.first().trim(',').trim()
 				val freePart = split.drop(1).joinToString(" ")
@@ -147,7 +148,7 @@ 			Mode.MODE_POSITION -> {
 				val query = intent.extras?.getString(QUERY_KEY)
 				val lat = intent.extras?.getDouble(LATITUDE_KEY)
 				val lon = intent.extras?.getDouble(LONGITUDE_KEY)
-				supportActionBar?.title = getString(R.string.stops_near_code, query)
+				binding.topAppBar.title = getString(R.string.stops_near_code, query)
 				getQueryablesByLocation(Location(null).apply {
 					latitude = lat!!
 					longitude = lon!!
@@ -156,7 +157,7 @@ 			}
 
 			Mode.MODE_SEARCH -> {
 				val query = intent.extras?.getString(QUERY_KEY)!!
-				supportActionBar?.title = getString(R.string.results_for, query)
+				binding.topAppBar.title = getString(R.string.results_for, query)
 				getQueryablesByQuery(query, this)
 			}
 		}
@@ -270,6 +271,9 @@ 				updateItems(result, null, false)
 			} catch (e: TrafficResponseException) {
 				Log.w("Suggestion", "$e")
 				showError(e.error)
+			} catch (e: ServerException) {
+				Log.w("Suggestion", "Transitous returned: ${e.statusCode}, ${e.message}")
+				showError(Error.fromTransitous(e))
 			}
 		}
 	}
@@ -289,6 +293,9 @@ 				updateItems(result, position, showArrow)
 			} catch (e: TrafficResponseException) {
 				Log.w("Suggestion", "$e")
 				showError(e.error)
+			} catch (e: ServerException) {
+				Log.w("Suggestion", "Transitous returned: ${e.statusCode}, ${e.message}")
+				showError(Error.fromTransitous(e))
 			}
 		}
 	}




diff --git a/app/src/main/res/layout/departure_bottom_sheet.xml b/app/src/main/res/layout/departure_bottom_sheet.xml
index f2bb7dc23101df9ee440b0a8997d2ebbfbe753ce..8fb3f89430cb97b27c1a5b1b825567850ee1c374 100644
--- a/app/src/main/res/layout/departure_bottom_sheet.xml
+++ b/app/src/main/res/layout/departure_bottom_sheet.xml
@@ -24,6 +24,7 @@         android:id="@+id/arrival_status"
         android:layout_width="wrap_content"
         android:layout_height="wrap_content"
         android:layout_marginTop="0dp"
+        android:visibility="gone"
         android:textAppearance="@style/TextAppearance.Material3.BodySmall"
         app:layout_constraintEnd_toEndOf="parent"
         app:layout_constraintStart_toStartOf="parent"
@@ -35,6 +36,7 @@         android:id="@+id/arrival_time"
         android:layout_width="wrap_content"
         android:layout_height="wrap_content"
         android:layout_marginTop="0dp"
+        android:visibility="gone"
         android:textAppearance="@style/TextAppearance.Material3.DisplaySmall"
         app:layout_constraintEnd_toEndOf="parent"
         app:layout_constraintStart_toStartOf="parent"




diff --git a/app/src/main/res/layout/result.xml b/app/src/main/res/layout/result.xml
index 76614ac6dfa60c71c84bfc0c97448af7c18426af..6146e5c5891d3f052e445ddd3a2e4c8fff9fb1a3 100644
--- a/app/src/main/res/layout/result.xml
+++ b/app/src/main/res/layout/result.xml
@@ -74,7 +74,7 @@ 		tool:text="TfL London" />
 
 	<!-- todo maxWidth or separate layout for graphView -->
 	<com.google.android.material.textview.MaterialTextView
-		android:id="@+id/suggestion_description"
+		android:id="@+id/suggestion_change_options"
 		style="@style/Theme.Bimba.SearchResult.Description"
 		android:layout_width="0dp"
 		android:layout_height="wrap_content"
@@ -85,12 +85,31 @@ 		android:lineSpacingMultiplier="1.6"
 		android:maxWidth="360dp"
 		android:paddingTop="4dp"
 		android:paddingBottom="4dp"
-		app:layout_constraintBottom_toBottomOf="parent"
 		app:layout_constraintEnd_toStartOf="@+id/distance"
 		app:layout_constraintHorizontal_bias="0.0"
 		app:layout_constraintStart_toStartOf="@+id/suggestion_title"
 		app:layout_constraintTop_toBottomOf="@+id/feed_name"
 		app:layout_constraintVertical_bias="1.0"
 		tool:text="Metropolitan » Baker Street, Tower Hill The Monument, Westminster, Piccadilly Circus, Oxford Street" />
+
+	<!-- todo maxWidth or separate layout for graphView -->
+	<com.google.android.material.textview.MaterialTextView
+		android:id="@+id/suggestion_description"
+		style="@style/Theme.Bimba.SearchResult.Description"
+		android:layout_width="0dp"
+		android:layout_height="wrap_content"
+		android:layout_marginTop="4dp"
+		android:layout_marginEnd="4dp"
+		android:includeFontPadding="false"
+		android:lineSpacingMultiplier="1.6"
+		android:maxWidth="360dp"
+		android:paddingTop="4dp"
+		android:paddingBottom="4dp"
+		app:layout_constraintEnd_toStartOf="@+id/distance"
+		app:layout_constraintHorizontal_bias="0.0"
+		app:layout_constraintStart_toStartOf="@+id/suggestion_title"
+		app:layout_constraintTop_toBottomOf="@+id/suggestion_change_options"
+		app:layout_constraintVertical_bias="1.0"
+		tool:text="England, London" />
 
 </androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file




diff --git a/app/src/main/res/menu/departures_menu.xml b/app/src/main/res/menu/departures_menu.xml
index 9aef4cf6ed99cb06a663eeb16dedd94e4145a664..a5fd28a6a6ebcdcd9eca1d2b7da8a2d77228dedc 100644
--- a/app/src/main/res/menu/departures_menu.xml
+++ b/app/src/main/res/menu/departures_menu.xml
@@ -39,12 +39,10 @@ 		android:icon="@drawable/calendar"
 		app:showAsAction="ifRoom"
 		android:contentDescription="@string/title_select_date"
 		android:title="@string/title_select_date"/>
-	<!-- TODO elizabeth
 	<item
 		android:id="@+id/terminus_arrival_showing"
 		android:icon="@drawable/terminus"
 		app:showAsAction="ifRoom"
 		android:contentDescription="@string/terminus_arrival_showing"
-		android:title="@string/terminus_arrival_showing"
-		/>-->
+		android:title="@string/terminus_arrival_showing" />
 </menu>




diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index d7eabb6448290335cfad6b1b52b8943562ede311..b00ba98ca26bb4cd3ff5911e98920bbbd4505df8 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -273,7 +273,7 @@ 	Bimba has crashed
 	<string name="acra_notification_text">An unexpected obstruction has shown up on Bimba’s way. Do you want to send a report?</string>
 	<string name="send">Send</string>
 	<string name="discard">Discard</string>
-	<string name="send_with_comment">Send with commend</string>
+	<string name="send_with_comment">Send with comment</string>
 	<string name="acra_notification_comment">Comment added to crash report</string>
 	<string name="filtered_departures">Filtered departures</string>
 	<string name="alerts">Alerts</string>
@@ -286,6 +286,7 @@ 	link to email
 	<string name="transitous_description">A community-run provider-neutral international public transport routing service. Coverage is available at https://transitous.org/sources/</string>
 	<string name="transitous_attribution">Transitous (https://transitous.org) API provided by Spline (https://routing.spline.de). Localities (https://github.com/public-transport/transitous/tree/main/feeds) maintained by the community.</string>
 	<string name="local_time">local time</string>
+	<string name="arrow">Arrow pointing to the stop</string>
     <string name="departure_arrived">arrived</string>
 	<string name="arrival_approximate">approx. arr.</string>
 	<string name="arrival">arrival</string>




diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index 1f990cc3b25e064ea38ef5e14bc97f87aa2297e5..7bc3779f9823af620c497c26f8a3c661eb9a8d37 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -78,4 +78,4 @@
 	<style name="Preference.SwitchPreferenceCompat" parent="@style/Preference.SwitchPreferenceCompat.Material" tool:ignore="ResourceCycle">
 		<item name="widgetLayout">@layout/preferences_switch_material</item>
 	</style>
-</resources>
\ No newline at end of file
+</resources>




diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml
index bdc076567a58b017c5a152746e2ba04f6e14a2b6..febf014d61a368f90e36f1774a2b1fbcf8f3baff 100644
--- a/app/src/main/res/values-en-rGB/strings.xml
+++ b/app/src/main/res/values-en-rGB/strings.xml
@@ -271,7 +271,7 @@ 	Bimba has crashed
 	<string name="acra_notification_text">An unexpected obstruction has shown up on Bimba’s way. Do you want to send a report?</string>
 	<string name="send">Send</string>
 	<string name="discard">Discard</string>
-	<string name="send_with_comment">Send with commend</string>
+	<string name="send_with_comment">Send with comment</string>
 	<string name="acra_notification_comment">Comment added to crash report</string>
 	<string name="filtered_departures">Filtered departures</string>
 	<string name="alerts">Alerts</string>
@@ -284,6 +284,7 @@ 	link to email
 	<string name="transitous_description">A community-run provider-neutral international public transport routing service. Coverage is available at https://transitous.org/sources/</string>
 	<string name="transitous_attribution">Transitous (https://transitous.org) API provided by Spline (https://routing.spline.de). Localities (https://github.com/public-transport/transitous/tree/main/feeds) maintained by the community.</string>
 	<string name="local_time">local time</string>
+	<string name="arrow">Arrow pointing to the stop</string>
     <string name="departure_arrived">arrived</string>
 	<string name="arrival_approximate">approx. arr.</string>
 	<string name="arrival">arrival</string>




diff --git a/app/src/main/res/values-en-rUS/strings.xml b/app/src/main/res/values-en-rUS/strings.xml
index 075852ab7b5dd2b4fc0fca4e326a543a1ea2f949..53b5db71611451c372af76e76bda67502c14d749 100644
--- a/app/src/main/res/values-en-rUS/strings.xml
+++ b/app/src/main/res/values-en-rUS/strings.xml
@@ -268,7 +268,7 @@ 	Bimba crashed
 	<string name="acra_notification_text">An unexpected obstruction showed up on Bimba’s way. Do you want to send a report?</string>
 	<string name="send">Send</string>
 	<string name="discard">Discard</string>
-	<string name="send_with_comment">Send with commend</string>
+	<string name="send_with_comment">Send with comment</string>
 	<string name="acra_notification_comment">Comment added to crash report</string>
 	<string name="filtered_departures">Filtered departures</string>
 	<string name="alerts">Alerts</string>
@@ -282,6 +282,7 @@ 	No email app installed
 	<string name="transitous_description">A community-run provider-neutral international public transport routing service. Coverage is available at https://transitous.org/sources/</string>
 	<string name="transitous_attribution">Transitous (https://transitous.org) API provided by Spline (https://routing.spline.de). Localities (https://github.com/public-transport/transitous/tree/main/feeds) maintained by the community.</string>
 	<string name="local_time">local time</string>
+	<string name="arrow">Arrow pointing to the stop</string>
     <string name="departure_arrived">arrived</string>
 	<string name="arrival_approximate">approx. arr.</string>
 	<string name="arrival">arrival</string>




diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml
index 9e162712580cf6ac01c38c1dec5c8a8337d52f9f..09dc3798658a04c334efe19c2f20fe8924a6a437 100644
--- a/app/src/main/res/values-pl/strings.xml
+++ b/app/src/main/res/values-pl/strings.xml
@@ -302,6 +302,7 @@     link do e-maila
     <string name="no_email_app">Brak aplikacji e-mail</string>
     <string name="transitous_attribution">API Transitous (https://transitous.org) dostarczane przez Spline (https://routing.spline.de). Lokalizacje (https://github.com/public-transport/transitous/tree/main/feeds) utrzymywane przez społeczność.</string>
     <string name="local_time">czas lokalny</string>
+	<string name="arrow">strzałka wskazująca kierunek do przystanku</string>
     <string name="departure_arrived">przyjechał</string>
     <string name="arrival_approximate">przyb. przyj.</string>
     <string name="arrival">przyjazd</string>




diff --git a/app/src/main/res/xml/root_preferences.xml b/app/src/main/res/xml/root_preferences.xml
index 47230fb1e059c9c12699e5d229b2b65e3182d971..c82baad406fe24af9a7df280f960432d107ec9ee 100644
--- a/app/src/main/res/xml/root_preferences.xml
+++ b/app/src/main/res/xml/root_preferences.xml
@@ -38,7 +38,6 @@ 			app:key="line_decoration"
 			app:title="@string/line_decorations"
 			app:useSimpleSummaryProvider="true"
 			/>
-		<!-- TODO elizabeth
 		<ListPreference
 			app:defaultValue="grey_out"
 			app:entries="@array/terminus_arrival_showing"
@@ -47,6 +46,6 @@ 			app:icon="@drawable/terminus"
 			app:key="terminus_arrival_showing"
 			app:title="@string/terminus_arrival_showing"
 			app:useSimpleSummaryProvider="true"
-			/>-->
+			/>
 	</PreferenceCategory>
 </PreferenceScreen>
\ No newline at end of file




diff --git a/build.gradle.kts b/build.gradle.kts
index 86f0ddbf3403895e1c7ff8b91dd0849b8edda743..469ffc011b5a5a8007953b795633669f6779049e 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -6,6 +6,8 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules.
 plugins {
     id("com.android.application") version "8.7.2" apply false
     id("com.android.library") version "8.7.2" apply false
+    id("org.openapi.generator") version "7.9.0" apply false
+    id("de.undercouch.download") version "5.6.0" apply false
     kotlin("android") version "2.0.10" apply false
     kotlin("jvm") version "1.7.20" apply false
     kotlin("plugin.parcelize") version "1.8.20" apply false




diff --git a/metadata/en/changelogs/34.txt b/metadata/en/changelogs/34.txt
new file mode 100644
index 0000000000000000000000000000000000000000..2850a6537f1b4180d4c748184bddd828cfbb0898
--- /dev/null
+++ b/metadata/en/changelogs/34.txt
@@ -0,0 +1 @@
+* switch to MOTIS 2 for Transitous




diff --git a/metadata/en-US/changelogs/34.txt b/metadata/en-US/changelogs/34.txt
new file mode 100644
index 0000000000000000000000000000000000000000..2850a6537f1b4180d4c748184bddd828cfbb0898
--- /dev/null
+++ b/metadata/en-US/changelogs/34.txt
@@ -0,0 +1 @@
+* switch to MOTIS 2 for Transitous




diff --git a/metadata/pl-PL/changelogs/34.txt b/metadata/pl-PL/changelogs/34.txt
new file mode 100644
index 0000000000000000000000000000000000000000..da3c6ffd9c2a9460320ec9e6c68832f8b9da75ba
--- /dev/null
+++ b/metadata/pl-PL/changelogs/34.txt
@@ -0,0 +1 @@
+* przełączenie na MOTIS 2 dla Transitous