1075 lines
50 KiB
Kotlin
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
|
|
)
|
|
}
|
|
}
|