Compare commits

..

No commits in common. "4263a7eff027ca32f4f20d6c3aa5241f4b22d1a3" and "ca2c5fe38506ee48cb85c3773badb34700548cf3" have entirely different histories.

5 changed files with 58 additions and 206 deletions

1
.gitignore vendored
View File

@ -13,4 +13,3 @@
.externalNativeBuild .externalNativeBuild
.cxx .cxx
local.properties local.properties
test-data

View File

@ -4,10 +4,10 @@
<selectionStates> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-01-28T21:00:38.307279245Z"> <DropdownSelection timestamp="2026-01-19T17:27:16.298528612Z">
<Target type="DEFAULT_BOOT"> <Target type="DEFAULT_BOOT">
<handle> <handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=BS98317AA1341400032" /> <DeviceId pluginId="LocalEmulator" identifier="path=/home/lukas/.config/.android/avd/Medium_Phone_OSS.avd" />
</handle> </handle>
</Target> </Target>
</DropdownSelection> </DropdownSelection>

View File

@ -14,36 +14,33 @@ 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.addMarker
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
data class GTFSRoute( data class GTFSRoute(
val agency_id: String?, val agency_id: String,
val is_night: Boolean, val is_night: Boolean,
val route_color: String?, val route_color: String,
val route_desc: String? = null, val route_desc: String? = null,
val route_id: String?, val route_id: String,
val route_long_name: String?, val route_long_name: String,
val route_short_name: String?, val route_short_name: String,
val route_text_color: String?, val route_text_color: String,
val route_type: Int, val route_type: Int,
val route_url: String?, val route_url: String,
val is_regional: Boolean, val is_regional: Boolean,
val is_substitute_transport: Boolean val is_substitute_transport: Boolean
) )
// Type alias for array of GTFS routes // Type alias for array of GTFS routes
typealias GTFSRoutes = MutableList<GTFSRoute> typealias GTFSRoutes = List<GTFSRoute>
var gtfsRoutes: GTFSRoutes = mutableListOf()
var gtfsRoutes: GTFSRoutes? = null
@Serializable @Serializable
data class GTFSStopGeometry( data class GTFSStopGeometry(
val coordinates: List<Double>, val coordinates: List<Double>,
val type: String? val type: String
) )
@Serializable @Serializable
@ -52,7 +49,7 @@ data class GTFSStopProperties(
val parent_station: String? = null, val parent_station: String? = null,
val platform_code: String? = null, val platform_code: String? = null,
val stop_id: String, val stop_id: String,
val stop_name: String?, val stop_name: String,
val wheelchair_boarding: Int, val wheelchair_boarding: Int,
val zone_id: String? = null, val zone_id: String? = null,
val level_id: String? = null val level_id: String? = null
@ -62,16 +59,16 @@ data class GTFSStopProperties(
data class GTFSStop( data class GTFSStop(
val geometry: GTFSStopGeometry, val geometry: GTFSStopGeometry,
val properties: GTFSStopProperties, val properties: GTFSStopProperties,
val type: String? val type: String
) )
@Serializable @Serializable
data class GTFSStops( data class GTFSStops(
val features: MutableList<GTFSStop>, val features: List<GTFSStop>,
val type: String? val type: String
) )
var gtfsStops: GTFSStops = GTFSStops(mutableListOf<GTFSStop>(), "FeatureCollection") var gtfsStops: GTFSStops? = null
// OkHttpClient for making HTTP requests // OkHttpClient for making HTTP requests
@ -107,24 +104,17 @@ private fun fetchGTFSPosition(gtfsUrl: URL,api_key: String, callee: Activity) {
} }
} }
// 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
GTFSIsTracking = true GTFSIsTracking = true
GTFSTrackingThread = Thread { GTFSTrackingThread = Thread {
val gtfsUrl = URL(url); var gtfsUrl = URL(url);
try { try {
if(gtfsRoutes.isEmpty()) { if(gtfsRoutes == null) {
GTFSMainHandler.post {
Toast.makeText(callee, "Fetching routes...", Toast.LENGTH_SHORT).show()}
var messageParsed: GTFSRoutes;
var offset = 0;
do {
val request = Request.Builder() val request = Request.Builder()
.url("${gtfsUrl.protocol}://${gtfsUrl.host}${callee.getString(R.string.routes_path)}?offset=${offset}") .url("${gtfsUrl.protocol}://${gtfsUrl.host}${callee.getString(R.string.routes_path)}")
.header( .header(
"User-Agent", "User-Agent",
"${BuildConfig.APPLICATION_ID}/${BuildConfig.VERSION_NAME} (Android)" "${BuildConfig.APPLICATION_ID}/${BuildConfig.VERSION_NAME} (Android)"
@ -137,20 +127,11 @@ fun startGTFSTracking(url: String, api_key: String, callee: Activity) {
} else { } else {
"Error: ${response.code} ${response.message}" "Error: ${response.code} ${response.message}"
} }
messageParsed = json.decodeFromString<GTFSRoutes>(message) gtfsRoutes = json.decodeFromString<GTFSRoutes>(message)
gtfsRoutes.addAll(messageParsed) }}
offset += messageParsed.size if(gtfsStops == null) {
}} while (messageParsed.isNotEmpty())
}
if(gtfsStops.features.isEmpty()) {
GTFSMainHandler.post {
Toast.makeText(callee, "Fetching stops...", Toast.LENGTH_SHORT).show()}
var messageParsed: GTFSStops;
var offset = 0;
do {
val request = Request.Builder() val request = Request.Builder()
.url("${gtfsUrl.protocol}://${gtfsUrl.host}${callee.getString(R.string.stops_path)}?offset=${offset}") .url("${gtfsUrl.protocol}://${gtfsUrl.host}${callee.getString(R.string.stops_path)}")
.header( .header(
"User-Agent", "User-Agent",
"${BuildConfig.APPLICATION_ID}/${BuildConfig.VERSION_NAME} (Android)" "${BuildConfig.APPLICATION_ID}/${BuildConfig.VERSION_NAME} (Android)"
@ -163,35 +144,25 @@ fun startGTFSTracking(url: String, api_key: String, callee: Activity) {
} else { } else {
"Error: ${response.code} ${response.message}" "Error: ${response.code} ${response.message}"
} }
messageParsed = json.decodeFromString<GTFSStops>(message) gtfsStops = json.decodeFromString<GTFSStops>(message)
gtfsStops.features.addAll(messageParsed.features)
}
offset += messageParsed.features.size;
}while(messageParsed.features.isNotEmpty())
}
// put all stops on the map // put all stops on the map
GTFSMainHandler.post {
Toast.makeText(callee, "Drawing stops...", Toast.LENGTH_SHORT).show()}
for (stop in gtfsStops!!.features) { for (stop in gtfsStops!!.features) {
GTFSMainHandler.post {
if (stop.properties.parent_station == null) { mapState.addMarker(stop.properties.stop_id, x = longitudeToXNormalized(stop.geometry.coordinates[0]), y = latitudeToYNormalized(stop.geometry.coordinates[0])) {
mapState.addMarker(stop.properties.stop_id, x = longitudeToXNormalized(stop.geometry.coordinates[0]), y = latitudeToYNormalized(stop.geometry.coordinates[1]), renderingStrategy = RenderingStrategy.LazyLoading("default")) {
Icon( Icon(
painter = painterResource(id = R.drawable.outline_directions_bus_24), painter = painterResource(id = R.drawable.outline_directions_bus_24),
contentDescription = null, contentDescription = null,
modifier = Modifier.size(12.dp), modifier = Modifier.size(17.dp),
tint = Color(0xFF0000FF) tint = Color(0xFF0000FF)
) )
}}
} }
GTFSMainHandler.post {
Toast.makeText(callee, "Done drawing stops", Toast.LENGTH_SHORT).show()}
} catch(e: Exception) {
GTFSMainHandler.post {
Toast.makeText(callee, e.message ?: "Unknown error", Toast.LENGTH_SHORT).show()
} }
}
}}
} catch(e: Exception) {
Toast.makeText(callee, e.message ?: "Unknown error", Toast.LENGTH_SHORT).show()
GTFSMainHandler.post { stopGTFSTracking() } GTFSMainHandler.post { stopGTFSTracking() }
return@Thread return@Thread
} }
@ -208,11 +179,6 @@ fun startGTFSTracking(url: String, api_key: String, callee: Activity) {
fun stopGTFSTracking() { fun stopGTFSTracking() {
GTFSIsTracking = false GTFSIsTracking = false
for (stop in gtfsStops!!.features) {
if (stop.properties.parent_station == null) {
mapState.removeMarker(stop.properties.stop_id);
}
}
GTFSTrackingThread?.interrupt() GTFSTrackingThread?.interrupt()
GTFSTrackingThread = null GTFSTrackingThread = null
} }

View File

@ -40,25 +40,14 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.edit import androidx.core.content.edit
import ovh.plrapps.mapcompose.api.addLayer import ovh.plrapps.mapcompose.api.addLayer
import ovh.plrapps.mapcompose.api.addLazyLoader
import ovh.plrapps.mapcompose.api.addMarker import ovh.plrapps.mapcompose.api.addMarker
import ovh.plrapps.mapcompose.api.scrollTo import ovh.plrapps.mapcompose.api.scrollTo
import ovh.plrapps.mapcompose.core.TileStreamProvider import ovh.plrapps.mapcompose.core.TileStreamProvider
import ovh.plrapps.mapcompose.ui.MapUI import ovh.plrapps.mapcompose.ui.MapUI
import ovh.plrapps.mapcompose.ui.state.MapState import ovh.plrapps.mapcompose.ui.state.MapState
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.net.URL import java.net.URL
import kotlinx.coroutines.*
import java.util.concurrent.Executors
import android.os.StrictMode
// Thread pool for tile downloading to prevent blocking UI thread
private val tileDownloadExecutor = Executors.newFixedThreadPool(
(Runtime.getRuntime().availableProcessors() - 3).coerceAtLeast(1)
)
// MapState configuration for OpenStreetMap // MapState configuration for OpenStreetMap
// Max zoom level is 18, tile size is 256x256 // Max zoom level is 18, tile size is 256x256
// At zoom level 18, there are 2^18 = 262144 tiles in each dimension // At zoom level 18, there are 2^18 = 262144 tiles in each dimension
@ -70,108 +59,13 @@ val mapState = MapState(
tileSize = 256 tileSize = 256
) )
/**
* 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
* Uses background thread pool to prevent ANR (Application Not Responding)
*/
fun createCachedTileStreamProvider(context: Context, cacheExpiryDays: Int = 30): TileStreamProvider {
val cacheDir = File(context.cacheDir, "map_tiles")
if (!cacheDir.exists()) {
cacheDir.mkdirs()
}
val cacheExpiryMillis = cacheExpiryDays * 24 * 60 * 60 * 1000L
return TileStreamProvider { row, col, zoomLvl ->
try {
// Create a unique filename for this tile
val tileFile = File(cacheDir, "tile_${zoomLvl}_${col}_${row}.png")
// Check if tile exists in cache and is not expired
val shouldUseCache = tileFile.exists() &&
(System.currentTimeMillis() - tileFile.lastModified()) < cacheExpiryMillis
if (shouldUseCache) {
// Use cached tile
FileInputStream(tileFile)
} else {
// Download tile from OpenStreetMap in background thread
val future = tileDownloadExecutor.submit<FileInputStream?> {
try {
val url = URL("https://tile.openstreetmap.org/$zoomLvl/$col/$row.png")
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)"
connection.setRequestProperty("User-Agent", userAgent)
connection.connectTimeout = 5000 // 5 second timeout
connection.readTimeout = 10000 // 10 second timeout
val inputStream = connection.getInputStream()
// Save to cache (overwrites if expired)
val outputStream = FileOutputStream(tileFile)
inputStream.use { input ->
outputStream.use { output ->
input.copyTo(output)
}
}
// Return the cached file's input stream
FileInputStream(tileFile)
} catch (e: Exception) {
e.printStackTrace()
null
}
}
// Wait for download with timeout
try {
future.get() ?: run {
// If download failed but we have a cached tile (even if expired), use it
if (tileFile.exists()) {
FileInputStream(tileFile)
} else {
null
}
}
} catch (e: Exception) {
// If download timed out or failed, try using expired cache
if (tileFile.exists()) {
FileInputStream(tileFile)
} else {
null
}
}
}
} catch (e: Exception) {
e.printStackTrace()
null
}
}
}
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// Configure StrictMode to detect threading violations (debug mode only)
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
.detectAll()
.penaltyLog() // Log violations instead of crashing
.build()
)
StrictMode.setVmPolicy(
StrictMode.VmPolicy.Builder()
.detectAll()
.penaltyLog()
.build()
)
}
setContent { setContent {
MaterialTheme { MaterialTheme {
@ -201,16 +95,15 @@ fun MainScreen(callee: Activity) {
var haukUrl by remember { mutableStateOf("") } var haukUrl by remember { mutableStateOf("") }
var golemioAPIkey by remember { mutableStateOf(sharedPreferences.getString("golemioAPIkey", "")?: "" ) } var golemioAPIkey by remember { mutableStateOf(sharedPreferences.getString("golemioAPIkey", "")?: "" ) }
var haukState by remember { mutableStateOf(false) } var haukState by remember { mutableStateOf(false) }
var golemioState by remember { mutableStateOf(golemioAPIkey.isNotEmpty())} var golemioState by remember { mutableStateOf(false)}
LaunchedEffect(Unit) { if (golemioAPIkey.isNotEmpty()) {
if (golemioState) { golemioState = true
startGTFSTracking(callee.getString(R.string.golemio_base_url), golemioAPIkey, callee) startGTFSTracking(callee.getString(R.string.golemio_base_url), golemioAPIkey, callee);
}
} }
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
OpenStreetMapScreen(callee) OpenStreetMapScreen()
SettingsButton( SettingsButton(
onClick = { showSettings = true }, onClick = { showSettings = true },
modifier = Modifier modifier = Modifier
@ -318,7 +211,6 @@ fun ShowSettingsMenu(
value = haukUrl, value = haukUrl,
onValueChange = onHaukUrlChange, onValueChange = onHaukUrlChange,
label = { Text("Target Hauk URL") }, label = { Text("Target Hauk URL") },
singleLine = true,
modifier = Modifier modifier = Modifier
.width(250.dp) .width(250.dp)
) )
@ -332,7 +224,6 @@ fun ShowSettingsMenu(
onCheckedChange = { onGolemioStateChange(it) } onCheckedChange = { onGolemioStateChange(it) }
) )
TextField(value = golemioAPIkey, onValueChange = onGolemioAPIChange, label = { Text("Golemio API Key") }, TextField(value = golemioAPIkey, onValueChange = onGolemioAPIChange, label = { Text("Golemio API Key") },
singleLine = true,
modifier = Modifier modifier = Modifier
.width(250.dp)); .width(250.dp));
ElevatedButton(onClick = onGolemioApply, modifier = Modifier.padding(start = 5.dp), shape = RectangleShape) { ElevatedButton(onClick = onGolemioApply, modifier = Modifier.padding(start = 5.dp), shape = RectangleShape) {
@ -344,17 +235,15 @@ fun ShowSettingsMenu(
@Preview @Preview
@Composable @Composable
fun OpenStreetMapScreen(context: Context? = null) { fun OpenStreetMapScreen() {
// Create tile stream provider for OpenStreetMap with caching // Create tile stream provider for OpenStreetMap with User-Agent
val tileStreamProvider = remember(context) { val tileStreamProvider = TileStreamProvider { row, col, zoomLvl ->
context?.let {
createCachedTileStreamProvider(it)
} ?: TileStreamProvider { row, col, zoomLvl ->
// Fallback for preview mode without context
try { try {
val url = URL("https://tile.openstreetmap.org/$zoomLvl/$col/$row.png") val url = URL("https://tile.openstreetmap.org/$zoomLvl/$col/$row.png")
val connection = url.openConnection() val connection = url.openConnection()
val userAgent = "MHDrunPathfinder/Preview (Android)" // Set User-Agent header as required by OSM tile usage policy
// Format: AppId/Version (Platform) (Contact: email)
val userAgent = "${BuildConfig.APPLICATION_ID}/${BuildConfig.VERSION_NAME} (Android) (Contact: poliecho@pupes.org)"
connection.setRequestProperty("User-Agent", userAgent) connection.setRequestProperty("User-Agent", userAgent)
connection.getInputStream() connection.getInputStream()
} catch (e: Exception) { } catch (e: Exception) {
@ -362,9 +251,8 @@ fun OpenStreetMapScreen(context: Context? = null) {
null null
} }
} }
}
mapState.addLazyLoader("default");
/* Add a marker at the center of the map */ /* Add a marker at the center of the map */
mapState.addMarker("target_position", x = longitudeToXNormalized(14.4058031), y = latitudeToYNormalized(50.0756083)) { mapState.addMarker("target_position", x = longitudeToXNormalized(14.4058031), y = latitudeToYNormalized(50.0756083)) {

View File

@ -3,5 +3,4 @@
<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="golemio_base_url">https://api.golemio.cz</string> <string name="golemio_base_url">https://api.golemio.cz</string>
<string name="golemio_realtime_path"></string>
</resources> </resources>