Compare commits

..

No commits in common. "dbd29bf42138765e2077f4b8feab8b2196ab1f56" and "67fea519499972b5d0bd1ff1c2ea363798877342" have entirely different histories.

5 changed files with 131 additions and 204 deletions

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-02-13T13:44:07.200825088Z"> <DropdownSelection timestamp="2026-01-28T21:00:38.307279245Z">
<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="PhysicalDevice" identifier="serial=BS98317AA1341400032" />
</handle> </handle>
</Target> </Target>
</DropdownSelection> </DropdownSelection>

View File

@ -116,39 +116,39 @@ fun startGTFSTracking(url: String, api_key: String, callee: Activity) {
GTFSTrackingThread = Thread { GTFSTrackingThread = Thread {
val gtfsUrl = URL(url); val gtfsUrl = URL(url);
try { try {
if(gtfsRoutes.isEmpty()) { if(gtfsRoutes.isEmpty()) {
GTFSMainHandler.post { GTFSMainHandler.post {
Toast.makeText(callee, "Fetching routes...", Toast.LENGTH_SHORT).show()} Toast.makeText(callee, "Fetching routes...", Toast.LENGTH_SHORT).show()}
var messageParsed: GTFSRoutes; var messageParsed: GTFSRoutes;
var offset = 0; var offset = 0;
do { 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)}?offset=${offset}")
.header( .header(
"User-Agent", "User-Agent",
"${BuildConfig.APPLICATION_ID}/${BuildConfig.VERSION_NAME} (Android)" "${BuildConfig.APPLICATION_ID}/${BuildConfig.VERSION_NAME} (Android)"
) )
.header("X-Access-Token", api_key).build(); .header("X-Access-Token", api_key).build();
gtfshttpClient.newCall(request).execute().use { response -> gtfshttpClient.newCall(request).execute().use { response ->
var message = if (response.isSuccessful) { var message = if (response.isSuccessful) {
response.body?.string() ?: "Empty response" response.body?.string() ?: "Empty response"
} else { } else {
"Error: ${response.code} ${response.message}" "Error: ${response.code} ${response.message}"
} }
messageParsed = json.decodeFromString<GTFSRoutes>(message) messageParsed = json.decodeFromString<GTFSRoutes>(message)
gtfsRoutes.addAll(messageParsed) gtfsRoutes.addAll(messageParsed)
offset += messageParsed.size offset += messageParsed.size
}} while (messageParsed.isNotEmpty()) }} while (messageParsed.isNotEmpty())
} }
if(gtfsStops.features.isEmpty()) { if(gtfsStops.features.isEmpty()) {
GTFSMainHandler.post { GTFSMainHandler.post {
Toast.makeText(callee, "Fetching stops...", Toast.LENGTH_SHORT).show()} Toast.makeText(callee, "Fetching stops...", Toast.LENGTH_SHORT).show()}
var messageParsed: GTFSStops; var messageParsed: GTFSStops;
var offset = 0; var offset = 0;
do { 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)}?offset=${offset}")
.header( .header(
@ -168,8 +168,8 @@ fun startGTFSTracking(url: String, api_key: String, callee: Activity) {
} }
offset += messageParsed.features.size; offset += messageParsed.features.size;
}while(messageParsed.features.isNotEmpty()) }while(messageParsed.features.isNotEmpty())
} }
// put all stops on the map // put all stops on the map
GTFSMainHandler.post { GTFSMainHandler.post {
Toast.makeText(callee, "Drawing stops...", Toast.LENGTH_SHORT).show()} Toast.makeText(callee, "Drawing stops...", Toast.LENGTH_SHORT).show()}
@ -188,12 +188,12 @@ fun startGTFSTracking(url: String, api_key: String, callee: Activity) {
GTFSMainHandler.post { GTFSMainHandler.post {
Toast.makeText(callee, "Done drawing ${gtfsStops.features.size} stops", Toast.LENGTH_SHORT).show()} Toast.makeText(callee, "Done drawing ${gtfsStops.features.size} stops", Toast.LENGTH_SHORT).show()}
} catch(e: Exception) { } catch(e: Exception) {
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()
} }
GTFSMainHandler.post { stopGTFSTracking() } GTFSMainHandler.post { stopGTFSTracking() }
return@Thread return@Thread
} }
while (GTFSIsTracking) { while (GTFSIsTracking) {
fetchGTFSPosition(gtfsUrl,api_key, callee) fetchGTFSPosition(gtfsUrl,api_key, callee)

View File

@ -49,38 +49,15 @@ private fun fetchHaukPosition(haukUrl: String, callee: Activity) {
message = "{" + message.substringAfter("{"); message = "{" + message.substringAfter("{");
val hauk_response = json.decodeFromString<HaukResponse>(message) val hauk_response = json.decodeFromString<HaukResponse>(message)
val lat = hauk_response.points.last().getOrNull(0)
val lon = hauk_response.points.last().getOrNull(1)
if (lat != null && lon != null) {
val lastlat = hauk_response.points.getOrNull(hauk_response.points.size - 2)?.getOrNull(0) val lat = hauk_response.points.last()?.getOrNull(0)
val lastlon = hauk_response.points.getOrNull(hauk_response.points.size - 2)?.getOrNull(1) val lon = hauk_response.points.last()?.getOrNull(1)
val newspeed = if (lat != null && lon != null) {
if (lastlat != null && lastlon != null) { HaukMainHandler.post {
var fulldistance: Double = 0.0 mapState.moveMarker("target_position", x = longitudeToXNormalized(lon), y = latitudeToYNormalized(lat))
for (i in 1 until hauk_response.points.size -1) { }
try{
fulldistance += haversineDistance(hauk_response.points[i-1][0]!!, hauk_response.points[i-1][1]!!, hauk_response.points[i][0]!!, hauk_response.points[i][1]!!)
} catch (e: Exception) {
// Ignore
}
}
(fulldistance / (hauk_response.interval * hauk_response.points.size)*3600)
} else {
0.0
}
HaukMainHandler.post {
try {
mapState.moveMarker("target_position", x = longitudeToXNormalized(lon), y = latitudeToYNormalized(lat))
target_speed = "${"%.1f".format(newspeed)}km/h"
} catch (e: Exception) {
// Marker might not exist yet, ignore
}
}
} else { } else {
HaukMainHandler.post { Toast.makeText(callee, "TARGET LOCATION NOT AVAILABLE", Toast.LENGTH_SHORT).show()
Toast.makeText(callee, "TARGET LOCATION NOT AVAILABLE", Toast.LENGTH_SHORT).show()
}
}} }}
} catch (e: Exception) { } catch (e: Exception) {
// Post UI update to main thread // Post UI update to main thread

View File

@ -4,27 +4,6 @@ import kotlinx.serialization.json.Json
import kotlin.math.PI import kotlin.math.PI
import kotlin.math.ln import kotlin.math.ln
import kotlin.math.tan import kotlin.math.tan
import kotlin.math.*
fun haversineDistance(
lat1: Double,
lon1: Double,
lat2: Double,
lon2: Double
): Double {
val R = 6371.0 // Earth radius in kilometers
val phi1 = lat1 * PI / 180
val phi2 = lat2 * PI / 180
val deltaPhi = (lat2 - lat1) * PI / 180
val deltaLambda = (lon2 - lon1) * PI / 180
val a = sin(deltaPhi / 2).pow(2) +
cos(phi1) * cos(phi2) * sin(deltaLambda / 2).pow(2)
val c = 2 * atan2(sqrt(a), sqrt(1 - a))
return R * c
}
val json = Json { val json = Json {
coerceInputValues = true // Coerce nulls to default values if applicable coerceInputValues = true // Coerce nulls to default values if applicable

View File

@ -2,6 +2,7 @@ package org.pupes.mhdrunpathfinder
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
@ -29,10 +30,8 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
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
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
@ -51,12 +50,15 @@ import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.io.FileOutputStream import java.io.FileOutputStream
import java.net.URL import java.net.URL
import kotlinx.coroutines.*
import java.util.concurrent.Executors
import android.os.StrictMode import android.os.StrictMode
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.sp
import ovh.plrapps.mapcompose.api.getMarkerInfo
// 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
@ -67,12 +69,12 @@ val mapState = MapState(
fullHeight = 67108864, fullHeight = 67108864,
tileSize = 256 tileSize = 256
) )
var target_speed by mutableStateOf("0.0km/h");
/** /**
* Creates a cached tile stream provider for OpenStreetMap tiles * Creates a cached tile stream provider for OpenStreetMap tiles
* Tiles are cached in the app's cache directory for offline access and faster loading * 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 * 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 { fun createCachedTileStreamProvider(context: Context, cacheExpiryDays: Int = 30): TileStreamProvider {
val cacheDir = File(context.cacheDir, "map_tiles") val cacheDir = File(context.cacheDir, "map_tiles")
@ -84,38 +86,63 @@ fun createCachedTileStreamProvider(context: Context, cacheExpiryDays: Int = 30):
return TileStreamProvider { row, col, zoomLvl -> return TileStreamProvider { row, col, zoomLvl ->
try { try {
// Create a unique filename for this tile
val tileFile = File(cacheDir, "tile_${zoomLvl}_${col}_${row}.png") val tileFile = File(cacheDir, "tile_${zoomLvl}_${col}_${row}.png")
// Check if tile exists in cache and is not expired
val shouldUseCache = tileFile.exists() && val shouldUseCache = tileFile.exists() &&
(System.currentTimeMillis() - tileFile.lastModified()) < cacheExpiryMillis (System.currentTimeMillis() - tileFile.lastModified()) < cacheExpiryMillis
if (shouldUseCache) { if (shouldUseCache) {
return@TileStreamProvider FileInputStream(tileFile) // 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
// Since mapcompose runs this on a background thread, we can do networking here. val inputStream = connection.getInputStream()
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
connection.readTimeout = 10000
connection.getInputStream().use { inputStream -> // Save to cache (overwrites if expired)
FileOutputStream(tileFile).use { outputStream -> val outputStream = FileOutputStream(tileFile)
inputStream.copyTo(outputStream) inputStream.use { input ->
outputStream.use { output ->
input.copyTo(output)
}
}
// Return the cached file's input stream
FileInputStream(tileFile)
} catch (e: Exception) {
e.printStackTrace()
null
} }
} }
FileInputStream(tileFile)
} catch (e: Exception) { // Wait for download with timeout
e.printStackTrace() try {
// If download fails, try to use an expired cached tile if it exists future.get() ?: run {
if (tileFile.exists()) { // If download failed but we have a cached tile (even if expired), use it
FileInputStream(tileFile) if (tileFile.exists()) {
} else { FileInputStream(tileFile)
null } 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) { } catch (e: Exception) {
@ -170,7 +197,6 @@ class MainActivity : ComponentActivity() {
@Composable @Composable
fun MainScreen(callee: Activity) { fun MainScreen(callee: Activity) {
val sharedPreferences = remember { callee.getSharedPreferences("PREFERENCES", Context.MODE_PRIVATE) } val sharedPreferences = remember { callee.getSharedPreferences("PREFERENCES", Context.MODE_PRIVATE) }
val coroutineScope = rememberCoroutineScope()
var showSettings by remember { mutableStateOf(false) } var showSettings by remember { mutableStateOf(false) }
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", "")?: "" ) }
@ -185,40 +211,12 @@ fun MainScreen(callee: Activity) {
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
OpenStreetMapScreen(callee) OpenStreetMapScreen(callee)
IconButton( // settings button SettingsButton(
onClick = { showSettings = true }, onClick = { showSettings = true },
modifier = Modifier modifier = Modifier
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
.padding(end = 5.dp, bottom = 5.dp), .padding(end = 5.dp, bottom = 5.dp)
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)
}
}
},
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 ->
mapState.scrollTo(marker.x, marker.y, destScale = 0.1)
}
}
},
modifier = Modifier
.padding(start = 5.dp, bottom = 5.dp),
IconID = R.drawable.user_location
)
}
if (showSettings) { if (showSettings) {
ShowSettingsMenu( ShowSettingsMenu(
onDismiss = { showSettings = false }, onDismiss = { showSettings = false },
@ -277,7 +275,7 @@ fun MainScreen(callee: Activity) {
@Composable @Composable
// When a composable with parameters is used with @Preview, // When a composable with parameters is used with @Preview,
// a default value must be provided for the preview to render. // a default value must be provided for the preview to render.
fun IconButton(modifier: Modifier = Modifier, onClick: () -> Unit = {}, IconID: Int = R.drawable.baseline_settings_24) { fun SettingsButton(modifier: Modifier = Modifier, onClick: () -> Unit = {}) {
Button( Button(
onClick = { onClick() }, onClick = { onClick() },
colors = ButtonDefaults.buttonColors(containerColor = Color(0xCFFFFFFF)), colors = ButtonDefaults.buttonColors(containerColor = Color(0xCFFFFFFF)),
@ -285,7 +283,7 @@ fun IconButton(modifier: Modifier = Modifier, onClick: () -> Unit = {}, IconID:
) { ) {
Icon( Icon(
painter = painterResource(id = IconID), painter = painterResource(id = R.drawable.baseline_settings_24),
contentDescription = null, contentDescription = null,
modifier = Modifier.size(24.dp), modifier = Modifier.size(24.dp),
tint = Color(0xFF000000) tint = Color(0xFF000000)
@ -366,66 +364,39 @@ fun OpenStreetMapScreen(context: Context? = null) {
} }
} }
// Track if map has been initialized to prevent duplicate operations mapState.addLazyLoader("default");
var mapInitialized by remember { mutableStateOf(false) }
// Add the tile layer, markers, and set initial position - all in LaunchedEffect to avoid blocking main thread /* Add a marker at the center of the map */
mapState.addMarker("target_position", x = longitudeToXNormalized(14.4058031), y = latitudeToYNormalized(50.0756083)) {
Icon(
painter = painterResource(id = R.drawable.target),
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = Color(0xFFFF0000)
)
}
/* Add a marker for current position */
mapState.addMarker("current_position", x = longitudeToXNormalized(14.4378), y = latitudeToYNormalized(50.0755)) {
Icon(
painter = painterResource(id = R.drawable.user_location),
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = Color(0xFF0000FF) // Blue color for current position
)
}
// Add the tile layer and set initial position
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (!mapInitialized) { mapState.addLayer(tileStreamProvider)
// Add lazy loader first
mapState.addLazyLoader("default")
// Add tile layer // Scroll to Prague, Czech Republic
mapState.addLayer(tileStreamProvider) // Prague coordinates: latitude 50.0755, longitude 14.4378
val normalizedX = longitudeToXNormalized(14.4378)
val normalizedY = latitudeToYNormalized(50.0755)
// Add a marker for target position // Use a higher scale to zoom in more (closer to 1.0 = more zoomed in)
mapState.addMarker("target_position", x = longitudeToXNormalized(14.0), y = latitudeToYNormalized(50.0), Offset(-0.5f,-0.25f)) { mapState.scrollTo(normalizedX, normalizedY, destScale = 0.1)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
painter = painterResource(id = R.drawable.target),
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = Color(0xFFFF0000)
)
Surface(
color = Color.Red,
shadowElevation = 4.dp,
shape = MaterialTheme.shapes.small,
modifier = Modifier.padding(1.dp)
) {
Text(
text = target_speed,
modifier = Modifier.padding(1.dp),
style = MaterialTheme.typography.bodyMedium,
fontSize = 8.sp,
color = Color.Blue
)
}
}
}
/* Add a marker for current position */
mapState.addMarker("current_position", x = longitudeToXNormalized(14.0), y = latitudeToYNormalized(50.0),
Offset(-0.5f,-0.5f)
) {
Icon(
painter = painterResource(id = R.drawable.user_location),
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = Color(0xFF004802)
)
}
// Scroll to Prague, Czech Republic
// Prague coordinates: latitude 50.0755, longitude 14.4378
val normalizedX = longitudeToXNormalized(14.4378)
val normalizedY = latitudeToYNormalized(50.0755)
// Use a higher scale to zoom in more (closer to 1.0 = more zoomed in)
mapState.scrollTo(normalizedX, normalizedY, destScale = 0.1)
mapInitialized = true
}
} }
// Display the map // Display the map