Files
GPS2Audio/app/src/main/kotlin/de/waypointaudio/MainActivity.kt
T
2026-06-22 09:55:44 +00:00

340 lines
16 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package de.waypointaudio
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.*
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import de.waypointaudio.data.TermsStore
import de.waypointaudio.data.TermsVersions
import de.waypointaudio.data.WakeLockSettingsStore
import de.waypointaudio.service.WakeLockController
import de.waypointaudio.ui.TermsDialog
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import de.waypointaudio.ui.ExplorerScreen
import de.waypointaudio.ui.ExplorerScreenMapLibre
import de.waypointaudio.ui.HomeScreen
import de.waypointaudio.ui.MapMode
import de.waypointaudio.ui.MapScreen
import de.waypointaudio.ui.PermissionRequiredScreen
import de.waypointaudio.ui.TrackDraftEditorScreen
import de.waypointaudio.ui.TrackDraftListScreen
import de.waypointaudio.ui.VersionNoticeDialogHost
import de.waypointaudio.ui.WaypointListScreen
import de.waypointaudio.ui.theme.WaypointAudioTheme
import de.waypointaudio.viewmodel.WaypointViewModel
/**
* Hauptaktivität der App.
*
* Navigation:
* "list" → WaypointListScreen (Startbildschirm)
* "map" → MapScreen (osmdroid Kartenansicht)
*
* Berechtigungslogik:
* - Nur ACCESS_FINE_LOCATION und ACCESS_COARSE_LOCATION sind zwingend erforderlich
* (für Standort-Button und Karte). Ohne diese wird der PermissionScreen gezeigt.
* - POST_NOTIFICATIONS und Audiozugriff werden separat angefragt, blockieren
* aber nicht die Hauptoberfläche.
*/
class MainActivity : ComponentActivity() {
private val viewModel: WaypointViewModel by viewModels()
/** Nur Standortberechtigungen steuern, ob die App-UI angezeigt wird. */
private val locationPermissionsGranted = mutableStateOf(false)
/** Hält das Display abhängig von der Nutzer-Einstellung aktiv. */
lateinit var wakeLockController: WakeLockController
private set
/**
* v2.5.9 — Terms-Akzeptanz. -1 = noch nicht geladen, 0 = noch nicht akzeptiert
* (Dialog zeigen), 1 = akzeptiert (UI freigeben).
*/
private val termsState = mutableStateOf(-1)
// Launcher für Standortberechtigungen (zwingend erforderlich)
private val locationPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { results ->
// Mindestens ACCESS_COARSE_LOCATION muss gewährt sein
val coarseGranted = results[Manifest.permission.ACCESS_COARSE_LOCATION] == true
val fineGranted = results[Manifest.permission.ACCESS_FINE_LOCATION] == true
locationPermissionsGranted.value = coarseGranted || fineGranted
// Nach Gewährung von Standort: optionale Berechtigungen separat anfragen
if (locationPermissionsGranted.value) {
requestOptionalPermissions()
}
}
// Launcher für optionale Berechtigungen (Benachrichtigungen, Audio)
private val optionalPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { /* optional kein Block der Hauptoberfläche */ }
override fun onCreate(savedInstanceState: Bundle?) {
// v2.5.8 — Android Splash Screen (androidx.core.splashscreen). MUSS vor
// super.onCreate() installiert werden. Sorgt für ein kurzes, dezentes
// GPS2Audio-Intro mit Logo (Map-Pin + Wellen) auf Navy-Hintergrund.
// Wir halten den Splash bewusst nur ca. 700 ms — kein Loading-Spinner,
// keine Werbe-Pause. Auf API 31+ animiert das System das Logo automatisch
// beim Übergang. Auf API 2630 nutzt der Backport dieselbe Optik.
val splash = installSplashScreen()
val splashStart = android.os.SystemClock.elapsedRealtime()
splash.setKeepOnScreenCondition {
android.os.SystemClock.elapsedRealtime() - splashStart < 700L
}
super.onCreate(savedInstanceState)
// Wake-Lock-Controller initialisieren
wakeLockController = WakeLockController(this)
val wakeStore = WakeLockSettingsStore(applicationContext)
lifecycleScope.launchWhenStarted {
wakeStore.settings.collect { settings ->
wakeLockController.applySettings(settings)
}
}
lifecycleScope.launchWhenStarted {
viewModel.serviceRunning.collect { running ->
wakeLockController.setTourActive(running)
}
}
// v2.5.9 — Terms-Akzeptanz laden. Solange unklar, UI nicht aufdecken.
val termsStore = TermsStore(applicationContext)
lifecycleScope.launch {
val accepted = termsStore.acceptedVersion.first()
termsState.value = if (accepted == TermsVersions.CURRENT) 1 else 0
}
// Standortberechtigungen prüfen und ggf. anfordern
checkAndRequestLocationPermissions()
setContent {
WaypointAudioTheme(darkTheme = isSystemInDarkTheme()) {
val locationGranted by locationPermissionsGranted
val termsAccepted by termsState
// v2.5.9 — Solange der Akzeptanzstatus noch nicht geladen wurde
// (termsAccepted == -1) zeigen wir nichts; das verhindert ein
// kurzes "Aufflackern" der UI bevor der Dialog erscheint.
if (termsAccepted == -1) {
// bewusst leeres Theme — DataStore ist nach wenigen ms da.
} else if (termsAccepted == 0) {
TermsDialog(
onAccept = {
lifecycleScope.launch {
termsStore.setAccepted(TermsVersions.CURRENT)
termsState.value = 1
}
},
onDecline = {
// Ohne Zustimmung darf die App nicht weiter genutzt werden.
finishAndRemoveTask()
}
)
} else if (locationGranted) {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = "home"
) {
// v2.5.0 — neue Hauptseite "Audio-Bibliothek".
composable("home") {
HomeScreen(
viewModel = viewModel,
onOpenTour = { _ ->
// Tour wurde im HomeScreen bereits über
// viewModel.selectTour() aktiviert. Wir öffnen
// direkt die bestehende Tour-/Waypoint-Ansicht.
navController.navigate("list")
},
// v2.7.14 — Explorer-Kontext-Fix (neutral):
// Einstieg aus Audio-Bibliothek/Home öffnet immer einen
// leeren, neutralen Kontext ("Unbenannte Tour").
// selectNeutralTourForNewExplorer() setzt selectedTour auf
// DEFAULT_TOUR_NAME und aktiviert den Neutral-Explorer-Flag,
// der verhindert, dass der combine-Kollektor automatisch
// zur ersten echten Tour wechselt.
// Einstieg aus WaypointListScreen (Touransicht) behält
// die jeweils aktive Tour — dort wird keine tour-Reset-Funktion
// aufgerufen.
onOpenExplorer = {
viewModel.selectNeutralTourForNewExplorer()
navController.navigate("explorer")
},
// v2.5.19 — „Track Editor" wurde aus dem Hauptmenü
// entfernt; Drafts bleiben über den Explorer
// erreichbar. Die „drafts"-Route bleibt für den
// Explorer-Fluss bestehen.
onOpenClassicView = { navController.navigate("list") }
)
}
composable("list") {
WaypointListScreen(
viewModel = viewModel,
onNavigateToMap = { navController.navigate("map") },
onNavigateToDrafts = { navController.navigate("drafts") },
onNavigateToExplorer = { navController.navigate("explorer") }
)
}
composable("map") {
// v2.2.3: Legacy-Editor — nur noch als interner Fallback erreichbar.
// Mode-Switch zwischen Karteneditor und Explorer entfällt, Explorer
// ist der zentrale Arbeitsbereich.
MapScreen(
viewModel = viewModel,
onNavigateBack = { navController.popBackStack() },
currentMode = null,
onModeChange = null
)
}
composable("explorer") {
// v2.4.0 — MapLibre Engine Cutover: aktiver Renderer ist jetzt MapLibre.
// Die osmdroid-Variante in [ExplorerScreen] bleibt im Quelltext als
// dokumentierter Fallback erhalten.
ExplorerScreenMapLibre(
viewModel = viewModel,
onNavigateBack = { navController.popBackStack() },
onOpenInEditor = { /* abgelöst — Explorer übernimmt */ },
onOpenDraft = { id -> navController.navigate("draft/$id") }
)
// Hinweis: ExplorerScreen (osmdroid) bleibt aus Build-/Symbol-
// Kompatibilität in der Sourcetree.
@Suppress("unused")
val osmdroidFallbackRef: @androidx.compose.runtime.Composable () -> Unit = {
ExplorerScreen(
viewModel = viewModel,
onNavigateBack = { navController.popBackStack() },
onOpenInEditor = { /* abgelöst — Explorer übernimmt */ },
onOpenDraft = { id -> navController.navigate("draft/$id") }
)
}
}
composable("drafts") {
TrackDraftListScreen(
viewModel = viewModel,
onOpenDraft = { id -> navController.navigate("draft/$id") },
onNavigateBack = { navController.popBackStack() }
)
}
composable(
route = "draft/{draftId}",
arguments = listOf(navArgument("draftId") { type = NavType.StringType })
) { backStackEntry ->
val id = backStackEntry.arguments?.getString("draftId") ?: ""
TrackDraftEditorScreen(
viewModel = viewModel,
draftId = id,
onNavigateBack = { navController.popBackStack() }
)
}
}
} else {
PermissionRequiredScreen(
onPermissionsGranted = { checkAndRequestLocationPermissions() }
)
}
}
}
}
override fun onResume() {
super.onResume()
// Beim Zurückkehren aus den Einstellungen erneut prüfen
locationPermissionsGranted.value = hasLocationPermission()
if (locationPermissionsGranted.value) {
requestOptionalPermissions()
}
if (::wakeLockController.isInitialized) {
wakeLockController.onActivityResumed()
}
}
override fun onPause() {
if (::wakeLockController.isInitialized) {
wakeLockController.onActivityPaused()
}
super.onPause()
}
// ---------------------------------------------------------------------------
// Berechtigungslogik
// ---------------------------------------------------------------------------
private fun checkAndRequestLocationPermissions() {
if (hasLocationPermission()) {
locationPermissionsGranted.value = true
requestOptionalPermissions()
} else {
locationPermissionLauncher.launch(
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
)
}
}
/** Prüft ob mindestens grobe oder feine Standortberechtigung vorhanden ist. */
private fun hasLocationPermission(): Boolean {
val fine = ContextCompat.checkSelfPermission(
this, Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
val coarse = ContextCompat.checkSelfPermission(
this, Manifest.permission.ACCESS_COARSE_LOCATION
) == PackageManager.PERMISSION_GRANTED
return fine || coarse
}
/**
* Optionale Berechtigungen (Benachrichtigungen, Audiozugriff) anfragen,
* nachdem Standortberechtigung gewährt wurde.
* Diese blockieren NICHT die Haupt-UI.
*/
private fun requestOptionalPermissions() {
val toRequest = buildList {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(
this@MainActivity, Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
add(Manifest.permission.POST_NOTIFICATIONS)
}
if (ContextCompat.checkSelfPermission(
this@MainActivity, Manifest.permission.READ_MEDIA_AUDIO
) != PackageManager.PERMISSION_GRANTED
) {
add(Manifest.permission.READ_MEDIA_AUDIO)
}
} else {
if (ContextCompat.checkSelfPermission(
this@MainActivity, Manifest.permission.READ_EXTERNAL_STORAGE
) != PackageManager.PERMISSION_GRANTED
) {
add(Manifest.permission.READ_EXTERNAL_STORAGE)
}
}
}
if (toRequest.isNotEmpty()) {
optionalPermissionLauncher.launch(toRequest.toTypedArray())
}
}
}