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 * - Wenn Bytes gelesen: Pair(bytes, null) * - Wenn alle Strategien scheitern: Pair(null, diagnoseMeldung) */ private fun readLicenseBytes(context: Context, uri: Uri): Pair { val errors = mutableListOf() // 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(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 ) } }