map improvements and GTFS vehicle tracking bases

This commit is contained in:
2026-09-02 22:09:06 +02:00
parent f0299e4371
commit a2c5f1aac3
9 changed files with 187 additions and 43 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-09-01T18:25:26.823607018Z">
<DropdownSelection timestamp="2026-09-02T15:28:00.985067432Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=/home/lukas/.config/.android/avd/Medium_Phone_OSS.avd" />
+1
View File
@@ -59,4 +59,5 @@ dependencies {
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)
implementation(libs.gtfs.realtime.bindings)
}
@@ -4,18 +4,11 @@ import android.app.Activity
import android.os.Handler
import android.os.Looper
import android.widget.Toast
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.google.transit.realtime.GtfsRealtime
import kotlinx.serialization.Serializable
import okhttp3.OkHttpClient
import okhttp3.Request
import ovh.plrapps.mapcompose.api.addMarker
import ovh.plrapps.mapcompose.api.removeMarker
import ovh.plrapps.mapcompose.ui.state.markers.model.RenderingStrategy
import java.net.URL
@Serializable
@@ -82,32 +75,36 @@ private val GTFSMainHandler = Handler(Looper.getMainLooper())
private var GTFSTrackingThread: Thread? = null
private var GTFSIsTracking = false
private fun fetchGTFSPosition(gtfsUrl: URL,api_key: String, callee: Activity) {
private fun fetchGTFSPositions(gtfsUrl: URL, api_key: String, callee: Activity) {
try {
val request = Request.Builder()
.url("${gtfsUrl.protocol}://${gtfsUrl.host}${"/"}")
.url("${gtfsUrl.protocol}://${gtfsUrl.host}${callee.getString(R.string.combined_feed_path)}")
.header("User-Agent", "${BuildConfig.APPLICATION_ID}/${BuildConfig.VERSION_NAME} (Android)")
.header("X-Access-Token", api_key)
.build()
gtfshttpClient.newCall(request).execute().use { response ->
var message = if (response.isSuccessful) {
response.body?.string() ?: "Empty response"
} else {
"Error: ${response.code} ${response.message}"
if (!response.isSuccessful) {
throw Exception("Error: ${response.code} ${response.message}")
}
// TODO: Handle response
val bytes = response.body?.bytes() ?: throw Exception("Empty response")
val feed = GtfsRealtime.FeedMessage.parseFrom(bytes)
feed.entityList.forEach { entity ->
if (entity.hasVehicle()) {
// use vehicle
}
}
}
} catch (e: Exception) {
// Post UI update to main thread
GTFSMainHandler.post {
Toast.makeText(callee, e.message ?: "Unknown error", Toast.LENGTH_SHORT).show()
}
}
}
// Start the timer with a URL:
fun startGTFSTracking(url: String, api_key: String, callee: Activity) {
if (GTFSIsTracking) return
@@ -197,7 +194,7 @@ fun startGTFSTracking(url: String, api_key: String, callee: Activity) {
return@Thread
}
while (GTFSIsTracking) {
fetchGTFSPosition(gtfsUrl,api_key, callee)
fetchGTFSPositions(gtfsUrl,api_key, callee)
try {
Thread.sleep(1000) // Wait 1 second between requests
} catch (e: InterruptedException) {
@@ -4,10 +4,15 @@ import android.app.Activity
import android.os.Handler
import android.os.Looper
import android.widget.Toast
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import ovh.plrapps.mapcompose.api.addPath
import ovh.plrapps.mapcompose.api.moveMarker
import ovh.plrapps.mapcompose.api.removePath
import ovh.plrapps.mapcompose.api.updatePath
import java.net.URL
@Serializable
@@ -28,8 +33,9 @@ val haukhttpClient = OkHttpClient.Builder()
private val HaukMainHandler = Handler(Looper.getMainLooper())
private var HaukTrackingThread: Thread? = null
private var HaukIsTracking = false
var HaukIsTracking = false
private var PathCreated = false
private fun fetchHaukPosition(haukUrl: String, callee: Activity) {
try {
val url = URL(haukUrl)
@@ -76,6 +82,18 @@ private fun fetchHaukPosition(haukUrl: String, callee: Activity) {
} catch (e: Exception) {
// Marker might not exist yet, ignore
}
pathDataBuilder.addPoint(longitudeToXNormalized(lon), latitudeToYNormalized(lat));
if (!PathCreated) {
mapState.addPath(
"target_path", color = Color(0xFF0000FF),
pathData = pathDataBuilder.build()?: return@post,
width = 1.dp
);
PathCreated = true
}
mapState.updatePath("target_path", pathData = pathDataBuilder.build())
}
} else {
HaukMainHandler.post {
@@ -95,6 +113,10 @@ fun startHaukTracking(url: String, callee: Activity) {
if (HaukIsTracking) return
HaukIsTracking = true
HaukTrackingThread = Thread {
while (HaukIsTracking) {
fetchHaukPosition(url, callee)
@@ -111,4 +133,6 @@ fun stopHaukTracking() {
HaukIsTracking = false
HaukTrackingThread?.interrupt()
HaukTrackingThread = null
mapState.removePath("target_path");
PathCreated = false;
}
@@ -31,7 +31,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import kotlinx.coroutines.launch
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -52,9 +51,18 @@ import java.io.FileInputStream
import java.io.FileOutputStream
import java.net.URL
import android.os.StrictMode
import android.widget.Toast
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.sp
import ovh.plrapps.mapcompose.api.getMarkerInfo
import android.util.Log
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.ui.Alignment
import ovh.plrapps.mapcompose.api.addPath
import ovh.plrapps.mapcompose.api.makePathDataBuilder
import java.security.MessageDigest
// MapState configuration for OpenStreetMap
@@ -67,15 +75,42 @@ val mapState = MapState(
fullHeight = 67108864,
tileSize = 256
)
val pathDataBuilder = mapState.makePathDataBuilder();
var target_speed by mutableStateOf("0.0km/h");
/**
* Creates a cached tile stream provider for OpenStreetMap tiles
* Tiles are cached in the app's cache directory for offline access and faster loading
* Cache expires after 30 days to ensure map updates are fetched
* Derives a stable, filesystem-safe cache namespace for a tile provider from its URL.
* Query parameters are ignored: same path with a different apikey/lang/etc. maps to
* the same namespace. A different path (e.g. "outdoor" vs "basic") maps to a different one.
*/
fun createCachedTileStreamProvider(context: Context, cacheExpiryDays: Int = 30): TileStreamProvider {
val cacheDir = File(context.cacheDir, "map_tiles")
fun providerCacheNamespace(urlNoQuery: String): String {
val digest = MessageDigest.getInstance("SHA-256")
.digest(urlNoQuery.toByteArray(Charsets.UTF_8))
return digest.joinToString("") { "%02x".format(it) }.take(16)
}
/**
* Creates a cached tile stream provider for a given map tile URL template.
* Tiles are cached in the app's cache directory, namespaced per provider so different
* providers/layers never collide even if they share col/row/zoomLvl coordinates.
* Cache expires after 30 days to ensure map updates are fetched.
*
* @param urlBuilder builds the request URL for a given (row, col, zoomLvl). Everything
* after '?' (GET parameters) is ignored for cache namespacing purposes.
*/
fun createCachedTileStreamProvider(
context: Context,
cacheExpiryDays: Int = 30,
urlBuilder: (row: Int, col: Int, zoomLvl: Int) -> String
): TileStreamProvider {
// Namespace derived from the URL path (query params stripped), sampled once,
// so tiles from different providers/layers land in separate cache directories.
val sampleUrlNoQuery = urlBuilder(0, 0, 0).substringBefore('?')
val namespace = providerCacheNamespace(sampleUrlNoQuery)
val cacheDir = File(File(context.cacheDir, "map_tiles"), namespace)
if (!cacheDir.exists()) {
cacheDir.mkdirs()
}
@@ -95,7 +130,7 @@ fun createCachedTileStreamProvider(context: Context, cacheExpiryDays: Int = 30):
// Since mapcompose runs this on a background thread, we can do networking here.
try {
val url = URL("https://tile.openstreetmap.org/$zoomLvl/$col/$row.png")
val url = URL(urlBuilder(row, col, zoomLvl))
val connection = url.openConnection()
// Set User-Agent header as required by OSM tile usage policy
val userAgent = "${BuildConfig.APPLICATION_ID}/${BuildConfig.VERSION_NAME} (Android) (Contact: poliecho@pupes.org)"
@@ -115,7 +150,9 @@ fun createCachedTileStreamProvider(context: Context, cacheExpiryDays: Int = 30):
if (tileFile.exists()) {
FileInputStream(tileFile)
} else {
Log.e("TileStreamProvider", "Failed to download tile: ${e.message} from: ")
null
}
}
} catch (e: Exception) {
@@ -177,6 +214,8 @@ fun MainScreen(callee: Activity) {
var haukState by remember { mutableStateOf(false) }
var golemioState by remember { mutableStateOf(golemioAPIkey.isNotEmpty())}
var showMapProviderDialog by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
if (golemioState) {
startGTFSTracking(callee.getString(R.string.golemio_base_url), golemioAPIkey, callee)
@@ -219,6 +258,44 @@ fun MainScreen(callee: Activity) {
)
}
Box(modifier = Modifier.align(Alignment.BottomEnd).navigationBarsPadding()) {
IconButton( // change map provider
onClick = { showMapProviderDialog = true },
modifier = Modifier
.padding(end = 5.dp, bottom = 5.dp),
IconID = R.drawable.outline_layers_24
)
DropdownMenu(
expanded = showMapProviderDialog,
onDismissRequest = { showMapProviderDialog = false }
) {
DropdownMenuItem(
text = { Text("Map provider 1") },
onClick = { /* Do something... */ },
leadingIcon = {
Icon(
painter = painterResource(id = R.drawable.ic_launcher_foreground),
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = Color(0xFF000000)
)
},
)
DropdownMenuItem(
text = { Text("Add new provider") },
leadingIcon = {
Icon(
painter = painterResource(id = R.drawable.outline_add_24),
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = Color(0xFF000000)
)
},
onClick = { /* Do something... */ }
)
}
}
if (showSettings) {
ShowSettingsMenu(
onDismiss = { showSettings = false },
@@ -342,7 +419,7 @@ fun ShowSettingsMenu(
}
}
}
}}
}}
@Preview
@Composable
@@ -350,7 +427,9 @@ fun OpenStreetMapScreen(context: Context? = null) {
// Create tile stream provider for OpenStreetMap with caching
val tileStreamProvider = remember(context) {
context?.let {
createCachedTileStreamProvider(it)
createCachedTileStreamProvider(it) { row, col, zoomLvl ->
"https://api.mapy.com/v1/maptiles/outdoor/256/$zoomLvl/$col/$row?lang=cs&apikey=jv6a1-RP0jvLuMAL8jUUVw5Jaw1gAVC2jttMzdsOqyw"
}
} ?: TileStreamProvider { row, col, zoomLvl ->
// Fallback for preview mode without context
try {
@@ -0,0 +1,20 @@
<!--
~ Copyright (C) 2026 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M440,520L200,520L200,440L440,440L440,200L520,200L520,440L760,440L760,520L520,520L520,760L440,760L440,520Z"/>
</vector>
@@ -0,0 +1,20 @@
<!--
~ Copyright (C) 2026 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M480,842L120,562L186,512L480,740L774,512L840,562L480,842ZM480,640L120,360L480,80L840,360L480,640ZM480,360L480,360L480,360L480,360ZM480,538L710,360L480,182L250,360L480,538Z"/>
</vector>
+1
View File
@@ -2,6 +2,7 @@
<string name="app_name">MHD run Pathfinder</string>
<string name="routes_path">/v2/gtfs/routes</string>
<string name="stops_path">/v2/gtfs/stops</string>
<string name="combined_feed_path">/v2/vehiclepositions/gtfsrt/pid_feed.pb</string>"
<string name="golemio_base_url">https://api.golemio.cz</string>
<string name="golemio_realtime_path"></string>
</resources>
+2
View File
@@ -1,6 +1,7 @@
[versions]
agp = "9.0.0"
coreKtx = "1.10.1"
gtfsRealtimeBindings = "0.2.0"
junit = "4.13.2"
junitVersion = "1.1.5"
espressoCore = "3.5.1"
@@ -14,6 +15,7 @@ appcompat = "1.7.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
gtfs-realtime-bindings = { module = "org.mobilitydata:gtfs-realtime-bindings", version.ref = "gtfsRealtimeBindings" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }