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> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <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"> <Target type="DEFAULT_BOOT">
<handle> <handle>
<DeviceId pluginId="LocalEmulator" identifier="path=/home/lukas/.config/.android/avd/Medium_Phone_OSS.avd" /> <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) androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest) 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.Handler
import android.os.Looper import android.os.Looper
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.layout.size import com.google.transit.realtime.GtfsRealtime
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 kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import ovh.plrapps.mapcompose.api.addMarker
import ovh.plrapps.mapcompose.api.removeMarker import ovh.plrapps.mapcompose.api.removeMarker
import ovh.plrapps.mapcompose.ui.state.markers.model.RenderingStrategy
import java.net.URL import java.net.URL
@Serializable @Serializable
@@ -82,32 +75,36 @@ private val GTFSMainHandler = Handler(Looper.getMainLooper())
private var GTFSTrackingThread: Thread? = null private var GTFSTrackingThread: Thread? = null
private var GTFSIsTracking = false 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 { try {
val request = Request.Builder() 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("User-Agent", "${BuildConfig.APPLICATION_ID}/${BuildConfig.VERSION_NAME} (Android)")
.header("X-Access-Token", api_key)
.build() .build()
gtfshttpClient.newCall(request).execute().use { response -> gtfshttpClient.newCall(request).execute().use { response ->
var message = if (response.isSuccessful) { if (!response.isSuccessful) {
response.body?.string() ?: "Empty response" throw Exception("Error: ${response.code} ${response.message}")
} else {
"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) { } catch (e: Exception) {
// Post UI update to main thread
GTFSMainHandler.post { GTFSMainHandler.post {
Toast.makeText(callee, e.message ?: "Unknown error", Toast.LENGTH_SHORT).show() Toast.makeText(callee, e.message ?: "Unknown error", Toast.LENGTH_SHORT).show()
} }
} }
} }
// Start the timer with a URL: // Start the timer with a URL:
fun startGTFSTracking(url: String, api_key: String, callee: Activity) { fun startGTFSTracking(url: String, api_key: String, callee: Activity) {
if (GTFSIsTracking) return if (GTFSIsTracking) return
@@ -197,7 +194,7 @@ fun startGTFSTracking(url: String, api_key: String, callee: Activity) {
return@Thread return@Thread
} }
while (GTFSIsTracking) { while (GTFSIsTracking) {
fetchGTFSPosition(gtfsUrl,api_key, callee) fetchGTFSPositions(gtfsUrl,api_key, callee)
try { try {
Thread.sleep(1000) // Wait 1 second between requests Thread.sleep(1000) // Wait 1 second between requests
} catch (e: InterruptedException) { } catch (e: InterruptedException) {
@@ -4,10 +4,15 @@ import android.app.Activity
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.widget.Toast import android.widget.Toast
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import ovh.plrapps.mapcompose.api.addPath
import ovh.plrapps.mapcompose.api.moveMarker import ovh.plrapps.mapcompose.api.moveMarker
import ovh.plrapps.mapcompose.api.removePath
import ovh.plrapps.mapcompose.api.updatePath
import java.net.URL import java.net.URL
@Serializable @Serializable
@@ -28,8 +33,9 @@ val haukhttpClient = OkHttpClient.Builder()
private val HaukMainHandler = Handler(Looper.getMainLooper()) private val HaukMainHandler = Handler(Looper.getMainLooper())
private var HaukTrackingThread: Thread? = null private var HaukTrackingThread: Thread? = null
private var HaukIsTracking = false var HaukIsTracking = false
private var PathCreated = false
private fun fetchHaukPosition(haukUrl: String, callee: Activity) { private fun fetchHaukPosition(haukUrl: String, callee: Activity) {
try { try {
val url = URL(haukUrl) val url = URL(haukUrl)
@@ -76,6 +82,18 @@ private fun fetchHaukPosition(haukUrl: String, callee: Activity) {
} catch (e: Exception) { } catch (e: Exception) {
// Marker might not exist yet, ignore // 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 { } else {
HaukMainHandler.post { HaukMainHandler.post {
@@ -95,6 +113,10 @@ fun startHaukTracking(url: String, callee: Activity) {
if (HaukIsTracking) return if (HaukIsTracking) return
HaukIsTracking = true HaukIsTracking = true
HaukTrackingThread = Thread { HaukTrackingThread = Thread {
while (HaukIsTracking) { while (HaukIsTracking) {
fetchHaukPosition(url, callee) fetchHaukPosition(url, callee)
@@ -111,4 +133,6 @@ fun stopHaukTracking() {
HaukIsTracking = false HaukIsTracking = false
HaukTrackingThread?.interrupt() HaukTrackingThread?.interrupt()
HaukTrackingThread = null 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.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -52,9 +51,18 @@ import java.io.FileInputStream
import java.io.FileOutputStream import java.io.FileOutputStream
import java.net.URL import java.net.URL
import android.os.StrictMode import android.os.StrictMode
import android.widget.Toast
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import ovh.plrapps.mapcompose.api.getMarkerInfo 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 // MapState configuration for OpenStreetMap
@@ -67,15 +75,42 @@ val mapState = MapState(
fullHeight = 67108864, fullHeight = 67108864,
tileSize = 256 tileSize = 256
) )
val pathDataBuilder = mapState.makePathDataBuilder();
var target_speed by mutableStateOf("0.0km/h"); var target_speed by mutableStateOf("0.0km/h");
/** /**
* Creates a cached tile stream provider for OpenStreetMap tiles * Derives a stable, filesystem-safe cache namespace for a tile provider from its URL.
* Tiles are cached in the app's cache directory for offline access and faster loading * Query parameters are ignored: same path with a different apikey/lang/etc. maps to
* Cache expires after 30 days to ensure map updates are fetched * the same namespace. A different path (e.g. "outdoor" vs "basic") maps to a different one.
*/ */
fun createCachedTileStreamProvider(context: Context, cacheExpiryDays: Int = 30): TileStreamProvider { fun providerCacheNamespace(urlNoQuery: String): String {
val cacheDir = File(context.cacheDir, "map_tiles") 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()) { if (!cacheDir.exists()) {
cacheDir.mkdirs() 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. // Since mapcompose runs this on a background thread, we can do networking here.
try { try {
val url = URL("https://tile.openstreetmap.org/$zoomLvl/$col/$row.png") val url = URL(urlBuilder(row, col, zoomLvl))
val connection = url.openConnection() val connection = url.openConnection()
// Set User-Agent header as required by OSM tile usage policy // Set User-Agent header as required by OSM tile usage policy
val userAgent = "${BuildConfig.APPLICATION_ID}/${BuildConfig.VERSION_NAME} (Android) (Contact: poliecho@pupes.org)" 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()) { if (tileFile.exists()) {
FileInputStream(tileFile) FileInputStream(tileFile)
} else { } else {
Log.e("TileStreamProvider", "Failed to download tile: ${e.message} from: ")
null null
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
@@ -177,6 +214,8 @@ fun MainScreen(callee: Activity) {
var haukState by remember { mutableStateOf(false) } var haukState by remember { mutableStateOf(false) }
var golemioState by remember { mutableStateOf(golemioAPIkey.isNotEmpty())} var golemioState by remember { mutableStateOf(golemioAPIkey.isNotEmpty())}
var showMapProviderDialog by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (golemioState) { if (golemioState) {
startGTFSTracking(callee.getString(R.string.golemio_base_url), golemioAPIkey, callee) 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) { if (showSettings) {
ShowSettingsMenu( ShowSettingsMenu(
onDismiss = { showSettings = false }, onDismiss = { showSettings = false },
@@ -350,7 +427,9 @@ fun OpenStreetMapScreen(context: Context? = null) {
// Create tile stream provider for OpenStreetMap with caching // Create tile stream provider for OpenStreetMap with caching
val tileStreamProvider = remember(context) { val tileStreamProvider = remember(context) {
context?.let { 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 -> } ?: TileStreamProvider { row, col, zoomLvl ->
// Fallback for preview mode without context // Fallback for preview mode without context
try { 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="app_name">MHD run Pathfinder</string>
<string name="routes_path">/v2/gtfs/routes</string> <string name="routes_path">/v2/gtfs/routes</string>
<string name="stops_path">/v2/gtfs/stops</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_base_url">https://api.golemio.cz</string>
<string name="golemio_realtime_path"></string> <string name="golemio_realtime_path"></string>
</resources> </resources>
+2
View File
@@ -1,6 +1,7 @@
[versions] [versions]
agp = "9.0.0" agp = "9.0.0"
coreKtx = "1.10.1" coreKtx = "1.10.1"
gtfsRealtimeBindings = "0.2.0"
junit = "4.13.2" junit = "4.13.2"
junitVersion = "1.1.5" junitVersion = "1.1.5"
espressoCore = "3.5.1" espressoCore = "3.5.1"
@@ -14,6 +15,7 @@ appcompat = "1.7.1"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } 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" } junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }