Compare commits

...

3 Commits

Author SHA1 Message Date
Thorsten 5019c83b80 Wegpunkt-Pfeile mit Richtung und Info-Fenster; GPX-Schreib-Performance fix
- TrackDetailActivity: Zeigt zoom-adaptive Richtungspfeile (Bearing) als Marker auf der Karte; Klick öffnet Info-Fenster mit Geschwindigkeit und Kurs
- GPXManager: appendLocation schreibt nur noch per appendText statt Datei komplett neu zu schreiben (O(1) statt O(n))
- TrackingService: finalizeSession() beim Stoppen aufrufen, um GPX-Footer korrekt abzuschließen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 11:52:27 +02:00
Thorsten 8de7b4d886 jetzt mit Google Drive ZIP Backup 2026-03-06 11:27:44 +01:00
Thorsten a455be8db7 Geschwindigkeit und Anzeigen korrigiert 2026-03-06 10:05:16 +01:00
6 changed files with 242 additions and 80 deletions
+8
View File
@@ -15,6 +15,14 @@
</SelectionState>
<SelectionState runConfigName="gpstracker">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-03-06T08:51:20.686825100Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=28161JEEJR069K" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
</selectionStates>
</component>
@@ -20,6 +20,10 @@ import com.google.gson.Gson
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.BufferedOutputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
class ActivitiesActivity : AppCompatActivity() {
@@ -35,14 +39,16 @@ class ActivitiesActivity : AppCompatActivity() {
uri?.let { importGpxFile(it) }
}
private val backupFolderLauncher = registerForActivityResult(
ActivityResultContracts.OpenDocumentTree()
// Backup: Wir erstellen eine ZIP-Datei
private val createBackupLauncher = registerForActivityResult(
ActivityResultContracts.CreateDocument("application/zip")
) { uri ->
uri?.let { performDriveBackup(it) }
}
private val restoreFolderLauncher = registerForActivityResult(
ActivityResultContracts.OpenDocumentTree()
// Restore: Wir wählen eine ZIP-Datei aus
private val restoreBackupLauncher = registerForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri ->
uri?.let { performDriveRestore(it) }
}
@@ -77,11 +83,12 @@ class ActivitiesActivity : AppCompatActivity() {
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_drive_backup -> {
backupFolderLauncher.launch(googleDriveUri())
val date = java.text.SimpleDateFormat("yyyy-MM-dd", java.util.Locale.getDefault()).format(java.util.Date())
createBackupLauncher.launch("gpstracker_backup_$date.zip")
true
}
R.id.action_drive_restore -> {
restoreFolderLauncher.launch(googleDriveUri())
restoreBackupLauncher.launch(arrayOf("application/zip", "application/x-zip-compressed"))
true
}
R.id.action_import_gpx -> {
@@ -92,10 +99,7 @@ class ActivitiesActivity : AppCompatActivity() {
}
}
private fun googleDriveUri(): Uri =
Uri.parse("content://com.google.android.apps.docs.storage/")
private fun performDriveBackup(folderUri: Uri) {
private fun performDriveBackup(fileUri: Uri) {
val tracks = trackStorage.getAllTracks()
if (tracks.isEmpty()) {
Toast.makeText(this, "Keine Tracks zum Sichern", Toast.LENGTH_SHORT).show()
@@ -104,39 +108,28 @@ class ActivitiesActivity : AppCompatActivity() {
lifecycleScope.launch(Dispatchers.IO) {
try {
val folder = DocumentFile.fromTreeUri(this@ActivitiesActivity, folderUri)
if (folder == null || !folder.canWrite()) {
withContext(Dispatchers.Main) {
Toast.makeText(this@ActivitiesActivity, "Ordner nicht beschreibbar", Toast.LENGTH_LONG).show()
}
return@launch
}
contentResolver.openOutputStream(fileUri)?.use { os ->
ZipOutputStream(BufferedOutputStream(os)).use { zos ->
tracks.forEach { track ->
val safeName = trackStorage.sanitizeFileName(track.name)
// JSON Datei in ZIP
val jsonEntry = ZipEntry("$safeName.json")
zos.putNextEntry(jsonEntry)
zos.write(gson.toJson(track).toByteArray(Charsets.UTF_8))
zos.closeEntry()
var count = 0
tracks.forEach { track ->
val safeName = track.name.replace(Regex("[^a-zA-Z0-9._-]"), "_")
val jsonName = "$safeName.json"
folder.findFile(jsonName)?.delete()
folder.createFile("application/json", jsonName)?.let { doc ->
contentResolver.openOutputStream(doc.uri)?.use { os ->
os.write(gson.toJson(track).toByteArray(Charsets.UTF_8))
// GPX Datei in ZIP
val gpxEntry = ZipEntry("$safeName.gpx")
zos.putNextEntry(gpxEntry)
zos.write(gpxExporter.generateGpxContent(track).toByteArray(Charsets.UTF_8))
zos.closeEntry()
}
}
val gpxName = "$safeName.gpx"
folder.findFile(gpxName)?.delete()
folder.createFile("application/gpx+xml", gpxName)?.let { doc ->
contentResolver.openOutputStream(doc.uri)?.use { os ->
os.write(gpxExporter.generateGpxContent(track).toByteArray(Charsets.UTF_8))
}
}
count++
}
withContext(Dispatchers.Main) {
Toast.makeText(this@ActivitiesActivity, "$count Track(s) gesichert (JSON + GPX)", Toast.LENGTH_LONG).show()
Toast.makeText(this@ActivitiesActivity, "${tracks.size} Tracks in Backup gesichert", Toast.LENGTH_LONG).show()
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
@@ -146,42 +139,35 @@ class ActivitiesActivity : AppCompatActivity() {
}
}
private fun performDriveRestore(folderUri: Uri) {
private fun performDriveRestore(fileUri: Uri) {
lifecycleScope.launch(Dispatchers.IO) {
try {
val folder = DocumentFile.fromTreeUri(this@ActivitiesActivity, folderUri)
if (folder == null) {
withContext(Dispatchers.Main) {
Toast.makeText(this@ActivitiesActivity, "Ordner nicht zugänglich", Toast.LENGTH_LONG).show()
}
return@launch
}
val files = folder.listFiles()
val jsonFiles = files.filter { it.name?.endsWith(".json") == true }
val gpxFiles = files.filter { it.name?.endsWith(".gpx") == true }
val jsonNames = jsonFiles.map { it.name!!.removeSuffix(".json") }.toSet()
var restored = 0
var skipped = 0
jsonFiles.forEach { file ->
contentResolver.openInputStream(file.uri)?.use { inputStream ->
val content = inputStream.readBytes().toString(Charsets.UTF_8)
val track = runCatching { gson.fromJson(content, Track::class.java) }.getOrNull()
if (track != null) {
if (trackStorage.getTrack(track.name) == null) { trackStorage.saveTrack(track); restored++ } else skipped++
contentResolver.openInputStream(fileUri)?.use { inputStream ->
ZipInputStream(inputStream).use { zis ->
var entry = zis.nextEntry
while (entry != null) {
if (!entry.isDirectory && entry.name.endsWith(".json")) {
val content = zis.readBytes().toString(Charsets.UTF_8)
val track = runCatching { gson.fromJson(content, Track::class.java) }.getOrNull()
if (track != null) {
if (trackStorage.getTrack(track.name) == null) {
trackStorage.saveTrack(track)
restored++
} else {
skipped++
}
}
}
zis.closeEntry()
entry = zis.nextEntry
}
}
}
gpxFiles.filter { it.name?.removeSuffix(".gpx") !in jsonNames }.forEach { file ->
val track = gpxImporter.importFromUri(file.uri)
if (track != null) {
if (trackStorage.getTrack(track.name) == null) { trackStorage.saveTrack(track); restored++ } else skipped++
}
}
withContext(Dispatchers.Main) {
val msg = buildString {
append("$restored Track(s) wiederhergestellt")
@@ -206,7 +192,6 @@ class ActivitiesActivity : AppCompatActivity() {
private fun loadAndDisplayTracks() {
val tracks = trackStorage.getAllTracks()
binding.tracksContainer.removeAllViews()
if (tracks.isEmpty()) {
@@ -1,6 +1,10 @@
package de.tstratmann.gpstracker
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Path
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
@@ -18,6 +22,7 @@ class TrackDetailActivity : AppCompatActivity(), OnMapReadyCallback {
private lateinit var binding: ActivityTrackDetailBinding
private lateinit var googleMap: GoogleMap
private lateinit var track: Track
private val waypointMarkers = mutableListOf<Marker>()
companion object {
const val EXTRA_TRACK_NAME = "track_name"
@@ -86,6 +91,93 @@ class TrackDetailActivity : AppCompatActivity(), OnMapReadyCallback {
drawTrackWithSpeedGradient()
addStartEndMarkers()
fitMapToTrack()
googleMap.setOnCameraIdleListener {
displayWaypoints(googleMap.cameraPosition.zoom)
}
googleMap.setOnMarkerClickListener { marker ->
val point = marker.tag as? TrackPoint ?: return@setOnMarkerClickListener false
marker.title = formatWaypointSpeed(point.speed)
marker.snippet = "Kurs: ${point.bearing.toInt()}°"
marker.showInfoWindow()
true
}
}
private fun getWaypointStride(zoom: Float): Int = when {
zoom < 12f -> Int.MAX_VALUE
zoom < 13f -> 100
zoom < 14f -> 50
zoom < 15f -> 20
zoom < 16f -> 10
zoom < 17f -> 5
zoom < 18f -> 3
else -> 1
}
private fun createArrowBitmap(bearing: Float): BitmapDescriptor {
val size = 48
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val cx = size / 2f
val cy = size / 2f
val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
style = Paint.Style.FILL
}
val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.argb(220, 30, 100, 200)
style = Paint.Style.STROKE
strokeWidth = 3f
}
val path = Path().apply {
moveTo(cx, cy - size * 0.38f)
lineTo(cx + size * 0.22f, cy + size * 0.30f)
lineTo(cx, cy + size * 0.12f)
lineTo(cx - size * 0.22f, cy + size * 0.30f)
close()
}
canvas.rotate(bearing, cx, cy)
canvas.drawPath(path, fillPaint)
canvas.drawPath(path, strokePaint)
return BitmapDescriptorFactory.fromBitmap(bitmap)
}
private fun displayWaypoints(zoom: Float) {
waypointMarkers.forEach { it.remove() }
waypointMarkers.clear()
val stride = getWaypointStride(zoom)
if (stride == Int.MAX_VALUE) return
track.points.forEachIndexed { index, point ->
if (index % stride == 0) {
val marker = googleMap.addMarker(
MarkerOptions()
.position(LatLng(point.latitude, point.longitude))
.icon(createArrowBitmap(point.bearing))
.anchor(0.5f, 0.5f)
.flat(true)
) ?: return@forEachIndexed
marker.tag = point
waypointMarkers.add(marker)
}
}
}
private fun formatWaypointSpeed(speedMs: Float): String {
val useKnots = track.activityType?.useKnots == true
return if (useKnots) {
val knots = speedMs * 1.94384f
"%.1f kn".format(knots)
} else {
"${(speedMs * 3.6f).toInt()} km/h"
}
}
private fun drawTrackWithSpeedGradient() {
@@ -19,21 +19,30 @@ class GPXManager(private val context: Context) {
return file
}
fun finalizeSession() {
currentFile?.appendText(getGpxFooter())
currentFile = null
}
fun appendLocation(location: Location) {
currentFile?.let { file ->
val time = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}.format(Date(location.time))
val trkpt = """
<trkpt lat="${location.latitude}" lon="${location.longitude}">
<ele>${location.altitude}</ele>
<time>$time</time>
</trkpt>
""".trimIndent()
val sb = StringBuilder()
sb.append("<trkpt lat=\"${location.latitude}\" lon=\"${location.longitude}\">")
sb.append("<ele>${location.altitude}</ele>")
sb.append("<time>$time</time>")
if (location.speed > 0 || location.bearing > 0) {
sb.append("<extensions>")
if (location.speed > 0) sb.append("<speed>${location.speed}</speed>")
if (location.bearing > 0) sb.append("<course>${location.bearing}</course>")
sb.append("</extensions>")
}
sb.append("</trkpt>")
val content = file.readText().removeSuffix(getGpxFooter())
file.writeText(content + trkpt + getGpxFooter())
file.appendText(sb.toString())
}
}
@@ -45,6 +54,12 @@ class GPXManager(private val context: Context) {
return context.filesDir.listFiles { _, name -> name.endsWith(".gpx") }?.toList() ?: emptyList()
}
fun deleteAllFiles(): Int {
val files = getAllFiles()
files.forEach { it.delete() }
return files.size
}
private fun getGpxHeader() = """
<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="WearGPSTracker" xmlns="http://www.topografix.com/GPX/1/1">
@@ -25,11 +25,14 @@ class TrackingService : Service() {
private var startTime = 0L
private var lastLocation: Location? = null
private var job: Job? = null
private var speedSumKmh = 0.0
private var speedCount = 0
data class TrackingData(
val durationSeconds: Long = 0,
val distanceKm: Double = 0.0,
val speedKmh: Double = 0.0
val speedKmh: Double = 0.0,
val avgSpeedKmh: Double = 0.0
)
inner class LocalBinder : Binder() {
@@ -65,9 +68,16 @@ class TrackingService : Service() {
lastLocation = location
val speedKmh = location.speed * 3.6
if (speedKmh > 0) {
speedSumKmh += speedKmh
speedCount++
}
val avgSpeedKmh = if (speedCount > 0) speedSumKmh / speedCount else 0.0
_trackingState.value = currentData.copy(
distanceKm = newDistance,
speedKmh = speedKmh
speedKmh = speedKmh,
avgSpeedKmh = avgSpeedKmh
)
}
@@ -86,10 +96,13 @@ class TrackingService : Service() {
gpxManager.startNewSession()
startTime = System.currentTimeMillis()
lastLocation = null
speedSumKmh = 0.0
speedCount = 0
_trackingState.value = TrackingData()
val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 1000)
.setMinUpdateIntervalMillis(1000)
val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 5000)
.setMinUpdateIntervalMillis(5000)
.setMinUpdateDistanceMeters(10f)
.build()
try {
@@ -110,6 +123,7 @@ class TrackingService : Service() {
fun stopTracking() {
fusedLocationClient.removeLocationUpdates(locationCallback)
job?.cancel()
gpxManager.finalizeSession()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
@@ -14,6 +14,8 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -154,16 +156,18 @@ fun TrackingScreen(navController: NavHostController) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
verticalArrangement = Arrangement.Center,
modifier = Modifier.padding(horizontal = 16.dp)
) {
val hours = trackingData.durationSeconds / 3600
val minutes = (trackingData.durationSeconds % 3600) / 60
val seconds = trackingData.durationSeconds % 60
val timeString = String.format("%02d:%02d:%02d", hours, minutes, seconds)
Text(text = timeString, style = MaterialTheme.typography.display1)
Text(text = timeString, style = MaterialTheme.typography.title1)
Text(text = String.format("%.2f km", trackingData.distanceKm), style = MaterialTheme.typography.body1)
Text(text = String.format("%.1f km/h", trackingData.speedKmh), style = MaterialTheme.typography.body1)
Text(text = String.format("Ø %.1f km/h", trackingData.avgSpeedKmh), style = MaterialTheme.typography.body2)
Spacer(modifier = Modifier.height(12.dp))
@@ -200,11 +204,47 @@ fun ActivitiesScreen(navController: NavHostController) {
val gpxManager = remember { GPXManager(context) }
val syncManager = remember { WearSyncManager(context) }
var count by remember { mutableIntStateOf(gpxManager.getActivitiesCount()) }
var showDeleteConfirm by remember { mutableStateOf(false) }
if (showDeleteConfirm) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = "Alle löschen?", style = MaterialTheme.typography.title3)
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = {
val deleted = gpxManager.deleteAllFiles()
count = 0
showDeleteConfirm = false
Toast.makeText(context, "$deleted Aktivitäten gelöscht", Toast.LENGTH_SHORT).show()
},
colors = ButtonDefaults.buttonColors(backgroundColor = Color.Red),
modifier = Modifier.fillMaxWidth(0.8f)
) {
Text("Ja, löschen", color = Color.White)
}
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = { showDeleteConfirm = false },
modifier = Modifier.fillMaxWidth(0.8f),
colors = ButtonDefaults.secondaryButtonColors()
) {
Text("Abbrechen")
}
}
}
return
}
val scrollState = rememberScrollState()
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
verticalArrangement = Arrangement.Center,
modifier = Modifier.verticalScroll(scrollState).padding(vertical = 16.dp)
) {
Text(text = "$count Aktivitäten", style = MaterialTheme.typography.title3)
Spacer(modifier = Modifier.height(12.dp))
@@ -230,6 +270,14 @@ fun ActivitiesScreen(navController: NavHostController) {
Text("GPX Sync")
}
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = { showDeleteConfirm = true },
modifier = Modifier.fillMaxWidth(0.8f),
colors = ButtonDefaults.buttonColors(backgroundColor = Color.Red)
) {
Text("Alle löschen", color = Color.White)
}
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = { navController.popBackStack() },
modifier = Modifier.fillMaxWidth(0.8f),