Release GPS2Audio v2.5.44 POI category fix

This commit is contained in:
Perplexity Computer
2026-05-30 05:42:44 +00:00
parent 18ea77b21d
commit 014e6c1bf1
100 changed files with 28584 additions and 1083 deletions
@@ -0,0 +1,180 @@
package de.waypointaudio.license
import android.content.Context
import android.util.Base64
import de.waypointaudio.R
import org.json.JSONObject
import java.io.BufferedReader
import java.io.InputStreamReader
import java.security.KeyFactory
import java.security.PublicKey
import java.security.Signature
import java.security.spec.X509EncodedKeySpec
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
/**
* v2.5.20 — Echte Offline-Lizenzprüfung.
*
* v2.5.17 hat lediglich den Public Key als Raw-Resource eingebettet und ein
* Skelett für die spätere Prüfung bereitgestellt. v2.5.20 implementiert nun:
* - Lesen einer `.gps2audio-license`-Datei (JSON).
* - Strukturelle Prüfung (`schema_version`, `payload`, `product`, Signatur-
* Algorithmus).
* - ECDSA-P256-SHA256-Verifikation gegen den eingebetteten Public Key.
* - Gültigkeitsprüfung (`valid_until` falls gesetzt).
*
* Die Methode [verifyOffline] gibt ein [Result]-Objekt mit dem Ergebnis
* zurück. Sie wirft NIEMALS — auch nicht bei ungültigem JSON oder fehlender
* Resource — sondern liefert ein passendes Fehlerergebnis.
*
* WICHTIG: Diese Prüfung beeinflusst KEINE App-Funktion. Sie wird nur für
* die Anzeige im Lizenzbereich verwendet (siehe [LicenseStatusProvider]).
* Der Private Key bleibt ausschliesslich beim Ersteller.
*/
object LicenseValidator {
private const val SCHEMA_VERSION = "gps2audio-license-v1"
private const val SIGNATURE_ALGORITHM = "ECDSA_P256_SHA256"
private const val PRODUCT_NAME = "GPS2Audio"
data class Result(
val state: LicenseStatus.State,
val payload: JSONObject? = null,
val rawJson: String? = null,
val message: String? = null,
) {
val isValid: Boolean get() = state == LicenseStatus.State.VALID
}
fun readEmbeddedPublicKeyPem(context: Context): String? = try {
context.resources.openRawResource(R.raw.gps2audio_license_public).use { input ->
BufferedReader(InputStreamReader(input, Charsets.UTF_8)).readText()
}
} catch (_: Throwable) {
null
}
/**
* Prüft den übergebenen Lizenz-Dateiinhalt offline.
*
* Reihenfolge der Checks:
* 1. JSON parsen.
* 2. `schema_version` muss "gps2audio-license-v1" sein.
* 3. `payload.product` muss "GPS2Audio" sein.
* 4. `signature.algorithm` muss "ECDSA_P256_SHA256" sein.
* 5. ECDSA-Verifikation gegen den eingebetteten Public Key.
* 6. `payload.valid_until` (falls vorhanden) darf nicht in der
* Vergangenheit liegen.
*/
fun verifyOffline(context: Context, licenseFileBytes: ByteArray): Result {
val raw = try {
String(licenseFileBytes, Charsets.UTF_8)
} catch (_: Throwable) {
return Result(LicenseStatus.State.INVALID, message = "Datei ist kein gültiges UTF-8")
}
val doc = try {
JSONObject(raw)
} catch (_: Throwable) {
return Result(LicenseStatus.State.INVALID, message = "Lizenzdatei ist kein gültiges JSON")
}
val schemaVersion = doc.optString("schema_version", "")
val payload = doc.optJSONObject("payload")
val signatureObj = doc.optJSONObject("signature")
if (schemaVersion != SCHEMA_VERSION || payload == null) {
return Result(LicenseStatus.State.INVALID, payload, raw, "Ungültiges Lizenzformat")
}
val product = payload.optString("product", "")
if (product != PRODUCT_NAME) {
return Result(LicenseStatus.State.INVALID, payload, raw, "Falsches Produkt: $product")
}
val algorithm = signatureObj?.optString("algorithm", "") ?: ""
if (algorithm != SIGNATURE_ALGORITHM) {
return Result(LicenseStatus.State.INVALID, payload, raw, "Unbekannter Signaturalgorithmus")
}
val signatureValue = signatureObj.optString("value", "")
val signatureBytes = try {
Base64.decode(signatureValue, Base64.DEFAULT)
} catch (_: Throwable) {
return Result(LicenseStatus.State.INVALID, payload, raw, "Signatur ist kein gültiges Base64")
}
val publicKey = loadEmbeddedPublicKey(context)
?: return Result(LicenseStatus.State.INVALID, payload, raw, "Eingebetteter Public Key fehlt")
val signedContent = JSONObject().apply {
put("schema_version", schemaVersion)
put("payload", payload)
}
val canonical = CanonicalJson.encode(signedContent)
val signatureValid = try {
val sig = Signature.getInstance("SHA256withECDSA")
sig.initVerify(publicKey)
sig.update(canonical.toByteArray(Charsets.UTF_8))
sig.verify(signatureBytes)
} catch (_: Throwable) {
false
}
if (!signatureValid) {
return Result(LicenseStatus.State.INVALID, payload, raw, "Signatur ungültig")
}
val validUntilRaw = payload.optString("valid_until", "")
val validUntilNonNull = if (payload.isNull("valid_until")) null else validUntilRaw.ifEmpty { null }
if (validUntilNonNull != null) {
val expiry = parseIsoDate(validUntilNonNull)
if (expiry == null) {
return Result(LicenseStatus.State.INVALID, payload, raw, "Ungültiges Ablaufdatum")
}
if (expiry.before(Date())) {
return Result(LicenseStatus.State.EXPIRED, payload, raw, "Lizenz abgelaufen")
}
}
return Result(LicenseStatus.State.VALID, payload, raw, "Lizenz ist gültig")
}
private fun loadEmbeddedPublicKey(context: Context): PublicKey? {
val pem = readEmbeddedPublicKeyPem(context) ?: return null
return try {
val base64 = pem
.replace("-----BEGIN PUBLIC KEY-----", "")
.replace("-----END PUBLIC KEY-----", "")
.replace("\\s".toRegex(), "")
val der = Base64.decode(base64, Base64.DEFAULT)
val spec = X509EncodedKeySpec(der)
KeyFactory.getInstance("EC").generatePublic(spec)
} catch (_: Throwable) {
null
}
}
private fun parseIsoDate(value: String): Date? {
val cleaned = value.trim().let {
if (it.endsWith("Z")) it.dropLast(1) + "+0000" else it.replace(Regex("([+-]\\d{2}):(\\d{2})$"), "$1$2")
}
val formats = listOf(
"yyyy-MM-dd'T'HH:mm:ssZ",
"yyyy-MM-dd'T'HH:mm:ss.SSSZ",
"yyyy-MM-dd",
)
for (pattern in formats) {
try {
val sdf = SimpleDateFormat(pattern, Locale.US)
sdf.timeZone = TimeZone.getTimeZone("UTC")
return sdf.parse(cleaned)
} catch (_: Throwable) {
// try next
}
}
return null
}
}