map improvements and GTFS vehicle tracking bases
This commit is contained in:
Generated
+1
-1
@@ -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" />
|
||||
|
||||
@@ -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)
|
||||
@@ -193,19 +232,19 @@ fun MainScreen(callee: Activity) {
|
||||
IconID = R.drawable.baseline_settings_24
|
||||
)
|
||||
Column(modifier = Modifier.align(Alignment.TopStart)) {
|
||||
IconButton( // look at target button
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
mapState.getMarkerInfo("target_position")?.let { marker ->
|
||||
mapState.scrollTo(marker.x, marker.y, destScale = 0.1)
|
||||
IconButton( // look at target button
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
mapState.getMarkerInfo("target_position")?.let { marker ->
|
||||
mapState.scrollTo(marker.x, marker.y, destScale = 0.1)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.padding(start = 5.dp, bottom = 5.dp),
|
||||
IconID = R.drawable.target
|
||||
)
|
||||
IconButton( // look at my location button
|
||||
},
|
||||
modifier = Modifier
|
||||
.padding(start = 5.dp, bottom = 5.dp),
|
||||
IconID = R.drawable.target
|
||||
)
|
||||
IconButton( // look at my location button
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
mapState.getMarkerInfo("current_position")?.let { marker ->
|
||||
@@ -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 },
|
||||
@@ -339,10 +416,10 @@ fun ShowSettingsMenu(
|
||||
.width(250.dp));
|
||||
ElevatedButton(onClick = onGolemioApply, modifier = Modifier.padding(start = 5.dp), shape = RectangleShape) {
|
||||
Text("Apply")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
}}
|
||||
|
||||
@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>
|
||||
@@ -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>
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user