Files
GPS2Audio/app/src/main/kotlin/de/waypointaudio/ui/AboutDialog.kt
T
2026-05-30 05:42:44 +00:00

1075 lines
50 KiB
Kotlin

package de.waypointaudio.ui
import android.content.ActivityNotFoundException
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.ParcelFileDescriptor
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import de.waypointaudio.R
import de.waypointaudio.license.DeviceIdProvider
import de.waypointaudio.license.LicenseStatusProvider
import de.waypointaudio.util.AppVersion
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileInputStream
import java.io.IOException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
// Info-Dialog: Zeigt App-Name, Lizenz-/Pro-Bereich mit Lizenzstatus, Versions-/Entwicklerinfos.
//
// v2.5.28 - Profi Lizenztext:
// - Profi-Sektion: Abo-Wording vollständig entfernt. Stattdessen korrekte
// Formulierung einer einjährigen Lizenz mit aktiver Erneuerungsoption.
// - Keine automatische Verlängerung / keine Abo-Vertragswirkung mehr suggeriert.
// - Features-Text neu strukturiert: kompakte Bullet-Liste statt Fließtext.
// - Neue Strings: about_pro_tagline2, about_pro_renewal_note, about_pro_features_list.
// - Alle technischen Lizenzimport-/Prüf-Mechanismen aus v2.5.27 unverändert.
//
// v2.5.27 - Profi Abo Mailfix:
// - Empfängeradresse geändert von m3lmayer@protonmail.com auf marcel.mayer@nesohub.org.
// - Mail-Intent robuster: ACTION_SENDTO mit vollständiger mailto-URI (Empfänger, Subject,
// Body vollständig URL-encoded via java.net.URLEncoder direkt in Uri.parse("mailto:...")).
// Empfänger ist damit zuverlässig gesetzt, unabhängig von E-Mail-App-Verhalten.
// - Alle technischen Lizenzimport-/Prüf-Mechanismen aus v2.5.26 unverändert.
//
// v2.5.26 - License-Page-Cleanup:
// - Preis (555 €) und PayPal-Button aus der About-Seite entfernt.
// - Neuer E-Mail-Button "Profi-Lizenz per E-Mail anfragen" mit vorbefüllter
// E-Mail (ACTION_SENDTO, mailto:), inkl. Geräte-ID automatisch eingefügt.
// - Fallback-Toast wenn keine E-Mail-App verfügbar ist.
// - Pro-Sektion verschlankt: Tagline + Features-Text, kompaktere Struktur.
// - LicenseStatusProvider: humanLicenseType() erkennt jetzt auch "pro"/"profi"
// als Anzeigename "Profi" (nur Anzeige, kein Formatänderung).
// - Alle technischen Lizenzimport-/Prüf-Mechanismen aus v2.5.25 unverändert.
//
// v2.5.25 - License-Import-Thread-Fix:
// - Alle blockierenden I/O-Operationen (contentResolver.openInputStream,
// openFileDescriptor, FileInputStream, ByteArrayOutputStream.copyTo,
// LicenseStatusProvider.import inkl. JSON/Signaturvalidierung) laufen jetzt
// auf Dispatchers.IO, nicht mehr auf dem Main-Thread.
// - ActivityResult-Callback startet eine Coroutine via rememberCoroutineScope().
// withContext(Dispatchers.IO) { readLicenseBytes(...) + LicenseStatusProvider.import() }
// Danach werden UI-State/Toast/reloadTrigger wieder auf dem Main-Thread gesetzt.
// - isImporting-Flag verhindert Doppelauslösung des Import-Buttons.
// - exportLauncher ebenfalls auf Dispatchers.IO verschoben.
// - Textimport: LicenseStatusProvider.import() jetzt ebenfalls auf Dispatchers.IO.
// - Alle v2.5.24-Fallbacks (drei Lesestrategien, Diagnose-Dialog, Textimport,
// OpenDocument */*, importNonce) unverändert beibehalten.
//
// v2.5.24 - Lizenzimport-Fallbacks:
// 1. readLicenseBytes() mit drei Lesestrategien:
// a) openInputStream(uri) -> copyTo(ByteArrayOutputStream)
// b) openFileDescriptor(uri, "r") -> FileInputStream(fd)
// c) uri.scheme=="file" -> File(uri.path).inputStream()
// 2. OpenDocument() mit nur "*/" + "*" (d.h. */*) - keine MIME-Liste, verhindert Galerie-Modus.
// 3. importNonce: unveraendert aus v2.5.23.
// 4. Textimport-Fallback: AlertDialog mit JSON-Textfeld + Zwischenablage-Button.
// 5. Diagnose-Dialog statt generischem Toast bei Totalausfall aller Lesestrategien.
// v2.5.23 - Regression-Fix OpenDocument.
// v2.5.21 - Re-Import-Bugfix + Geraete-ID.
// v2.5.20 - Lizenzaktionen.
@Composable
fun AboutDialog(onDismiss: () -> Unit) {
val context = LocalContext.current
val versionInfo = remember(context) { AppVersion.get(context) }
val scope = rememberCoroutineScope()
// reloadTrigger: Inkrementieren erzwingt Recomposition der Lizenzstatus-Card.
var reloadTrigger by remember { mutableIntStateOf(0) }
// importNonce: Inkrementieren erzeugt neuen Launcher-Slot für Reimport derselben Datei.
var importNonce by remember { mutableIntStateOf(0) }
// v2.5.25 — Import läuft: verhindert Doppelauslösung während I/O auf IO-Dispatcher.
var isImporting by remember { mutableStateOf(false) }
// Zustände für Fehlerdiagnose-Dialog
var showDiagDialog by remember { mutableStateOf(false) }
var diagMessage by remember { mutableStateOf("") }
// Zustand für Textimport-Dialog
var showTextImport by remember { mutableStateOf(false) }
LaunchedEffect(reloadTrigger) {
LicenseStatusProvider.ensureLoaded(context)
}
val showBgReliability = remember { mutableStateOf(false) }
if (showBgReliability.value) {
BackgroundReliabilityManualDialog(onClose = { showBgReliability.value = false })
}
var showRemoveConfirm by remember { mutableStateOf(false) }
// v2.5.24 — Diagnose-Dialog: zeigt technische Fehlerdetails, wenn alle Lesestrategien scheitern.
if (showDiagDialog) {
AlertDialog(
onDismissRequest = { showDiagDialog = false },
title = { Text(stringResource(R.string.about_license_import_err_diag_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = stringResource(R.string.about_license_import_err_diag_intro),
style = MaterialTheme.typography.bodyMedium
)
Text(
text = diagMessage,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
Text(
text = stringResource(R.string.about_license_import_err_diag_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
}
},
confirmButton = {
TextButton(onClick = { showDiagDialog = false }) {
Text(stringResource(R.string.about_close))
}
}
)
}
// v2.5.24 — Textimport-Dialog: Nutzer fügt JSON-Inhalt der Lizenzdatei ein.
if (showTextImport) {
LicenseTextImportDialog(
context = context,
onDismiss = { showTextImport = false },
onImported = {
reloadTrigger++
showTextImport = false
}
)
}
// v2.5.25 — Import-Launcher mit Dispatchers.IO-Fix.
//
// v2.5.24 — Import-Launcher: OpenDocument() mit nur "*/*" (maximale Kompatibilität).
//
// Begründung für nur "*/*":
// - Mehrere MIME-Typen (json, text/*, octet-stream, */*) können auf einigen
// MIUI-Versionen dazu führen, dass der Picker in einen Galerie/Medien-Modus
// wechselt. Nur "*/*" erzwingt immer den echten Datei-Browser.
// - OpenDocument() bleibt (nicht GetContent): echter SAF-Datei-Browser, zeigt
// alle Dateien inkl. .gps2audio-license.
//
// v2.5.25 — I/O auf Dispatchers.IO:
// - readLicenseBytes() (openInputStream/openFileDescriptor/File-Lesen) läuft
// jetzt vollständig auf Dispatchers.IO.
// - LicenseStatusProvider.import() (inkl. JSON-Parsing + Signaturprüfung)
// ebenfalls auf Dispatchers.IO.
// - UI-State (diagMessage, showDiagDialog, Toast, reloadTrigger, isImporting)
// wird danach auf dem Main-Thread gesetzt.
val importLauncher = key(importNonce) {
rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
) { uri: Uri? ->
// Immer erhöhen: nächster Import (auch gleiche Datei) bekommt frischen Launcher.
importNonce++
if (uri == null) {
// Nutzer hat Picker abgebrochen.
return@rememberLauncherForActivityResult
}
// v2.5.25 — Alle blockierenden Operationen auf Dispatchers.IO verschieben.
isImporting = true
scope.launch {
// --- Dispatchers.IO: readLicenseBytes + LicenseStatusProvider.import ---
val (bytes, errMsg) = withContext(Dispatchers.IO) {
readLicenseBytes(context, uri)
}
if (bytes == null) {
// Alle Strategien gescheitert → Diagnose-Dialog anzeigen (Main-Thread)
diagMessage = errMsg ?: context.getString(R.string.about_license_import_err_open)
showDiagDialog = true
isImporting = false
return@launch
}
if (bytes.isEmpty()) {
Toast.makeText(
context,
context.getString(R.string.about_license_import_err_empty),
Toast.LENGTH_LONG
).show()
isImporting = false
return@launch
}
// LicenseStatusProvider.import: JSON-Parsing + Signaturvalidierung auf IO
val result = withContext(Dispatchers.IO) {
LicenseStatusProvider.import(context, bytes)
}
// Zurück auf Main-Thread: UI-State setzen
val msg = if (result.success) {
context.getString(R.string.about_license_import_success, result.message)
} else {
context.getString(R.string.about_license_import_fail_invalid, result.message)
}
Toast.makeText(context, msg, Toast.LENGTH_LONG).show()
reloadTrigger++
isImporting = false
}
}
}
// v2.5.25 — Export ebenfalls auf Dispatchers.IO (openOutputStream ist blocking).
val exportLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("application/json")
) { uri: Uri? ->
if (uri == null) return@rememberLauncherForActivityResult
scope.launch {
val raw = withContext(Dispatchers.IO) {
LicenseStatusProvider.exportRawJson(context)
}
if (raw == null) {
Toast.makeText(
context,
context.getString(R.string.about_license_export_nothing),
Toast.LENGTH_LONG
).show()
return@launch
}
val ok = withContext(Dispatchers.IO) {
try {
context.contentResolver.openOutputStream(uri)?.use {
it.write(raw.toByteArray(Charsets.UTF_8))
true
} ?: false
} catch (_: Throwable) {
false
}
}
val msg = if (ok) {
context.getString(R.string.about_license_export_success)
} else {
context.getString(R.string.about_license_export_fail)
}
Toast.makeText(context, msg, Toast.LENGTH_LONG).show()
}
}
if (showRemoveConfirm) {
AlertDialog(
onDismissRequest = { showRemoveConfirm = false },
title = { Text(stringResource(R.string.about_license_action_remove_confirm_title)) },
text = { Text(stringResource(R.string.about_license_action_remove_confirm_text)) },
confirmButton = {
TextButton(onClick = {
val ok = LicenseStatusProvider.remove(context)
val msg = if (ok) {
context.getString(R.string.about_license_action_remove_done)
} else {
context.getString(R.string.about_license_action_remove_fail)
}
Toast.makeText(context, msg, Toast.LENGTH_LONG).show()
showRemoveConfirm = false
reloadTrigger++
importNonce++
}) { Text(stringResource(R.string.about_license_action_remove_confirm_ok)) }
},
dismissButton = {
TextButton(onClick = { showRemoveConfirm = false }) {
Text(stringResource(R.string.about_license_action_remove_confirm_cancel))
}
}
)
}
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
text = stringResource(R.string.about_title),
style = MaterialTheme.typography.headlineSmall
)
},
text = {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = stringResource(R.string.about_app_name),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Text(
text = stringResource(R.string.about_description),
style = MaterialTheme.typography.bodyMedium
)
// v2.5.28 — Profi-Sektion: Abo-Wording entfernt, einjährige Lizenz formuliert.
// Keine automatische Verlängerung. Features als kompakte Bullet-Liste.
// Geräte-ID wird automatisch in den E-Mail-Body eingefügt.
val proRequestCd = stringResource(R.string.about_pro_request_open_cd)
val proEmailRecipient = stringResource(R.string.about_pro_email_recipient)
val proEmailSubject = stringResource(R.string.about_pro_email_subject)
val proEmailBodyTemplate = stringResource(R.string.about_pro_email_body_template)
val proEmailNoApp = stringResource(R.string.about_pro_email_no_app)
val proDeviceId = remember(context) { DeviceIdProvider.getDeviceIdHash(context) }
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Column(
modifier = Modifier.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = stringResource(R.string.about_pro_section_label),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.75f)
)
Text(
text = stringResource(R.string.about_pro_title),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
// v2.5.28 — Einjährige Lizenz (kein Abo-Wording)
Text(
text = stringResource(R.string.about_pro_tagline),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
Text(
text = stringResource(R.string.about_pro_tagline2),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
Text(
text = stringResource(R.string.about_pro_renewal_note),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.85f)
)
HorizontalDivider(
modifier = Modifier.padding(vertical = 2.dp),
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.2f)
)
// v2.5.28 — Profi-Features als kompakte Bullet-Liste
Text(
text = stringResource(R.string.about_pro_features_label),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.85f)
)
Text(
text = stringResource(R.string.about_pro_features_list),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.85f)
)
HorizontalDivider(
modifier = Modifier.padding(vertical = 2.dp),
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.2f)
)
Text(
text = stringResource(R.string.about_pro_required_intro),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
Text(
text = stringResource(R.string.about_pro_required_line1),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
Text(
text = stringResource(R.string.about_pro_required_line2),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
Text(
text = stringResource(R.string.about_pro_required_line3),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
Text(
text = stringResource(R.string.about_pro_required_line4),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
Text(
text = stringResource(R.string.about_pro_private_note),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)
)
Text(
text = stringResource(R.string.about_pro_manual_note),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)
)
// v2.5.27 — Mail-Intent-Fix: vollständige mailto-URI mit Empfänger,
// Subject und Body direkt URL-encoded. Empfänger wird dadurch
// zuverlässig in jede E-Mail-App übertragen (kein extras-Verlust).
// ACTION_SENDTO + mailto: ist der korrekte Weg für direkte Mail-Apps.
Button(
onClick = {
val body = proEmailBodyTemplate.format(proDeviceId)
// URL-Encoding für Subject und Body (RFC 2368 / mailto-URI).
// java.net.URLEncoder.encode() kodiert Leerzeichen als '+',
// mailto erwartet jedoch '%20' — daher Replace.
val encodedSubject = java.net.URLEncoder.encode(proEmailSubject, "UTF-8")
.replace("+", "%20")
val encodedBody = java.net.URLEncoder.encode(body, "UTF-8")
.replace("+", "%20")
// Vollständige mailto-URI: Empfänger fest in URI kodiert,
// nicht nur als Extra → zuverlässig in allen Mail-Apps.
val mailtoUriString = "mailto:$proEmailRecipient?subject=$encodedSubject&body=$encodedBody"
val mailtoUri = Uri.parse(mailtoUriString)
val intent = Intent(Intent.ACTION_SENDTO, mailtoUri)
val launched = try {
context.startActivity(intent)
true
} catch (_: ActivityNotFoundException) {
false
}
if (!launched) {
// Fallback: Chooser (z. B. falls kein direkter Handler)
val fallback = Intent(Intent.ACTION_SENDTO, mailtoUri)
val chooser = Intent.createChooser(fallback, null)
val launched2 = try {
context.startActivity(chooser)
true
} catch (_: ActivityNotFoundException) {
false
}
if (!launched2) {
Toast.makeText(context, proEmailNoApp, Toast.LENGTH_LONG).show()
}
}
},
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
),
modifier = Modifier
.fillMaxWidth()
.semantics { contentDescription = proRequestCd }
) {
Text(text = stringResource(R.string.about_pro_request_button))
}
Text(
text = stringResource(R.string.about_pro_contact_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)
)
}
}
// Lizenzstatus-Card mit reloadTrigger-gesteuerter Recomposition.
val licenseStatus = remember(reloadTrigger) { LicenseStatusProvider.status }
Card(
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = stringResource(R.string.about_license_status_label),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold
)
AboutInlineRow(
label = stringResource(R.string.about_license_status_state_label),
value = licenseStatus.stateText
)
licenseStatus.licenseTypeLabel?.let {
AboutInlineRow(
label = stringResource(R.string.about_license_type_label),
value = it
)
}
AboutInlineRow(
label = stringResource(R.string.about_license_status_holder_label),
value = licenseStatus.holderText
)
AboutInlineRow(
label = stringResource(R.string.about_license_status_validity_label),
value = licenseStatus.validityText
)
AboutInlineRow(
label = stringResource(R.string.about_license_status_features_label),
value = licenseStatus.featuresText
)
licenseStatus.bindingText?.let {
AboutInlineRow(
label = stringResource(R.string.about_license_binding_label),
value = it
)
}
licenseStatus.licenseId?.let {
AboutInlineRow(
label = stringResource(R.string.about_license_id_label),
value = it
)
}
if (licenseStatus.permissions.isNotEmpty()) {
Text(
text = stringResource(R.string.about_license_permissions_label) + ":",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold
)
for ((key, value) in licenseStatus.permissions) {
val label = LicenseStatusProvider.humanPermissionLabel(key)
val mark = if (value) "" else ""
Text(
text = " $mark $label",
style = MaterialTheme.typography.bodySmall
)
}
}
licenseStatus.errorMessage?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
Text(
text = stringResource(R.string.about_license_status_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
// Aktionen
Text(
text = stringResource(R.string.about_license_actions_label),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
// v2.5.25 — Import: OpenDocument() */* + Dispatchers.IO-Fix.
// isImporting verhindert Doppelauslösung während laufender I/O.
OutlinedButton(
onClick = {
if (!isImporting) {
try {
importLauncher.launch(arrayOf("*/*"))
} catch (_: ActivityNotFoundException) {
Toast.makeText(
context,
context.getString(R.string.about_license_import_err_no_app),
Toast.LENGTH_LONG
).show()
}
}
},
enabled = !isImporting,
modifier = Modifier.fillMaxWidth()
) {
if (isImporting) {
Text(stringResource(R.string.about_license_action_import) + "")
} else {
Text(stringResource(R.string.about_license_action_import))
}
}
// v2.5.24 — Fallback: Lizenztext manuell einfügen (für MIUI/OEM-Probleme)
OutlinedButton(
onClick = { showTextImport = true },
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.about_license_action_paste))
}
val hasStored = remember(reloadTrigger) {
LicenseStatusProvider.hasStoredLicense(context)
}
if (hasStored) {
OutlinedButton(
onClick = {
try {
exportLauncher.launch(
context.getString(R.string.about_license_export_filename_default)
)
} catch (_: ActivityNotFoundException) {
Toast.makeText(
context,
context.getString(R.string.about_license_export_fail),
Toast.LENGTH_LONG
).show()
}
},
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.about_license_action_export))
}
OutlinedButton(
onClick = { showRemoveConfirm = true },
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error
),
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.about_license_action_remove))
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
// v2.5.21 — Geräte-ID-Sektion (unverändert)
val deviceIdHash = remember(context) { DeviceIdProvider.getDeviceIdHash(context) }
val deviceIdShort = remember(deviceIdHash) {
if (deviceIdHash.length > 24)
"${deviceIdHash.take(16)}${deviceIdHash.takeLast(8)}"
else deviceIdHash
}
Text(
text = stringResource(R.string.about_device_id_label),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold
)
Text(
text = stringResource(R.string.about_device_id_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
AboutInlineRow(
label = stringResource(R.string.about_device_id_field_label),
value = deviceIdShort
)
OutlinedButton(
onClick = {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE)
as? ClipboardManager
if (clipboard != null) {
val clip = ClipData.newPlainText(
context.getString(R.string.about_device_id_copy_label),
deviceIdHash
)
clipboard.setPrimaryClip(clip)
Toast.makeText(
context,
context.getString(R.string.about_device_id_copied),
Toast.LENGTH_SHORT
).show()
}
},
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.about_device_id_copy_button))
}
}
}
AboutRow(
label = stringResource(R.string.about_version_label),
value = versionInfo.display
)
AboutRow(
label = stringResource(R.string.about_developer_label),
value = stringResource(R.string.about_developer)
)
AboutRow(
label = stringResource(R.string.about_email_label),
value = stringResource(R.string.about_email)
)
HorizontalDivider()
Text(
text = stringResource(R.string.about_terms_label),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
Text(
text = stringResource(R.string.about_terms_text),
style = MaterialTheme.typography.bodySmall
)
HorizontalDivider()
Text(
text = stringResource(R.string.about_license_label),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
Text(
text = stringResource(R.string.about_license_name),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold
)
Text(
text = stringResource(R.string.about_license_summary),
style = MaterialTheme.typography.bodySmall
)
Text(
text = stringResource(R.string.about_license_url),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
Text(
text = stringResource(R.string.about_license_commons_clause_label),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
Text(
text = stringResource(R.string.about_license_commons_clause_name),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold
)
Text(
text = stringResource(R.string.about_license_commons_clause_summary),
style = MaterialTheme.typography.bodySmall
)
Text(
text = stringResource(R.string.about_third_party_label),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
Text(
text = stringResource(R.string.about_third_party_text),
style = MaterialTheme.typography.bodySmall
)
Text(
text = stringResource(R.string.about_license_disclaimer),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
Card(
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = stringResource(R.string.bg_reliability_settings_entry),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold
)
Text(
text = stringResource(R.string.bg_reliability_settings_entry_subtitle),
style = MaterialTheme.typography.bodySmall
)
TextButton(onClick = { showBgReliability.value = true }) {
Text(stringResource(R.string.bg_reliability_title))
}
}
}
Text(
text = stringResource(R.string.about_copyright),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.about_close))
}
}
)
}
/**
* v2.5.25 — readLicenseBytes() läuft vollständig auf Dispatchers.IO (Aufrufer-Pflicht).
*
* v2.5.24 — Robuste Lizenzdatei-Lesefunktion mit drei sequenziellen Strategien.
*
* Strategien (in dieser Reihenfolge):
* a) contentResolver.openInputStream(uri) → copyTo(ByteArrayOutputStream)
* Standard-Weg. Scheitert auf MIUI/Xiaomi, wenn der URI nach Rückgabe aus dem
* Picker keine gültige temporäre Leseberechtigung mehr hat.
* b) contentResolver.openFileDescriptor(uri, "r") → FileInputStream(fd)
* Alternativer Weg über ParcelFileDescriptor. Gibt auf manchen OEM-Systemen
* einen gültigen FD auch wenn openInputStream() null zurückgibt.
* c) uri.scheme == "file" → File(uri.path!!).inputStream()
* Fallback für datei-basierte URIs (file://-Schema). Relevant für Geräte, die
* echte Dateipfade statt Content-URIs liefern.
*
* WICHTIG: Diese Funktion MUSS auf einem IO-Thread aufgerufen werden
* (z.B. withContext(Dispatchers.IO) { readLicenseBytes(...) }).
* NetworkOnMainThreadException und StrictMode-Verstöße werden so verhindert.
*
* Rückgabe: Pair<ByteArray?, String?>
* - Wenn Bytes gelesen: Pair(bytes, null)
* - Wenn alle Strategien scheitern: Pair(null, diagnoseMeldung)
*/
private fun readLicenseBytes(context: Context, uri: Uri): Pair<ByteArray?, String?> {
val errors = mutableListOf<String>()
// Strategie a: openInputStream
try {
val stream = context.contentResolver.openInputStream(uri)
if (stream != null) {
val out = ByteArrayOutputStream()
stream.use { it.copyTo(out) }
val bytes = out.toByteArray()
if (bytes.isNotEmpty()) return Pair(bytes, null)
// Leeres Ergebnis ist kein Fehler der Strategie — wird vom Aufrufer geprüft.
return Pair(bytes, null)
} else {
errors.add("InputStream: null")
}
} catch (e: SecurityException) {
errors.add("InputStream: SecurityException (${e.message?.take(60) ?: "?"})")
} catch (e: IOException) {
errors.add("InputStream: IOException (${e.message?.take(60) ?: "?"})")
} catch (e: Throwable) {
errors.add("InputStream: ${e.javaClass.simpleName} (${e.message?.take(60) ?: "?"})")
}
// Strategie b: openFileDescriptor
try {
val pfd: ParcelFileDescriptor? = context.contentResolver.openFileDescriptor(uri, "r")
if (pfd != null) {
pfd.use { descriptor ->
val out = ByteArrayOutputStream()
FileInputStream(descriptor.fileDescriptor).use { it.copyTo(out) }
val bytes = out.toByteArray()
return Pair(bytes, null)
}
} else {
errors.add("FileDescriptor: null")
}
} catch (e: SecurityException) {
errors.add("FileDescriptor: SecurityException (${e.message?.take(60) ?: "?"})")
} catch (e: IOException) {
errors.add("FileDescriptor: IOException (${e.message?.take(60) ?: "?"})")
} catch (e: Throwable) {
errors.add("FileDescriptor: ${e.javaClass.simpleName} (${e.message?.take(60) ?: "?"})")
}
// Strategie c: file:-Schema
if (uri.scheme?.equals("file", ignoreCase = true) == true) {
try {
val path = uri.path
if (path != null) {
val file = File(path)
if (file.exists() && file.canRead()) {
val out = ByteArrayOutputStream()
file.inputStream().use { it.copyTo(out) }
return Pair(out.toByteArray(), null)
} else {
errors.add("File: nicht lesbar (existiert=${file.exists()})")
}
} else {
errors.add("File: path ist null")
}
} catch (e: Throwable) {
errors.add("File: ${e.javaClass.simpleName} (${e.message?.take(60) ?: "?"})")
}
} else {
errors.add("File: kein file:-Schema (scheme=${uri.scheme})")
}
// Alle Strategien gescheitert
val diag = errors.joinToString("; ")
return Pair(null, diag)
}
/**
* v2.5.25 — LicenseStatusProvider.import() läuft auf Dispatchers.IO (Aufrufer-Pflicht).
*
* v2.5.24 — Dialog zum manuellen Einfügen des Lizenztexts (JSON).
*
* Zweck: Fallback für Geräte/Dateimanager, die keine sauberen URIs liefern.
* Der Nutzer kann den Inhalt der .gps2audio-license-Datei (JSON-Text) direkt
* einfügen — entweder per Tippen oder per "Aus Zwischenablage".
*
* Wichtig:
* - Der eingefügte Text wird über LicenseStatusProvider.import() importiert.
* - Bestehende gültige Lizenz wird NICHT überschrieben, wenn der Text ungültig ist.
* - Der Dialog zeigt klar an, dass das eine Notlösung ist.
*/
@Composable
private fun LicenseTextImportDialog(
context: Context,
onDismiss: () -> Unit,
onImported: () -> Unit
) {
val scope = rememberCoroutineScope()
var text by remember { mutableStateOf("") }
var errorMsg by remember { mutableStateOf<String?>(null) }
var isImporting by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.about_license_paste_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = stringResource(R.string.about_license_paste_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f)
)
OutlinedTextField(
value = text,
onValueChange = {
text = it
errorMsg = null
},
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 120.dp, max = 200.dp),
label = { Text(stringResource(R.string.about_license_paste_field_label)) },
placeholder = { Text("{ \"license_id\": \"...\", ... }") },
maxLines = 12
)
// "Aus Zwischenablage" Button
TextButton(
onClick = {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE)
as? ClipboardManager
val clip = clipboard?.primaryClip
if (clip != null && clip.itemCount > 0) {
val pasted = clip.getItemAt(0).coerceToText(context).toString()
text = pasted
errorMsg = null
} else {
errorMsg = context.getString(R.string.about_license_paste_clipboard_empty)
}
},
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.about_license_paste_from_clipboard))
}
errorMsg?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
}
},
confirmButton = {
Button(
onClick = {
val trimmed = text.trim()
if (trimmed.isEmpty()) {
errorMsg = context.getString(R.string.about_license_paste_empty_error)
return@Button
}
// v2.5.25 — JSON-Parsing + Signaturvalidierung auf Dispatchers.IO
isImporting = true
errorMsg = null
val bytes = trimmed.toByteArray(Charsets.UTF_8)
scope.launch {
val result = withContext(Dispatchers.IO) {
LicenseStatusProvider.import(context, bytes)
}
isImporting = false
if (result.success) {
Toast.makeText(
context,
context.getString(R.string.about_license_import_success, result.message),
Toast.LENGTH_LONG
).show()
onImported()
} else {
errorMsg = context.getString(
R.string.about_license_import_fail_invalid,
result.message
)
}
}
},
enabled = text.isNotBlank() && !isImporting
) {
Text(stringResource(R.string.about_license_paste_import_button))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
}
)
}
@Composable
private fun AboutRow(label: String, value: String) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = label,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium
)
}
}
@Composable
private fun AboutInlineRow(
label: String,
value: String,
color: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.onSurface
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "$label:",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
color = color
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
color = color
)
}
}