diff --git a/AGENTS.md b/AGENTS.md index c1370347d..62143dc86 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,3 +40,21 @@ `local.properties`, `Android.common.mk`, `app/src/main/jni/openssl/`, сгенерированные исходники libstrongswan — список в `docs/README.md`) и собирать с `ORG_GRADLE_PROJECT_shortNdkCommands=true` (иначе линковка упирается в лимит длины команды Windows). - Автообновление приложения: `logic/updates/UpdateManager.kt` качает манифест/APK, сверяет SHA-256 и подпись. + +## Android-приложение: выкладка через панель SHX VPN + +Когда владелец просит **«собери и выложи»** (или «собери и загрузи в панель»), выкладка идёт через панель +`https://apk.shnt.top`, а не через `aws s3 cp`. Сама просьба и есть подтверждение владельца на загрузку. + +- Команда (из `src/frontends/android`, с `ORG_GRADLE_PROJECT_shortNdkCommands=true` при сборке из worktree): + `./gradlew uploadShx` — собирает `assembleRelease` и загружает APK **черновиком** в канал `beta`, тип `phone`. +- Опубликовать сразу — только если владелец сказал «опубликуй»: `./gradlew uploadShx -PshxPublish -PshxRollout=<процент>`. + Без явного процента спросить его. Канал релиза — `-PshxChannel=release`, другой тип — `-PshxType=`. +- Заметки «Что нового» берутся из `NOTES_RU` в `.env`; другие — `-PshxNotes="..."`. Перед выкладкой проверить, что + `NOTES_RU` и `versionCode` в `app/build.gradle` относятся к этому релизу (панель отклонит уже загруженный код версии). +- Нужны `SHX_URL` и `SHX_TOKEN` (заданы у пользователя Windows; скрипт `scripts/upload-apk.mjs` читает их и из реестра, + если процесс запущен раньше). Не печатать значение токена. +- Подпись: release APK подписывается keystore из `.env` (`KEYSTORE_FILE`); неподписанный APK задача не загружает. +- После загрузки сообщить владельцу ссылку на версию из вывода скрипта и статус (черновик или текущая с процентом). +- Сборка для старых приложений 2.19.x (`latest.json` в корне бакета) по-прежнему идёт через `build-local.*` и + требует отдельного подтверждения. diff --git a/src/frontends/android/app/build.gradle b/src/frontends/android/app/build.gradle index 941af1d2d..9a10d5212 100644 --- a/src/frontends/android/app/build.gradle +++ b/src/frontends/android/app/build.gradle @@ -45,8 +45,8 @@ android { minSdk = 24 targetSdk = 36 - versionCode 169 - versionName "2.19.1" + versionCode 172 + versionName "2.19.4" /* Base URL of the update storage, trailing slash included */ buildConfigField "String", "UPDATE_BASE_URL", "\"${updateBaseUrl}\"" @@ -74,6 +74,17 @@ android { buildConfigField "String", "UPDATE_S3_KEY_XOR", "\"${java.util.Base64.getEncoder().encodeToString(updateXorKey)}\"" buildConfigField "String", "UPDATE_S3_REGION", "\"${envOr('S3_REGION', 'us-east-1')}\"" + /* SHX update API (web panel). When UPDATE_API_URL, UPDATE_API_PUBLIC_KEY and UPDATE_API_HMAC_KEY are all set, + the app checks for updates there; the bucket path above stays as a fallback while the API is unreachable. + UPDATE_API_PUBLIC_KEY is the raw Ed25519 public key (base64, 32 bytes) shown on the panel's "API updates" page; + the HMAC key is only a guard against casual requests (an APK can always be unpacked), integrity rests on the + manifest signature and on the SHA-256 plus APK signature checks. */ + buildConfigField "String", "UPDATE_API_URL", "\"${envOr('UPDATE_API_URL', '')}\"" + buildConfigField "String", "UPDATE_API_PUBLIC_KEY", "\"${envOr('UPDATE_API_PUBLIC_KEY', '')}\"" + buildConfigField "String", "UPDATE_API_HMAC_KEY", "\"${xorB64(envOr('UPDATE_API_HMAC_KEY', ''))}\"" + /* public download page, opened by «Download from the site» when an update fails */ + buildConfigField "String", "UPDATE_DOWNLOAD_PAGE_URL", "\"${envOr('UPDATE_DOWNLOAD_PAGE_URL', 'https://get.shnt.top/get/universal')}\"" + externalNativeBuild { ndkBuild { arguments '-j' + Runtime.runtime.availableProcessors() @@ -175,6 +186,8 @@ dependencies { implementation 'androidx.activity:activity-compose:1.11.0' /* QR: scanning profiles (camera) and showing a profile as a QR code */ implementation 'com.journeyapps:zxing-android-embedded:4.3.0' + /* Ed25519 verification of the signed update manifest (java.security Ed25519 needs API 33, minSdk is 24) */ + implementation 'net.i2p.crypto:eddsa:0.3.0' debugImplementation 'androidx.compose.ui:ui-tooling' implementation 'androidx.compose.ui:ui-tooling-preview' @@ -182,6 +195,8 @@ dependencies { testImplementation 'junit:junit:4.13.2' testImplementation 'org.assertj:assertj-core:3.27.6' testImplementation 'org.mockito:mockito-core:5.20.0' + /* the android.jar org.json stubs throw on the JVM; the real implementation is needed for the update API tests */ + testImplementation 'org.json:json:20240303' androidTestImplementation 'androidx.test.ext:junit:1.3.0' androidTestImplementation 'androidx.test:runner:1.7.0' @@ -189,3 +204,32 @@ dependencies { androidTestImplementation composeBom androidTestImplementation 'androidx.compose.ui:ui-test-junit4' } + +/* Publish to the SHX VPN panel (https://apk.shnt.top): build the release APK and upload it in one step. + ./gradlew uploadShx -> draft in the beta channel, type phone + ./gradlew uploadShx -PshxPublish -PshxRollout=25 + ./gradlew uploadShx -PshxChannel=release -PshxType=tv -PshxNotes="..." + Needs Node 18+ and SHX_URL, SHX_TOKEN (a CI token from the panel's Settings -> Tokens). + Release notes default to NOTES_RU from .env; the version comes from the build's output-metadata.json. */ +tasks.register('uploadShx', Exec) { + group = 'publishing' + description = 'Build the release APK and upload it to the SHX VPN panel' + dependsOn 'assembleRelease' + def apkFile = layout.buildDirectory.file('outputs/apk/release/app-release.apk') + def script = rootProject.file('scripts/upload-apk.mjs') + doFirst { + def apk = apkFile.get().asFile + if (!apk.exists()) { + throw new GradleException("${apk} not found: the release APK is unsigned (set KEYSTORE_FILE in .env)") + } + def args = ['node', script.absolutePath, apk.absolutePath, + '--type', (findProperty('shxType') ?: envOr('SHX_TYPE', 'phone')).toString(), + '--channel', (findProperty('shxChannel') ?: 'beta').toString()] + def notes = (findProperty('shxNotes') ?: envOr('NOTES_RU', '')).toString() + if (notes) args += ['--notes', notes] + if (project.hasProperty('shxPublish')) { + args += ['--publish', '--rollout', (findProperty('shxRollout') ?: '100').toString()] + } + commandLine args + } +} diff --git a/src/frontends/android/app/proguard-rules.txt b/src/frontends/android/app/proguard-rules.txt index 785f3e746..1bda20534 100644 --- a/src/frontends/android/app/proguard-rules.txt +++ b/src/frontends/android/app/proguard-rules.txt @@ -64,3 +64,6 @@ -keep class * extends androidx.room.RoomDatabase { (); } + +# i2p eddsa (manifest signature check) references sun.security.x509 only for JDK interop, absent on Android +-dontwarn sun.security.x509.** diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/events/Events.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/events/Events.kt index e61cf83cb..b88c3d299 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/events/Events.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/events/Events.kt @@ -143,6 +143,16 @@ object Events ) } + /** A report went to the panel: the code stays here so the user can find it later. */ + @JvmStatic + fun reportSent(context: Context, code: String) + { + EventLog.getInstance(context).record( + EventKind.OK, EventCategory.APP, context.getString(R.string.report_event_title, code), + context.getString(R.string.report_event_text), key = "report:$code", + ) + } + /** The watchdog did not bring back [name]: a password would be needed. */ @JvmStatic fun restoreNeedsPassword(context: Context, uuid: String, name: String) diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/updates/ReportContent.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/updates/ReportContent.kt new file mode 100644 index 000000000..89c2e7379 --- /dev/null +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/updates/ReportContent.kt @@ -0,0 +1,177 @@ +/* + * Copyright (C) 2026 SHX VPN + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the + * Free Software Foundation; either version 2 of the License, or (at your + * option) any later version. See . + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + */ + +package org.strongswan.android.logic.updates + +import java.io.File +import java.io.RandomAccessFile + +/** + * What goes into a report sent from the log screen: today's part of charon.log and, + * on request, the app's diag.log. No Android types here, so it runs in unit tests. + */ +object ReportContent +{ + /* the panel unpacks a report with a 5 MB cap; 3 + 0.75 MB of text leaves room and stays far below 2 MB gzip */ + const val CHARON_MAX_BYTES = 3 * 1024 * 1024 + const val DIAG_MAX_BYTES = 768 * 1024 + const val CUT_MARK = "... (earlier lines cut)\n" + + /** Why a report was not sent; each maps to a reason line of the "not sent" sheet */ + enum class Failure { NO_NETWORK, SERVER_DOWN, SIGNATURE, TOO_OFTEN, TOO_LARGE, REJECTED } + + fun failure(httpCode: Int): Failure = when + { + httpCode == 0 -> Failure.NO_NETWORK + httpCode == 401 -> Failure.SIGNATURE + httpCode == 413 -> Failure.TOO_LARGE + httpCode == 429 -> Failure.TOO_OFTEN + httpCode >= 500 || httpCode == 404 -> Failure.SERVER_DOWN + else -> Failure.REJECTED + } + + private val MONTHS = listOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec") + /* charon: «Nov 8 14:02:11 06[IKE] ...» (strftime %b %e, C locale) */ + private val CHARON_DATE = Regex("""^(\w{3})\s+(\d{1,2})\s+\d{2}:\d{2}:\d{2}\s""") + + /** + * Lines of [month] (1-12) / [day] only; lines without a date stay with the dated line before them. + * When nothing is from today the whole text is kept: an old log still helps support. + */ + fun todayOnly(text: String, month: Int, day: Int): String + { + var keep = true + var today = false + val out = StringBuilder() + for (line in text.lineSequence()) + { + CHARON_DATE.find(line)?.let { m -> + keep = MONTHS.indexOf(m.groupValues[1]) + 1 == month && m.groupValues[2].toInt() == day + today = today || keep + } + if (keep && line.isNotEmpty()) + { + out.append(line).append('\n') + } + } + /* undated lines alone (e.g. the cut mark of readTail) do not count as today's log */ + return if (today) out.toString() else text + } + + /** The last [maxBytes] of UTF-8 [text], cut at a line start; the cut is marked */ + fun tailBytes(text: String, maxBytes: Int): String + { + val bytes = text.toByteArray(Charsets.UTF_8) + if (bytes.size <= maxBytes) + { + return text + } + var start = bytes.size - maxBytes + /* '\n' is never part of a multi-byte UTF-8 sequence, so cutting after it keeps characters whole */ + while (start < bytes.size && bytes[start - 1] != '\n'.code.toByte()) + { + start++ + } + if (start == bytes.size) + { /* one huge line: cut anywhere, but not inside a character (skip 10xxxxxx continuation bytes) */ + start = bytes.size - maxBytes + while (start < bytes.size && (bytes[start].toInt() and 0xC0) == 0x80) + { + start++ + } + } + return CUT_MARK + String(bytes, start, bytes.size - start, Charsets.UTF_8) + } + + /** Up to [maxBytes] from the end of [file] ("" when missing); a partial first line is dropped */ + fun readTail(file: File, maxBytes: Int): String + { + if (!file.exists()) + { + return "" + } + return try + { + RandomAccessFile(file, "r").use { f -> + val from = maxOf(0L, f.length() - maxBytes) + val buf = ByteArray((f.length() - from).toInt()) + f.seek(from) + f.readFully(buf) + val text = String(buf, Charsets.UTF_8) + if (from > 0) CUT_MARK + text.substringAfter('\n') else text + } + } + catch (e: Exception) + { + "" + } + } + + /** One network check of the diagnostics screen: the panel shows them as a check list */ + data class Check(val title: String, val value: String, val ok: Boolean?) + + /** JSON array of the checks (no org.json: this object stays testable without Android) */ + fun checksJson(checks: List): String = checks.joinToString(",", "[", "]") { c -> + "{\"title\":${jsonString(c.title)},\"value\":${jsonString(c.value)},\"ok\":${c.ok?.toString() ?: "null"}}" + } + + private fun jsonString(s: String): String = buildString { + append('"') + for (ch in s) + { + when + { + ch == '"' -> append("\\\"") + ch == '\\' -> append("\\\\") + ch == '\n' -> append("\\n") + ch == '\r' -> append("\\r") + ch == '\t' -> append("\\t") + ch < ' ' -> append("\\u%04x".format(ch.code)) + else -> append(ch) + } + } + append('"') + } + + /** The report text: the connection log, the network checks and diag.log when asked for */ + fun build(charon: String, diag: String?, checks: List? = null): String = buildString { + append("=== charon.log ===\n") + append(tailBytes(charon, CHARON_MAX_BYTES).ifBlank { "(empty)\n" }) + if (!checks.isNullOrEmpty()) + { + append("\n=== checks.json ===\n") + append(checksJson(checks)).append('\n') + } + if (diag != null) + { + append("\n=== diag.log ===\n") + append(tailBytes(diag, DIAG_MAX_BYTES).ifBlank { "(empty)\n" }) + } + } + + /** Meta of a report from the log screen; [network] is "Wi-Fi", "mobile" or "none" */ + fun meta( + versionName: String, versionCode: Long, manufacturer: String, model: String, release: String, sdk: Int, + network: String, comment: String, profile: String, + ) = ReportUploader.Meta( + versionName = versionName, + versionCode = versionCode, + device = if (model.startsWith(manufacturer, ignoreCase = true)) model else "$manufacturer $model", + os = "Android $release (API $sdk)", + network = network, + source = "log", + comment = comment.trim(), + profile = profile, + ) +} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/updates/ReportUploader.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/updates/ReportUploader.kt new file mode 100644 index 000000000..a9b257d66 --- /dev/null +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/updates/ReportUploader.kt @@ -0,0 +1,139 @@ +/* + * Copyright (C) 2026 SHX VPN + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the + * Free Software Foundation; either version 2 of the License, or (at your + * option) any later version. See . + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + */ + +package org.strongswan.android.logic.updates + +import org.json.JSONObject +import java.io.ByteArrayOutputStream +import java.net.HttpURLConnection +import java.net.URL +import java.util.zip.GZIPOutputStream + +/** + * Sends a diagnostic log to the web panel (POST /api/v1/reports) and returns the report + * code the user can quote to support. The request is signed like the update check. + * The "Send report" sheet of the log screen (LogActivity) calls it, see [ReportContent]. + */ +class ReportUploader( + private val baseUrl: String, + private val hmacKey: String, + private val installId: String, + private val clock: () -> Long = { System.currentTimeMillis() }, +) +{ + data class Meta( + val versionName: String, + val versionCode: Long, + val device: String, + val os: String, + val network: String, + val source: String, + val comment: String, + val profile: String, + ) + + sealed class Result + { + data class Sent(val code: String) : Result() + data class Failed(val httpCode: Int, val detail: String) : Result() + } + + companion object + { + /** Server limit: the whole request body must stay below 2 MB */ + const val MAX_BYTES = 2 * 1024 * 1024 + const val BOUNDARY = "----shxreport" + private val CODE = Regex("^SHX-[0-9A-Z]{5}$") + + /** The panel serves the app API under /api/v1 (the proxy routes only that prefix); the build may give just the host */ + fun apiBase(url: String): String + { + val base = url.trim().trimEnd('/') + return if (base.endsWith("/api/v1")) base else "$base/api/v1" + } + + /** 201 with {"code":"SHX-XXXXX"} is the only success; a broken 201 is not "no network" */ + fun parseResponse(httpCode: Int, body: String): Result + { + if (httpCode != 201) + { + return Result.Failed(httpCode, body.take(200)) + } + val code = try { JSONObject(body).optString("code", "") } catch (e: Exception) { "" } + return if (CODE.matches(code)) Result.Sent(code) else Result.Failed(httpCode, "bad response: ${body.take(100)}") + } + + fun gzip(text: String): ByteArray + { + val out = ByteArrayOutputStream() + GZIPOutputStream(out).use { it.write(text.toByteArray(Charsets.UTF_8)) } + return out.toByteArray() + } + + fun metaJson(m: Meta): String = JSONObject() + .put("versionName", m.versionName).put("versionCode", m.versionCode) + .put("device", m.device.take(120)).put("os", m.os.take(60)).put("network", m.network.take(40)) + .put("source", m.source).put("comment", m.comment.take(1000)).put("profile", m.profile.take(200)) + .toString() + + /** multipart/form-data with the gzip log and the meta JSON */ + fun multipartBody(logGz: ByteArray, meta: String): ByteArray + { + val out = ByteArrayOutputStream() + fun w(s: String) = out.write(s.toByteArray(Charsets.UTF_8)) + w("--$BOUNDARY\r\nContent-Disposition: form-data; name=\"log\"; filename=\"log.gz\"\r\nContent-Type: application/gzip\r\n\r\n") + out.write(logGz) + /* a plain text field: @fastify/multipart turns an application/json field into an object and the server then reads "[object Object]" */ + w("\r\n--$BOUNDARY\r\nContent-Disposition: form-data; name=\"meta\"\r\n\r\n") + w(meta) + w("\r\n--$BOUNDARY--\r\n") + return out.toByteArray() + } + } + + fun send(logText: String, meta: Meta): Result + { + val body = multipartBody(gzip(logText), metaJson(meta)) + if (body.size > MAX_BYTES) + { + return Result.Failed(413, "report is larger than 2 MB") + } + val url = apiBase(baseUrl) + "/reports" + val ts = clock() / 1000 + val conn = URL(url).openConnection() as HttpURLConnection + return try + { + conn.requestMethod = "POST" + conn.doOutput = true + conn.connectTimeout = 10_000 + conn.readTimeout = 20_000 + conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=$BOUNDARY") + conn.setRequestProperty("X-Install-Id", installId) + conn.setRequestProperty("X-Timestamp", ts.toString()) + conn.setRequestProperty("X-Signature", UpdateSigning.hmacBase64(hmacKey, UpdateSigning.requestInput("POST", URL(url).path, ts))) + conn.outputStream.use { it.write(body) } + val code = conn.responseCode + val text = (if (code in 200..299) conn.inputStream else conn.errorStream)?.use { it.readBytes().decodeToString() } ?: "" + parseResponse(code, text) + } + catch (e: Exception) + { + Result.Failed(0, e.message ?: e.javaClass.simpleName) + } + finally + { + conn.disconnect() + } + } +} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/updates/UpdateApi.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/updates/UpdateApi.kt new file mode 100644 index 000000000..08f924a0f --- /dev/null +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/updates/UpdateApi.kt @@ -0,0 +1,254 @@ +/* + * Copyright (C) 2026 SHX VPN + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the + * Free Software Foundation; either version 2 of the License, or (at your + * option) any later version. See . + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + */ + +package org.strongswan.android.logic.updates + +import net.i2p.crypto.eddsa.EdDSAEngine +import net.i2p.crypto.eddsa.EdDSAPublicKey +import net.i2p.crypto.eddsa.spec.EdDSANamedCurveTable +import net.i2p.crypto.eddsa.spec.EdDSAPublicKeySpec +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL +import java.net.URLEncoder +import java.security.MessageDigest +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** + * Client of the SHX update API (/api/v1/updates/check). + * + * The wire contract is frozen in the web panel repository: docs/openapi.v1.json + * and packages/contracts/vectors/signing.json. [UpdateSigning] builds exactly + * the strings the server signs; UpdateApiTest checks it against those vectors. + */ +@OptIn(ExperimentalEncodingApi::class) +object UpdateSigning +{ + const val SIGNING_VERSION = "shx-manifest-v1" + + /** The fields covered by the manifest signature (notes are display-only and not signed) */ + data class Fields( + val update: Boolean, + val mandatory: Boolean, + val versionCode: Long, + val versionName: String, + val sha256: String, + val size: Long, + val apkUrl: String, + val issuedAt: Long, + val checkHours: Int, + val wifiOnly: Boolean, + ) + + /** Fields joined by '\n' in a strict order; absent values are 0 / empty (same as the server) */ + fun manifestInput(f: Fields): String + { + return listOf( + SIGNING_VERSION, + if (f.update) "1" else "0", + if (f.mandatory) "1" else "0", + f.versionCode.toString(), + f.versionName, + f.sha256, + f.size.toString(), + f.apkUrl, + f.issuedAt.toString(), + f.checkHours.toString(), + if (f.wifiOnly) "1" else "0", + ).joinToString("\n") + } + + fun requestInput(method: String, path: String, timestamp: Long): String + { + return "${method.uppercase()}|$path|$timestamp" + } + + fun hmacBase64(key: String, input: String): String + { + val mac = Mac.getInstance("HmacSHA256") + mac.init(SecretKeySpec(key.toByteArray(Charsets.UTF_8), "HmacSHA256")) + return Base64.Default.encode(mac.doFinal(input.toByteArray(Charsets.UTF_8))) + } + + /** + * Ed25519 verification on API 24+ (java.security Ed25519 exists only from API 33), + * therefore a small pure-Java implementation is used + */ + fun verifyEd25519(publicKeyRaw: ByteArray, input: String, signatureBase64: String): Boolean + { + return try + { + val spec = EdDSAPublicKeySpec(publicKeyRaw, EdDSANamedCurveTable.getByName(EdDSANamedCurveTable.ED_25519)) + val engine = EdDSAEngine(MessageDigest.getInstance("SHA-512")) + engine.initVerify(EdDSAPublicKey(spec)) + engine.update(input.toByteArray(Charsets.UTF_8)) + engine.verify(Base64.Default.decode(signatureBase64)) + } + catch (e: Exception) + { + false + } + } +} + +/** Where the HTTP is delegated to, so the client can be tested without a network */ +interface UpdateHttp +{ + data class Response(val code: Int, val body: String) + + fun get(url: String, headers: Map): Response +} + +class HttpUrlConnectionHttp(private val timeoutMs: Int = 10_000) : UpdateHttp +{ + override fun get(url: String, headers: Map): UpdateHttp.Response + { + val conn = URL(url).openConnection(java.net.Proxy.NO_PROXY) as HttpURLConnection + conn.connectTimeout = timeoutMs + conn.readTimeout = timeoutMs + conn.setRequestProperty("Connection", "close") + conn.setRequestProperty("Cache-Control", "no-cache") + for ((k, v) in headers) + { + conn.setRequestProperty(k, v) + } + return try + { + val code = conn.responseCode + val stream = if (code in 200..299) conn.inputStream else conn.errorStream + UpdateHttp.Response(code, stream?.use { it.readBytes().decodeToString() } ?: "") + } + finally + { + conn.disconnect() + } + } +} + +/** Policy delivered inside the signed manifest; the user's own settings stay the default */ +data class UpdatePolicy(val checkHours: Int, val wifiOnly: Boolean) + +sealed class UpdateApiResult +{ + data class Update(val info: UpdateInfo, val mandatory: Boolean, val policy: UpdatePolicy, val issuedAt: Long) : UpdateApiResult() + + data class NoUpdate(val policy: UpdatePolicy) : UpdateApiResult() + + /** [kind]: NETWORK (try the legacy path), SIGNATURE (never fall back), OTHER (rate limit, bad request) */ + data class Failure(val kind: UpdateError, val detail: String) : UpdateApiResult() +} + +@OptIn(ExperimentalEncodingApi::class) +class UpdateApiClient( + private val baseUrl: String, + private val hmacKey: String, + private val publicKeyBase64: String, + private val installId: String, + private val http: UpdateHttp = HttpUrlConnectionHttp(), + private val clock: () -> Long = { System.currentTimeMillis() }, +) +{ + companion object + { + /** The manifest is rejected when its issue time is far from the device clock (replay of a stale "no update") */ + const val MAX_SKEW_SECONDS = 6 * 3600L + + fun isConfigured(baseUrl: String, hmacKey: String, publicKey: String): Boolean + { + return baseUrl.isNotBlank() && hmacKey.isNotBlank() && publicKey.isNotBlank() + } + } + + fun check(packageId: String, versionCode: Long, channel: String, abi: String?, sdk: Int, betaToken: String?): UpdateApiResult + { + val path = URL(ReportUploader.apiBase(baseUrl) + "/updates/check").path + val query = buildString { + append("package=").append(enc(packageId)) + append("&versionCode=").append(versionCode) + append("&channel=").append(enc(channel)) + if (!abi.isNullOrBlank()) append("&abi=").append(enc(abi)) + append("&sdk=").append(sdk) + if (!betaToken.isNullOrBlank()) append("&betaToken=").append(enc(betaToken)) + } + val ts = clock() / 1000 + val headers = mapOf( + "X-Install-Id" to installId, + "X-Timestamp" to ts.toString(), + "X-Signature" to UpdateSigning.hmacBase64(hmacKey, UpdateSigning.requestInput("GET", path, ts)), + ) + val response = try + { + http.get(ReportUploader.apiBase(baseUrl) + "/updates/check?" + query, headers) + } + catch (e: Exception) + { + return UpdateApiResult.Failure(UpdateError.NETWORK, e.message ?: e.javaClass.simpleName) + } + return when + { + response.code in 500..599 || response.code == 404 -> UpdateApiResult.Failure(UpdateError.NETWORK, "HTTP ${response.code}") + response.code != 200 -> UpdateApiResult.Failure(UpdateError.OTHER, "HTTP ${response.code}") + else -> parse(response.body) + } + } + + private fun parse(body: String): UpdateApiResult + { + val json = try { JSONObject(body) } catch (e: Exception) { return UpdateApiResult.Failure(UpdateError.OTHER, "bad json") } + val update = json.optBoolean("update", false) + val fields = UpdateSigning.Fields( + update = update, + mandatory = json.optBoolean("mandatory", false), + versionCode = if (update) json.optLong("versionCode", 0L) else 0L, + versionName = if (update) json.optString("versionName", "") else "", + sha256 = if (update) json.optString("sha256", "").lowercase() else "", + size = if (update) json.optLong("size", 0L) else 0L, + apkUrl = if (update) json.optString("apkUrl", "") else "", + issuedAt = json.optLong("issuedAt", 0L), + checkHours = json.optInt("checkHours", 12), + wifiOnly = json.optBoolean("wifiOnly", true), + ) + val key = try { Base64.Default.decode(publicKeyBase64) } catch (e: Exception) { ByteArray(0) } + if (key.size != 32 || !UpdateSigning.verifyEd25519(key, UpdateSigning.manifestInput(fields), json.optString("signature", ""))) + { + return UpdateApiResult.Failure(UpdateError.SIGNATURE, "manifest signature mismatch") + } + val skew = Math.abs(clock() / 1000 - fields.issuedAt) + if (skew > MAX_SKEW_SECONDS) + { + return UpdateApiResult.Failure(UpdateError.SIGNATURE, "manifest is too old or the device clock is off ($skew s)") + } + val policy = UpdatePolicy(fields.checkHours, fields.wifiOnly) + if (!update) + { + return UpdateApiResult.NoUpdate(policy) + } + if (fields.apkUrl.isBlank() || fields.sha256.length != 64) + { + return UpdateApiResult.Failure(UpdateError.OTHER, "incomplete manifest") + } + val notes = HashMap() + json.optJSONObject("notes")?.let { n -> for (k in n.keys()) notes[k.lowercase()] = n.optString(k, "") } + val info = UpdateInfo( + versionCode = fields.versionCode, versionName = fields.versionName, apkUrl = fields.apkUrl, + sha256 = fields.sha256, size = fields.size, notes = notes, fromApi = true, issuedAt = fields.issuedAt, + ) + return UpdateApiResult.Update(info, fields.mandatory, policy, fields.issuedAt) + } + + private fun enc(s: String) = URLEncoder.encode(s, "UTF-8") +} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/updates/UpdateManager.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/updates/UpdateManager.kt index 47ac73a4e..e9a603eb3 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/updates/UpdateManager.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/updates/UpdateManager.kt @@ -62,6 +62,7 @@ import java.net.Proxy import java.net.URL import java.security.MessageDigest import java.util.concurrent.TimeUnit +import kotlin.coroutines.cancellation.CancellationException /** Categories of update failures, mapped to localized messages by the UI */ enum class UpdateError { NETWORK, HASH, SIGNATURE, INSTALL, OTHER } @@ -76,6 +77,10 @@ data class UpdateInfo( val sha256: String, val size: Long, val notes: Map, + /* set for manifests from the SHX update API: the file URL is presigned and short-lived */ + val fromApi: Boolean = false, + val issuedAt: Long = 0L, + val mandatory: Boolean = false, ) sealed class UpdateState @@ -117,6 +122,17 @@ class UpdateManager private constructor(private val context: Context) private const val UPDATES_CHANNEL = "org.strongswan.android.updates" private const val UPDATES_NOTIFICATION_ID = 1001 private const val UPDATES_PROGRESS_NOTIFICATION_ID = 1002 + private const val PREF_INSTALL_ID = "pref_update_install_id" + private const val PREF_UPDATE_CHANNEL = "pref_update_channel" + private const val PREF_BETA_TOKEN = "pref_update_beta_token" + private const val PREF_API_CHECK_HOURS = "pref_update_api_check_hours" + private const val PREF_API_WIFI_ONLY = "pref_update_api_wifi_only" + /* a mandatory update the last check found: keeps the blocking sheet up across restarts and failed checks */ + private const val PREF_MANDATORY_CODE = "pref_update_mandatory_code" + private const val PREF_MANDATORY_NAME = "pref_update_mandatory_name" + private const val PREF_MANDATORY_SIZE = "pref_update_mandatory_size" + /* refresh the manifest when it is older than this before downloading (the URL lives 10 minutes) */ + private const val URL_MAX_AGE_SECONDS = 8 * 60L @Volatile private var mInstance: UpdateManager? = null @@ -166,6 +182,11 @@ class UpdateManager private constructor(private val context: Context) * was known in this process: install as soon as the check finds it */ @Volatile private var mInstallPending = false + /* set by cancelDownload(); the copy loop and the install step check it */ + @Volatile + private var mCancelRequested = false + @Volatile + private var mDownloadConn: HttpURLConnection? = null private var mVpnService: VpnStateService? = null private val mConnection = object : ServiceConnection @@ -191,7 +212,8 @@ class UpdateManager private constructor(private val context: Context) { cleanup() context.bindService(Intent(context, VpnStateService::class.java), mConnection, Context.BIND_AUTO_CREATE) - val request = PeriodicWorkRequestBuilder(12, TimeUnit.HOURS) + /* the interval comes from the server policy (signed manifest), 12 h until it is known */ + val request = PeriodicWorkRequestBuilder(checkIntervalHours(), TimeUnit.HOURS) .setConstraints(Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) /* background checks only run while charging - zero battery impact */ @@ -224,7 +246,7 @@ class UpdateManager private constructor(private val context: Context) return null } val last = mPrefs.getLong(Constants.PREF_LAST_UPDATE_CHECK, 0L) - if (System.currentTimeMillis() - last >= Constants.UPDATE_CHECK_INTERVAL_MS) + if (System.currentTimeMillis() - last >= TimeUnit.HOURS.toMillis(checkIntervalHours())) { return check() } @@ -244,6 +266,57 @@ class UpdateManager private constructor(private val context: Context) check() } + /** True while the update channel is "beta" */ + fun isBetaChannel(): Boolean = mPrefs.getString(PREF_UPDATE_CHANNEL, "release") == "beta" + + /** Switches the channel (leaving beta forgets the invitation code) and checks right away */ + fun setChannel(beta: Boolean, token: String?) + { + val edit = mPrefs.edit().putString(PREF_UPDATE_CHANNEL, if (beta) "beta" else "release") + if (beta) edit.putString(PREF_BETA_TOKEN, token?.trim()) else edit.remove(PREF_BETA_TOKEN) + edit.apply() + check() + } + + /** Version name and size of a mandatory update newer than the installed app, or null */ + fun pendingMandatory(): Pair? + { + if (mPrefs.getLong(PREF_MANDATORY_CODE, 0L) <= currentVersionCode()) + { + return null + } + return (mPrefs.getString(PREF_MANDATORY_NAME, null) ?: "") to mPrefs.getLong(PREF_MANDATORY_SIZE, -1L) + } + + /** Interrupts a running download, deletes the partial file and goes back to Idle */ + @Synchronized + fun cancelDownload() + { + if (mDownloadJob?.isActive != true) + { + return + } + mInstallPending = false + mCancelRequested = true + mDownloadJob?.cancel() + /* unblocks a read waiting for the network */ + try + { + mDownloadConn?.disconnect() + } + catch (_: Exception) + { + } + } + + private fun onDownloadCancelled() + { + DiagLog.i(TAG, "update: download cancelled") + cleanup() + cancelDownloadProgress() + mState.value = UpdateState.Idle + } + @Synchronized private fun check(): Job? { @@ -257,7 +330,12 @@ class UpdateManager private constructor(private val context: Context) mInstallPending = false return@launch } - mPrefs.edit().putLong(Constants.PREF_LAST_UPDATE_CHECK, System.currentTimeMillis()).apply() + val mandatory = info.mandatory && info.versionCode > currentVersionCode() + mPrefs.edit().putLong(Constants.PREF_LAST_UPDATE_CHECK, System.currentTimeMillis()) + .putLong(PREF_MANDATORY_CODE, if (mandatory) info.versionCode else 0L) + .putString(PREF_MANDATORY_NAME, info.versionName) + .putLong(PREF_MANDATORY_SIZE, info.size) + .apply() if (info.versionCode > currentVersionCode()) { DiagLog.i(TAG, "update: available ${info.versionName} (${info.versionCode}), " + @@ -300,7 +378,8 @@ class UpdateManager private constructor(private val context: Context) DiagLog.i(TAG, "update: silent download disabled, waiting for manual install") return } - if (isNetworkMetered()) + /* server policy "Wi-Fi only" (default true); a policy of false allows the silent download on metered networks too */ + if (mPrefs.getBoolean(PREF_API_WIFI_ONLY, true) && isNetworkMetered()) { DiagLog.i(TAG, "update: silent download deferred (network metered)") return @@ -309,12 +388,17 @@ class UpdateManager private constructor(private val context: Context) { return } + mCancelRequested = false mDownloadJob = mScope.launch { try { DiagLog.i(TAG, "update: downloading ${info.versionName} (${info.size} B)") val apk = downloadApk(info) verifySignature(apk) + if (mCancelRequested) + { + throw CancellationException("update download cancelled") + } if (isVpnDown() && canInstallSilently()) { Log.i(TAG, "Applying the downloaded update silently") @@ -340,6 +424,10 @@ class UpdateManager private constructor(private val context: Context) } } } + catch (e: CancellationException) + { + onDownloadCancelled() + } catch (e: Exception) { Log.e(TAG, "Silent update download failed", e) @@ -350,6 +438,9 @@ class UpdateManager private constructor(private val context: Context) } } + /** Check interval from the server policy, kept between 1 hour and a week */ + fun checkIntervalHours(): Long = mPrefs.getInt(PREF_API_CHECK_HOURS, 12).coerceIn(1, 168).toLong() + private fun isSilentUpdatesEnabled(): Boolean { return mPrefs.getBoolean(Constants.PREF_SILENT_UPDATES, false) @@ -408,7 +499,7 @@ class UpdateManager private constructor(private val context: Context) val pending = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) val notification = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) { - Notification.Builder(context, UPDATES_CHANNEL) + val builder = Notification.Builder(context, UPDATES_CHANNEL) .setSmallIcon(R.drawable.ic_notification) .setContentTitle(title) .setContentText("$progress%") @@ -416,11 +507,16 @@ class UpdateManager private constructor(private val context: Context) .setStyle(Notification.ProgressStyle() .setProgressSegments(listOf(Notification.ProgressStyle.Segment(100))) .setProgress(progress)) - .setRequestPromotedOngoing(true) .setOngoing(true) .setOnlyAlertOnce(true) .setContentIntent(pending) - .build() + /* setRequestPromotedOngoing appeared in Android 16 QPR2 (API 36.1): on 36.0 it is a + * NoSuchMethodError that killed the app as soon as an update download started */ + if (Build.VERSION.SDK_INT_FULL >= Build.VERSION_CODES_FULL.BAKLAVA_1) + { + builder.setRequestPromotedOngoing(true) + } + builder.build() } else { @@ -519,16 +615,25 @@ class UpdateManager private constructor(private val context: Context) check() return } + mCancelRequested = false mDownloadJob = mScope.launch { try { DiagLog.i(TAG, "update: downloading ${info.versionName} (manual)") val apk = downloadApk(info) verifySignature(apk) + if (mCancelRequested) + { + throw CancellationException("update download cancelled") + } DiagLog.i(TAG, "update: installing ${info.versionName} (manual)") UpdateInstaller.install(context, apk) mState.value = UpdateState.Idle } + catch (e: CancellationException) + { + onDownloadCancelled() + } catch (e: UpdateException) { Log.e(TAG, "Update failed: ${e.kind}", e) @@ -560,6 +665,26 @@ class UpdateManager private constructor(private val context: Context) private fun fetchManifest(): UpdateInfo? { + if (apiConfigured()) + { + when (val r = callApi()) + { + is UpdateApiResult.Update -> return r.info.copy(mandatory = r.mandatory) + is UpdateApiResult.NoUpdate -> return upToDateMarker() + is UpdateApiResult.Failure -> + { + /* only a network or server failure falls back to the legacy bucket path; a bad + * signature or a rate limit must not be bypassed by asking the bucket */ + if (r.kind != UpdateError.NETWORK || !legacyConfigured()) + { + DiagLog.i(TAG, "update: API check failed: ${r.kind} ${r.detail}") + mState.value = UpdateState.Failed(r.kind, r.detail) + return null + } + DiagLog.i(TAG, "update: API unreachable (${r.detail}), using the legacy manifest") + } + } + } try { val conn = openConnection(getBaseUrl() + Constants.UPDATE_MANIFEST_FILE) @@ -592,6 +717,58 @@ class UpdateManager private constructor(private val context: Context) } } + /** also signs report uploads (ReportUploader) */ + internal fun apiHmacKey(): String = decodeUpdateSecret(BuildConfig.UPDATE_API_HMAC_KEY) + + private fun apiConfigured(): Boolean = + UpdateApiClient.isConfigured(BuildConfig.UPDATE_API_URL, apiHmacKey(), BuildConfig.UPDATE_API_PUBLIC_KEY) + + /* the legacy path needs the bucket URL; without it there is nothing to fall back to */ + private fun legacyConfigured(): Boolean = BuildConfig.UPDATE_BASE_URL.isNotBlank() + + /** Stable random installation id (no personal data); the server stores only its hash */ + internal fun installId(): String + { + mPrefs.getString(PREF_INSTALL_ID, null)?.let { return it } + val bytes = ByteArray(18).also { java.security.SecureRandom().nextBytes(it) } + val id = android.util.Base64.encodeToString(bytes, android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP or android.util.Base64.NO_PADDING) + mPrefs.edit().putString(PREF_INSTALL_ID, id).apply() + return id + } + + private fun callApi(): UpdateApiResult + { + val client = UpdateApiClient(BuildConfig.UPDATE_API_URL, apiHmacKey(), BuildConfig.UPDATE_API_PUBLIC_KEY, installId()) + val abi = Build.SUPPORTED_ABIS.firstOrNull() + val result = client.check(context.packageName, currentVersionCode(), mPrefs.getString(PREF_UPDATE_CHANNEL, "release") ?: "release", + abi, Build.VERSION.SDK_INT, mPrefs.getString(PREF_BETA_TOKEN, null)) + val policy = when (result) + { + is UpdateApiResult.Update -> result.policy + is UpdateApiResult.NoUpdate -> result.policy + else -> null + } + /* server hints are kept for the scheduler; the user's own settings stay in charge */ + policy?.let { mPrefs.edit().putInt(PREF_API_CHECK_HOURS, it.checkHours).putBoolean(PREF_API_WIFI_ONLY, it.wifiOnly).apply() } + return result + } + + /** An UpdateInfo that sends check() into the "up to date" branch */ + private fun upToDateMarker() = UpdateInfo(currentVersionCode(), currentVersionName(), "", "", -1L, emptyMap(), fromApi = true) + + /** + * The presigned file URL lives ~10 minutes while a download may wait for Wi-Fi or charging: + * ask the API again when the manifest is old + */ + private fun freshen(info: UpdateInfo, force: Boolean = false): UpdateInfo + { + if (!info.fromApi || info.apkUrl.isBlank()) return info + val age = System.currentTimeMillis() / 1000 - info.issuedAt + if (!force && age in 0..URL_MAX_AGE_SECONDS) return info + val r = callApi() + return if (r is UpdateApiResult.Update && r.info.versionCode == info.versionCode && r.info.sha256 == info.sha256) r.info.copy(mandatory = r.mandatory) else info + } + private fun parseNotes(notes: JSONObject?): Map { if (notes == null) @@ -606,8 +783,9 @@ class UpdateManager private constructor(private val context: Context) return result } - private fun downloadApk(info: UpdateInfo): File + private fun downloadApk(original: UpdateInfo, retried: Boolean = false): File { + val info = freshen(original, force = retried) val dir = File(context.filesDir, UPDATE_DIR) if (!dir.exists() && !dir.mkdirs()) { @@ -620,36 +798,32 @@ class UpdateManager private constructor(private val context: Context) } apk.delete() - val conn = openConnection(info.apkUrl) + /* a presigned URL already carries its own signature: adding S3 auth headers makes S3 reject it */ + val conn = openConnection(info.apkUrl, signS3 = !info.fromApi) conn.readTimeout = 30_000 try { val code = conn.responseCode + if (code == HttpURLConnection.HTTP_FORBIDDEN && info.fromApi && !retried) + { /* the link expired while waiting: fetch a fresh one once */ + conn.disconnect() + return downloadApk(original, retried = true) + } if (code != HttpURLConnection.HTTP_OK) { throw UpdateException(UpdateError.NETWORK, "HTTP $code") } val expected = if (info.size > 0) info.size else -1L val digest = MessageDigest.getInstance("SHA-256") - val buffer = ByteArray(64 * 1024) - var downloaded = 0L var lastProgress = -1 - conn.inputStream.use { input -> + mDownloadConn = conn + val downloaded = conn.inputStream.use { input -> FileOutputStream(apk).use { output -> - while (true) - { - val len = input.read(buffer) - if (len < 0) - { - break - } - output.write(buffer, 0, len) - digest.update(buffer, 0, len) - downloaded += len + copyDownload(input, output, digest, { mCancelRequested }) { done -> if (expected > 0) { - val progress = (downloaded * 100 / expected).toInt().coerceIn(0, 100) + val progress = (done * 100 / expected).toInt().coerceIn(0, 100) if (progress != lastProgress) { lastProgress = progress @@ -674,6 +848,7 @@ class UpdateManager private constructor(private val context: Context) } finally { + mDownloadConn = null conn.disconnect() cancelDownloadProgress() } @@ -771,14 +946,17 @@ class UpdateManager private constructor(private val context: Context) } } - private fun openConnection(url: String): HttpURLConnection + private fun openConnection(url: String, signS3: Boolean = true): HttpURLConnection { val conn = URL(url).openConnection(Proxy.NO_PROXY) as HttpURLConnection conn.connectTimeout = CONNECT_TIMEOUT_MS conn.readTimeout = READ_TIMEOUT_MS conn.setRequestProperty("Connection", "close") conn.setRequestProperty("Cache-Control", "no-cache") - applyS3Auth(conn, url) + if (signS3) + { + applyS3Auth(conn, url) + } return conn } diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/updates/UpdateNotes.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/updates/UpdateNotes.kt new file mode 100644 index 000000000..c6ea4767b --- /dev/null +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/updates/UpdateNotes.kt @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2026 SHX VPN + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the + * Free Software Foundation; either version 2 of the License, or (at your + * option) any later version. See . + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + */ + +package org.strongswan.android.logic.updates + +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.security.MessageDigest +import kotlin.coroutines.cancellation.CancellationException + +/** + * Release notes of the app language: the full tag ("zh-tw"), then the language ("zh"), + * then Russian, then English. Keys of [notes] are lowercase (see UpdateManager.parseNotes). + */ +fun pickNotes(notes: Map, languageTag: String, language: String): String? +{ + return listOf(languageTag.lowercase(), language.lowercase(), "ru", "en") + .firstNotNullOfOrNull { key -> notes[key]?.takeIf { it.isNotBlank() } } +} + +/** Every non-empty line of the notes is one list item; a leading "-", "*" or "•" is dropped. */ +fun noteLines(text: String?): List +{ + return text.orEmpty().lines().map { it.trim().trimStart('-', '*', '•').trim() }.filter { it.isNotEmpty() } +} + +/** + * Copies the download into [output] and the digest; [isCancelled] is checked between reads and + * when a read fails (cancelling disconnects the connection, which breaks a blocked read). + */ +internal fun copyDownload(input: InputStream, output: OutputStream, digest: MessageDigest, + isCancelled: () -> Boolean, onProgress: (Long) -> Unit): Long +{ + val buffer = ByteArray(64 * 1024) + var total = 0L + while (true) + { + if (isCancelled()) + { + throw CancellationException("update download cancelled") + } + val len = try + { + input.read(buffer) + } + catch (e: IOException) + { + if (isCancelled()) throw CancellationException("update download cancelled") else throw e + } + if (len < 0) + { + return total + } + output.write(buffer, 0, len) + digest.update(buffer, 0, len) + total += len + onProgress(total) + } +} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/LogActivity.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/LogActivity.kt index 5269299e5..f18c4c307 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/LogActivity.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/LogActivity.kt @@ -9,7 +9,11 @@ package org.strongswan.android.ui +import android.content.ComponentName +import android.content.Context import android.content.Intent +import android.content.ServiceConnection +import android.os.IBinder import android.content.pm.PackageManager import android.os.Bundle import android.widget.Toast @@ -77,6 +81,7 @@ import org.strongswan.android.R import org.strongswan.android.data.LogContentProvider import org.strongswan.android.logic.CharonVpnService import org.strongswan.android.logic.DiagLog +import org.strongswan.android.logic.VpnStateService import org.strongswan.android.ui.compose.SecondaryScaffold import org.strongswan.android.ui.compose.theme.ShxTheme import java.io.BufferedReader @@ -87,18 +92,49 @@ class LogActivity : AppCompatActivity() { private val messages = kotlinx.coroutines.flow.MutableSharedFlow(extraBufferCapacity = 1) + /* the active profile goes into the report meta */ + private var vpn: VpnStateService? = null + private val connection = object : ServiceConnection + { + override fun onServiceConnected(name: ComponentName?, binder: IBinder?) + { + vpn = (binder as VpnStateService.LocalBinder).service + } + + override fun onServiceDisconnected(name: ComponentName?) + { + vpn = null + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() + bindService(Intent(this, VpnStateService::class.java), connection, Context.BIND_AUTO_CREATE) setContent { ShxTheme { - LogScreen(onBack = { finish() }, onSend = { sendLog() }, messages = messages) + val report: ReportViewModel = viewModel() + /* without the panel API in this build the report goes by email, as before */ + LogScreen(onBack = { finish() }, onSend = { if (ReportViewModel.apiReady(this)) report.open() else sendLog("") }, messages = messages) + ReportSheet(report, profile = ::activeProfile, onMail = { comment -> report.close(); sendLog(comment) }) } } } - private fun sendLog() + override fun onDestroy() + { + unbindService(connection) + super.onDestroy() + } + + private fun activeProfile(): String + { + val profile = vpn?.profile ?: return "" + return listOfNotNull(profile.name, profile.gateway).filter { it.isNotBlank() }.distinct().joinToString(" · ") + } + + private fun sendLog(comment: String) { /* the daemon log plus the lifecycle log; the latter survives process * deaths and documents watchdog/reconnect decisions */ @@ -124,6 +160,10 @@ class LogActivity : AppCompatActivity() intent.putExtra(Intent.EXTRA_EMAIL, arrayOf(MainActivity.CONTACT_EMAIL)) intent.putExtra(Intent.EXTRA_SUBJECT, getString(R.string.log_mail_subject, version)) intent.type = "text/plain" + if (comment.isNotBlank()) + { + intent.putExtra(Intent.EXTRA_TEXT, comment) + } if (uris.size > 1) { intent.putExtra(Intent.EXTRA_STREAM, uris) diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/ReportSheet.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/ReportSheet.kt new file mode 100644 index 000000000..b4e36a6b6 --- /dev/null +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/ReportSheet.kt @@ -0,0 +1,380 @@ +/* + * Copyright (C) 2026 SHX VPN + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the + * Free Software Foundation; either version 2 of the License, or (at your + * option) any later version. See . + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + */ + +package org.strongswan.android.ui + +import android.app.Application +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.os.Build +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import org.strongswan.android.ui.compose.runDiagnostics +import org.strongswan.android.BuildConfig +import org.strongswan.android.R +import org.strongswan.android.logic.CharonVpnService +import org.strongswan.android.logic.DiagLog +import org.strongswan.android.logic.connectivity.UnderlyingNetwork +import org.strongswan.android.logic.events.Events +import org.strongswan.android.logic.updates.ReportContent +import org.strongswan.android.logic.updates.ReportUploader +import org.strongswan.android.logic.updates.UpdateManager +import org.strongswan.android.ui.compose.design.ShxButton +import org.strongswan.android.ui.compose.design.ShxButtonStyle +import org.strongswan.android.ui.compose.design.ShxFormField +import org.strongswan.android.ui.compose.design.ShxIcons +import org.strongswan.android.ui.compose.design.ShxIntro +import org.strongswan.android.ui.compose.design.ShxNote +import org.strongswan.android.ui.compose.design.ShxPanel +import org.strongswan.android.ui.compose.design.ShxSheet +import org.strongswan.android.ui.compose.design.ShxSheetTitle +import org.strongswan.android.ui.compose.design.ShxToggleRow +import org.strongswan.android.ui.compose.theme.LocalShxColors +import org.strongswan.android.ui.compose.theme.ShxType +import java.io.File +import java.util.Calendar + +/** State of the "Send report" sheet (mockups App8-App10); the upload survives a rotation */ +class ReportViewModel(app: Application) : AndroidViewModel(app) +{ + sealed class Ui + { + data object Hidden : Ui() + data class Form(val sending: Boolean = false) : Ui() + data class Sent(val code: String) : Ui() + data class Failed(val failure: ReportContent.Failure) : Ui() + } + + var ui by mutableStateOf(Ui.Hidden) + private set + var comment by mutableStateOf("") + var attachDiag by mutableStateOf(true) + + /* a swipe may hide the sheet mid-upload: the result then opens it again */ + private var job: Job? = null + + fun open() + { + ui = Ui.Form(sending = job?.isActive == true) + } + + fun close() + { + ui = Ui.Hidden + } + + fun send(profile: String) + { + if (job?.isActive == true) + { + return + } + ui = Ui.Form(sending = true) + val app = getApplication() + val note = comment + val diag = attachDiag + job = viewModelScope.launch { + /* the same checks as the Diagnostics screen (~3 s); a hang must not block the report */ + val checks = if (!diag) null else withTimeoutOrNull(10_000) { + runCatching { runDiagnostics(app) }.getOrNull()?.steps?.map { ReportContent.Check(it.title, it.value, it.ok) } + } + val result = withContext(Dispatchers.IO) { + val now = Calendar.getInstance() + val charon = ReportContent.todayOnly( + ReportContent.readTail(File(app.filesDir, CharonVpnService.LOG_FILE), ReportContent.CHARON_MAX_BYTES * 2), + now.get(Calendar.MONTH) + 1, now.get(Calendar.DAY_OF_MONTH), + ) + val text = ReportContent.build(charon, if (diag) ReportContent.readTail(DiagLog.file(app), ReportContent.DIAG_MAX_BYTES) else null, checks) + val meta = ReportContent.meta( + BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE.toLong(), Build.MANUFACTURER, Build.MODEL, + Build.VERSION.RELEASE, Build.VERSION.SDK_INT, networkLabel(app), note, profile, + ) + val updates = UpdateManager.getInstance(app) + ReportUploader(BuildConfig.UPDATE_API_URL, updates.apiHmacKey(), updates.installId()).send(text, meta) + } + when (result) + { + is ReportUploader.Result.Sent -> + { + Events.reportSent(app, result.code) + comment = "" + ui = Ui.Sent(result.code) + } + is ReportUploader.Result.Failed -> + { + DiagLog.i("Report", "upload failed: HTTP ${result.httpCode} ${result.detail}") + ui = Ui.Failed(ReportContent.failure(result.httpCode)) + } + } + } + } + + companion object + { + /** Only the address and the HMAC key are needed (the manifest key is for updates) */ + fun apiReady(context: Context): Boolean = + BuildConfig.UPDATE_API_URL.isNotBlank() && UpdateManager.getInstance(context).apiHmacKey().isNotBlank() + + /** The network under the tunnel: with the VPN up the active network is the VPN itself */ + private fun networkLabel(context: Context): String + { + val network = UnderlyingNetwork.resolve(context) ?: return "none" + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return "none" + val caps = try { cm.getNetworkCapabilities(network) } catch (_: Exception) { null } ?: return "none" + return when + { + caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "Wi-Fi" + caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "mobile" + else -> "other" + } + } + } +} + +@Composable +fun ReportSheet(model: ReportViewModel, profile: () -> String, onMail: (String) -> Unit) +{ + val ui = model.ui + if (ui == ReportViewModel.Ui.Hidden) + { + return + } + ShxSheet(onDismiss = model::close) + { + when (ui) + { + is ReportViewModel.Ui.Form -> ReportForm(model, ui.sending, onSend = { model.send(profile()) }, onMail = { onMail(model.comment) }) + is ReportViewModel.Ui.Sent -> ReportSent(ui.code, onDone = model::close) + is ReportViewModel.Ui.Failed -> ReportFailed(ui.failure, onRetry = model::open, onMail = { onMail(model.comment) }) + ReportViewModel.Ui.Hidden -> Unit + } + } +} + +/* App8 */ +@Composable +private fun ReportForm(model: ReportViewModel, sending: Boolean, onSend: () -> Unit, onMail: () -> Unit) +{ + val c = LocalShxColors.current + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) + { + ShxSheetTitle(stringResource(R.string.log_send_report)) + ShxIntro(stringResource(R.string.report_intro)) + } + ReasonCard( + stringResource(R.string.report_contents), c.accent, + listOf(R.string.report_item_log, R.string.report_item_device, R.string.report_item_network, R.string.report_item_ips).map { stringResource(it) to null }, + ) + ShxPanel() + { + ShxToggleRow( + stringResource(R.string.report_diag_title), model.attachDiag, { model.attachDiag = it }, + subtitle = stringResource(R.string.report_diag_sub), enabled = !sending, + ) + } + ShxFormField( + label = stringResource(R.string.report_comment_label), + value = model.comment, + onValueChange = { model.comment = it.take(1000) }, + singleLine = false, + enabled = !sending, + ) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) + { + ShxButton(stringResource(R.string.report_cancel), model::close, style = ShxButtonStyle.OUTLINE, enabled = !sending, modifier = Modifier.weight(1f)) + Box(contentAlignment = Alignment.Center, modifier = Modifier.weight(2f)) + { + ShxButton(if (sending) "" else stringResource(R.string.report_send), { if (!sending) onSend() }, modifier = Modifier.fillMaxWidth()) + if (sending) + { + CircularProgressIndicator(color = c.onAccent, strokeWidth = 2.5.dp, modifier = Modifier.size(22.dp)) + } + } + } + if (!sending) + { + TextLink(stringResource(R.string.report_by_mail), onMail) + } +} + +/* App9 */ +@Composable +private fun ReportSent(code: String, onDone: () -> Unit) +{ + val c = LocalShxColors.current + val context = LocalContext.current + val copied = stringResource(R.string.report_code_copied) + ResultHeader(ShxIcons.Check, c.successContainer, c.success, stringResource(R.string.report_sent_title), stringResource(R.string.report_sent_sub)) + ShxPanel(padding = androidx.compose.foundation.layout.PaddingValues(16.dp)) + { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) + { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) + { + Text(stringResource(R.string.report_code_label).uppercase(), style = ShxType.section.copy(fontSize = 12.sp), color = c.accentText) + Text(code, style = ShxType.mono.copy(fontSize = 22.sp, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace, letterSpacing = 0.9.sp), color = c.text) + } + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.size(44.dp).clip(CircleShape).background(c.tile).clickable(role = Role.Button) { + (context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager).setPrimaryClip(ClipData.newPlainText("report", code)) + Toast.makeText(context, copied, Toast.LENGTH_SHORT).show() + }, + ) + { + Icon(ShxIcons.Doc, contentDescription = stringResource(R.string.report_copy_code), tint = c.onAccentContainer, modifier = Modifier.size(20.dp)) + } + } + } + ShxNote(stringResource(R.string.report_saved_note)) + ShxButton(stringResource(R.string.report_done), onDone, modifier = Modifier.fillMaxWidth()) +} + +/* App10 */ +@Composable +private fun ReportFailed(failure: ReportContent.Failure, onRetry: () -> Unit, onMail: () -> Unit) +{ + val c = LocalShxColors.current + val noAnswer = failure == ReportContent.Failure.NO_NETWORK || failure == ReportContent.Failure.SERVER_DOWN + ResultHeader( + ShxIcons.Alert, c.errorContainer, c.error, stringResource(R.string.report_failed_title), + stringResource(if (noAnswer) R.string.report_failed_no_answer else R.string.report_failed_refused), + ) + val reasons = when (failure) + { + ReportContent.Failure.NO_NETWORK -> listOf(R.string.report_reason_offline to R.string.report_reason_offline_d, R.string.report_reason_server to R.string.report_reason_server_d) + ReportContent.Failure.SERVER_DOWN -> listOf(R.string.report_reason_server to R.string.report_reason_server_d) + ReportContent.Failure.SIGNATURE -> listOf(R.string.report_reason_clock to R.string.report_reason_clock_d) + ReportContent.Failure.TOO_OFTEN -> listOf(R.string.report_reason_often to R.string.report_reason_often_d) + ReportContent.Failure.TOO_LARGE -> listOf(R.string.report_reason_large to R.string.report_reason_large_d) + ReportContent.Failure.REJECTED -> listOf(R.string.report_reason_rejected to R.string.report_reason_rejected_d) + } + ReasonCard(stringResource(R.string.report_reasons), c.error, reasons.map { (t, d) -> stringResource(t) to stringResource(d) }) + ShxNote(stringResource(R.string.report_mail_note)) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) + { + ShxButton(stringResource(R.string.report_retry), onRetry, height = 52.dp, modifier = Modifier.weight(1f)) + ShxButton(stringResource(R.string.report_by_letter), onMail, style = ShxButtonStyle.SOFT, height = 52.dp, modifier = Modifier.weight(1f)) + } +} + +/** 52dp round icon, 22/800 title, 13 sub (as F_ErrImport) */ +@Composable +private fun ResultHeader(icon: ImageVector, background: Color, tint: Color, title: String, sub: String) +{ + val c = LocalShxColors.current + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.padding(start = 4.dp, end = 4.dp, top = 4.dp)) + { + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(52.dp).clip(CircleShape).background(background)) + { + Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(24.dp)) + } + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) + { + Text(title, style = ShxType.pageTitle.copy(letterSpacing = (-0.02).sp), color = c.text) + Text(sub, style = ShxType.sub, color = c.textSub) + } + } +} + +/** White card with a section label and dotted lines: «bold — muted» when [items] carry a second part */ +@Composable +private fun ReasonCard(title: String, dot: Color, items: List>) +{ + val c = LocalShxColors.current + val shape = RoundedCornerShape(22.dp) + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth().clip(shape).background(c.card).border(1.dp, c.cardBorder, shape).padding(horizontal = 16.dp, vertical = 14.dp), + ) + { + Text(title.uppercase(), style = ShxType.section.copy(fontSize = 12.sp), color = c.accentText) + items.forEach { (head, tail) -> + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) + { + Box(Modifier.padding(top = 8.dp).size(6.dp).clip(CircleShape).background(dot)) + Text( + buildAnnotatedString { + if (tail == null) + { + append(head) + } + else + { + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(head) } + append(" — ") + withStyle(SpanStyle(color = c.textSub)) { append(tail) } + } + }, + style = ShxType.note.copy(fontSize = 14.sp), color = c.text, + ) + } + } + } +} + +@Composable +private fun TextLink(text: String, onClick: () -> Unit) +{ + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) + { + Text( + text, style = ShxType.chip, color = LocalShxColors.current.accentText, + modifier = Modifier.clip(RoundedCornerShape(12.dp)).clickable(role = Role.Button, onClick = onClick).padding(8.dp), + ) + } +} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/SettingsActivity.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/SettingsActivity.kt index 74b1abe70..b62054cb3 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/SettingsActivity.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/SettingsActivity.kt @@ -55,6 +55,7 @@ class SettingsActivity : AppCompatActivity() Box(Modifier.padding(padding)) { SettingsScreen(profiles = profiles, showTitle = false) } + org.strongswan.android.ui.compose.UpdateSheetHost() } } } diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/DiagnosticsScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/DiagnosticsScreen.kt index 02e0ae882..9a4564c52 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/DiagnosticsScreen.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/DiagnosticsScreen.kt @@ -92,7 +92,7 @@ data class DiagReport(val steps: List, val at: Long, val vpnUp: Boolea val allOk: Boolean? get() = if (steps.any { it.ok == null }) null else steps.all { it.ok == true } } -private suspend fun runDiagnostics(context: Context): DiagReport = coroutineScope { +internal suspend fun runDiagnostics(context: Context): DiagReport = coroutineScope { val vpn = VpnStateService.getLiveInstance() val vpnUp = vpn?.state == VpnStateService.State.CONNECTED val gateway = (vpn?.profile)?.gateway diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsPages.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsPages.kt index 1dba0004c..48ba82749 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsPages.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsPages.kt @@ -52,7 +52,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.strongswan.android.R -import org.strongswan.android.logic.updates.UpdateState import org.strongswan.android.ui.compose.design.ShxButton import org.strongswan.android.ui.compose.design.ShxButtonStyle import org.strongswan.android.ui.compose.design.ShxIcons @@ -221,91 +220,6 @@ fun KillSwitchPage(lockdown: Boolean) } } -/** Update card (F_Update): app icon, version, what is new, progress, Later / Install. */ -@Composable -fun UpdateCard(state: UpdateState, installed: String, onCheck: () -> Unit, onInstall: () -> Unit) -{ - val c = LocalShxColors.current - val info = (state as? UpdateState.Available)?.info ?: (state as? UpdateState.Downloading)?.info - Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) - { - val shape = RoundedCornerShape(24.dp) - Column( - verticalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.fillMaxWidth().clip(shape).background(c.card).border(1.dp, c.cardBorder, shape).padding(16.dp), - ) - { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) - { - Box(contentAlignment = Alignment.Center, modifier = Modifier.size(56.dp).clip(RoundedCornerShape(16.dp)).background(c.accent)) - { - Icon(painterResource(R.drawable.ic_notification), contentDescription = null, tint = Color.White, modifier = Modifier.size(36.dp)) - } - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) - { - Text( - if (info != null) "SHX VPN ${info.versionName}" else stringResource(R.string.update_card_uptodate), - style = ShxType.cardHeading.copy(fontSize = 20.sp), color = c.text, - ) - Text( - when - { - info != null -> stringResource(R.string.update_card_have, installed, formatBytes(info.size)) - state is UpdateState.Checking -> stringResource(R.string.update_card_checking) - state is UpdateState.Failed -> stringResource(R.string.update_card_failed) - else -> stringResource(R.string.update_card_version, installed) - }, - style = ShxType.sub, color = c.textSub, - ) - } - } - if (info != null) - { - val notes = info.notes["ru"] ?: info.notes["en"] ?: info.notes.values.firstOrNull() - if (!notes.isNullOrBlank()) - { - Text(stringResource(R.string.update_card_whats_new).uppercase(), style = ShxType.section.copy(fontSize = 12.sp), color = c.accentText) - notes.split(Regex("(?<=[.;])\\s+")).map { it.trim().trimEnd('.', ';') }.filter { it.isNotEmpty() }.forEach { line -> - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) - { - Box(Modifier.padding(top = 8.dp).size(6.dp).clip(CircleShape).background(c.accent)) - Text(line, style = ShxType.note.copy(fontSize = 14.sp), color = c.text) - } - } - } - } - if (state is UpdateState.Downloading) - { - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) - { - Row(Modifier.fillMaxWidth()) - { - Text(stringResource(R.string.update_card_downloading), style = ShxType.meta, color = c.textSub, modifier = Modifier.weight(1f)) - Text("${state.progress}%", style = ShxType.meta, color = c.textSub) - } - Box(Modifier.fillMaxWidth().height(6.dp).clip(RoundedCornerShape(3.dp)).background(c.tile)) - { - Box(Modifier.fillMaxWidth(state.progress.coerceIn(0, 100) / 100f).height(6.dp).clip(RoundedCornerShape(3.dp)).background(c.accent)) - } - } - } - } - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) - { - if (info != null) - { - ShxButton(stringResource(R.string.update_card_check), onCheck, style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f)) - ShxButton(stringResource(R.string.update_card_install), onInstall, modifier = Modifier.weight(2f), enabled = state !is UpdateState.Downloading) - } - else - { - ShxButton(stringResource(R.string.update_card_check), onCheck, style = ShxButtonStyle.SOFT, modifier = Modifier.fillMaxWidth(), enabled = state !is UpdateState.Checking) - } - } - Text(stringResource(R.string.update_card_footer), style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub, modifier = Modifier.padding(horizontal = 6.dp)) - } -} - /** * «Help» (F_Help): two big buttons (check the connection, contact support), * the frequent questions as an accordion, and «About the app». @@ -566,14 +480,7 @@ internal fun InstallPermissionSheet(onOpen: () -> Unit, onDismiss: () -> Unit) Text(stringResource(R.string.install_perm_hint), style = ShxType.note.copy(lineHeight = 19.sp, fontWeight = FontWeight.Normal), color = c.textSoft) } } - Row( - horizontalArrangement = Arrangement.spacedBy(10.dp), - modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).background(c.successContainer).padding(horizontal = 14.dp, vertical = 12.dp), - ) - { - Icon(ShxIcons.Shield, contentDescription = null, tint = c.onSuccess, modifier = Modifier.size(20.dp)) - Text(stringResource(R.string.install_perm_safe), style = ShxType.note.copy(lineHeight = 19.sp, fontWeight = FontWeight.Normal), color = c.onSuccess) - } + UpdateSafeNote(stringResource(R.string.install_perm_safe)) Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { ShxButton(stringResource(R.string.install_perm_later), onDismiss, style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f)) diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsScreen.kt index 8887bd235..721807b8b 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsScreen.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsScreen.kt @@ -105,8 +105,6 @@ import org.strongswan.android.logic.StrongSwanApplication import org.strongswan.android.logic.WatchdogReceiver import org.strongswan.android.logic.connectivity.WhiteListManager import org.strongswan.android.logic.shortcuts.VpnShortcutManager -import org.strongswan.android.logic.updates.UpdateError -import org.strongswan.android.logic.updates.UpdateInstaller import org.strongswan.android.logic.updates.UpdateManager import org.strongswan.android.logic.updates.UpdateState import org.strongswan.android.ui.LogActivity @@ -151,43 +149,9 @@ fun SettingsScreen(profiles: List, showTitle: Boolean = true) val updateManager = remember { UpdateManager.getInstance(context) } val updateState by updateManager.state.collectAsStateWithLifecycle() - var showUpdateConfirm by remember { mutableStateOf(false) } - var showInstallPermission by remember { mutableStateOf(false) } val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() - val unknownSourcesLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.StartActivityForResult() - ) { - if (UpdateInstaller.canInstall(context)) - { - updateManager.downloadAndInstall() - } - else - { - scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.update_permission_denied)) } - } - } - val startUpdate: () -> Unit = { - if (UpdateInstaller.canInstall(context)) - { - updateManager.downloadAndInstall() - } - else - { /* F_UpdateInstall explains the system page first */ - showInstallPermission = true - } - } - if (showInstallPermission) - { - InstallPermissionSheet( - onOpen = { - showInstallPermission = false - unknownSourcesLauncher.launch(UpdateInstaller.createInstallPermissionIntent(context)) - }, - onDismiss = { showInstallPermission = false }, - ) - } var pageState by rememberSaveable { mutableStateOf(SettingsPage.ROOT) } val updatesRequested by ShxNavigation.updatesPage.collectAsStateWithLifecycle() @@ -419,18 +383,7 @@ fun SettingsScreen(profiles: List, showTitle: Boolean = true) if (page == SettingsPage.UPDATES) { - UpdateCard( - state = updateState, - installed = versionName, - onCheck = { - when (updateState) - { - is UpdateState.Checking, is UpdateState.Downloading -> Unit - else -> updateManager.checkNow() - } - }, - onInstall = { showUpdateConfirm = true }, - ) + UpdatesPage() } if (page == SettingsPage.KILLSWITCH) @@ -445,75 +398,7 @@ fun SettingsScreen(profiles: List, showTitle: Boolean = true) KillSwitchPage(remember(resumes) { context.isVpnLockdownEnabled() }) } - if (page == SettingsPage.UPDATES) - { - ShxSection(title = stringResource(R.string.update_settings)) - { - var autoUpdate by remember { - mutableStateOf(prefs.getBoolean(Constants.PREF_AUTO_UPDATE_CHECK, true)) - } - ShxSettingToggleRow( - icon = ShxIcons.Refresh, - title = stringResource(R.string.update_auto_check_title), - subtitle = stringResource(R.string.update_auto_check_summary), - checked = autoUpdate, - onCheckedChange = { - autoUpdate = it - prefs.edit().putBoolean(Constants.PREF_AUTO_UPDATE_CHECK, it).apply() - }, - ) - ShxSectionDivider() - var silentUpdates by remember { - mutableStateOf(prefs.getBoolean(Constants.PREF_SILENT_UPDATES, false)) - } - ShxSettingToggleRow( - icon = ShxIcons.Refresh, - title = stringResource(R.string.update_silent_title), - subtitle = stringResource(R.string.update_silent_summary), - checked = silentUpdates, - onCheckedChange = { - silentUpdates = it - prefs.edit().putBoolean(Constants.PREF_SILENT_UPDATES, it).apply() - }, - ) - ShxSectionDivider() - ShxSettingRow( - icon = ShxIcons.Download, - title = stringResource(R.string.update_check_title), - subtitle = updateStatusText(updateState), - onClick = { - when (updateState) - { - is UpdateState.Checking, is UpdateState.Downloading -> Unit - else -> updateManager.checkNow() - } - }, - ) - val updateInfo = (updateState as? UpdateState.Available)?.info - ?: (updateState as? UpdateState.Downloading)?.info - if (updateInfo != null) - { - ShxSectionDivider() - ShxSettingRow( - icon = ShxIcons.Download, - title = stringResource(R.string.update_download_install), - subtitle = when (val state = updateState) - { - is UpdateState.Downloading -> stringResource(R.string.update_downloading_fmt, state.progress) - else -> stringResource(R.string.update_available_fmt, updateInfo.versionName) - }, - onClick = { - if (updateState !is UpdateState.Downloading) - { - showUpdateConfirm = true - } - }, - ) - } - } - } - - if (page == SettingsPage.ROOT || page == SettingsPage.UPDATES) Text( + if (page == SettingsPage.ROOT) Text( text = stringResource(R.string.app_version_fmt, versionName), style = ShxType.meta, color = LocalShxColors.current.textSub, @@ -600,61 +485,6 @@ fun SettingsScreen(profiles: List, showTitle: Boolean = true) ) } - if (showUpdateConfirm && updateState is UpdateState.Available) - { - val info = (updateState as UpdateState.Available).info - val notes = info.notes["ru"] ?: info.notes["en"] ?: info.notes.values.firstOrNull() ?: "" - ShxAlertDialog( - onDismissRequest = { showUpdateConfirm = false }, - title = { Text(stringResource(R.string.update_confirm_title, info.versionName)) }, - text = { if (notes.isNotEmpty()) { Text(notes) } }, - confirmButton = { - ShxDialogButton(primary = true, onClick = { - showUpdateConfirm = false - startUpdate() - }) { Text(stringResource(R.string.update_action)) } - }, - dismissButton = { - ShxDialogButton(onClick = { showUpdateConfirm = false }) { - Text(stringResource(android.R.string.cancel)) - } - }, - ) - } -} - -@Composable -private fun updateStatusText(state: UpdateState): String -{ - return when (state) - { - is UpdateState.Checking -> stringResource(R.string.update_checking) - is UpdateState.Idle -> stringResource(R.string.update_status_idle) - is UpdateState.UpToDate -> stringResource(R.string.update_status_uptodate) - is UpdateState.Available -> - { - if (state.info.size > 0) - { - stringResource(R.string.update_available_fmt, state.info.versionName) + " • " + - stringResource(R.string.update_size_mb_fmt, (state.info.size / (1024 * 1024)).toInt()) - } - else - { - stringResource(R.string.update_available_fmt, state.info.versionName) - } - } - is UpdateState.Downloading -> stringResource(R.string.update_downloading_fmt, state.progress) - is UpdateState.Failed -> stringResource( - when (state.error) - { - UpdateError.NETWORK -> R.string.update_error_network - UpdateError.HASH -> R.string.update_error_hash - UpdateError.SIGNATURE -> R.string.update_error_signature - UpdateError.INSTALL -> R.string.update_error_install - UpdateError.OTHER -> R.string.update_error_generic - } - ) - } } /** diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ShxApp.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ShxApp.kt index 4290820ca..753b99dbc 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ShxApp.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ShxApp.kt @@ -260,6 +260,8 @@ fun ShxApp(viewModel: HomeViewModel = viewModel()) { AddServerSheet(onDismiss = { addSheetOpen = false }) } + /* update sheets, the mandatory update included, over every tab */ + UpdateSheetHost() } } } diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/UpdatesUi.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/UpdatesUi.kt new file mode 100644 index 000000000..3c11d2251 --- /dev/null +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/UpdatesUi.kt @@ -0,0 +1,658 @@ +/* + * Copyright (C) 2026 SHX VPN + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the + * Free Software Foundation; either version 2 of the License, or (at your + * option) any later version. + */ + +package org.strongswan.android.ui.compose + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.text.format.DateUtils +import android.widget.Toast +import androidx.activity.compose.LocalActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.preference.PreferenceManager +import kotlinx.coroutines.flow.MutableStateFlow +import org.strongswan.android.BuildConfig +import org.strongswan.android.R +import org.strongswan.android.logic.updates.UpdateError +import org.strongswan.android.logic.updates.UpdateInfo +import org.strongswan.android.logic.updates.UpdateInstaller +import org.strongswan.android.logic.updates.UpdateManager +import org.strongswan.android.logic.updates.UpdateState +import org.strongswan.android.logic.updates.noteLines +import org.strongswan.android.logic.updates.pickNotes +import org.strongswan.android.ui.compose.design.ShxButton +import org.strongswan.android.ui.compose.design.ShxButtonStyle +import org.strongswan.android.ui.compose.design.ShxChoiceCard +import org.strongswan.android.ui.compose.design.ShxFormField +import org.strongswan.android.ui.compose.design.ShxIcons +import org.strongswan.android.ui.compose.design.ShxIntro +import org.strongswan.android.ui.compose.design.ShxNote +import org.strongswan.android.ui.compose.design.ShxPanel +import org.strongswan.android.ui.compose.design.ShxSectionHeader +import org.strongswan.android.ui.compose.design.ShxSheet +import org.strongswan.android.ui.compose.design.ShxSheetTitle +import org.strongswan.android.ui.compose.design.ShxTone +import org.strongswan.android.ui.compose.design.ShxToggleRow +import org.strongswan.android.ui.compose.theme.LocalShxColors +import org.strongswan.android.ui.compose.theme.ShxSpacing +import org.strongswan.android.ui.compose.theme.ShxType +import org.strongswan.android.utils.Constants + +/** Which update sheet the user opened: «What is new» (App2) or the install attempt (App3 / App5). */ +enum class UpdateSheet { REVIEW, ATTEMPT } + +/** Shared between the Updates page and the one sheet host of the activity. */ +object UpdateSheets +{ + val shown = MutableStateFlow(null) + + fun show(sheet: UpdateSheet?) + { + shown.value = sheet + } +} + +/** + * All update sheets of an activity (ShxApp and SettingsActivity call it once): + * «What is new» (App2), the download (App3), the mandatory update (App4), + * the failure (App5) and the install permission (App6). + */ +@Composable +fun UpdateSheetHost() +{ + val context = LocalContext.current + val manager = remember { UpdateManager.getInstance(context) } + val state by manager.state.collectAsStateWithLifecycle() + val sheet by UpdateSheets.shown.collectAsStateWithLifecycle() + val mandatory = remember(state) { manager.pendingMandatory() } + var askPermission by remember { mutableStateOf(false) } + + val install = { + UpdateSheets.show(UpdateSheet.ATTEMPT) + manager.downloadAndInstall() + } + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) + { + if (UpdateInstaller.canInstall(context)) install() + else Toast.makeText(context, R.string.update_permission_denied, Toast.LENGTH_LONG).show() + } + val start: () -> Unit = { + if (UpdateInstaller.canInstall(context)) install() else askPermission = true + } + + LaunchedEffect(Unit) + { /* a mandatory update found earlier (e.g. before a restart): make sure it is still current */ + if (manager.pendingMandatory() != null && state !is UpdateState.Available && state !is UpdateState.Downloading) + { + manager.checkNow() + } + } + LaunchedEffect(state) + { /* the installer took over, or the download was cancelled */ + if (sheet == UpdateSheet.ATTEMPT && (state is UpdateState.Idle || state is UpdateState.UpToDate)) + { + UpdateSheets.show(null) + } + } + + when + { + askPermission -> InstallPermissionSheet( + onOpen = { + askPermission = false + launcher.launch(UpdateInstaller.createInstallPermissionIntent(context)) + }, + onDismiss = { askPermission = false }, + ) + sheet == UpdateSheet.ATTEMPT -> ShxSheet(onDismiss = { UpdateSheets.show(null) }) + { + when (val s = state) + { + is UpdateState.Failed -> UpdateFailedContent(s, onRetry = start) + else -> UpdateProgressContent(s, onCancel = { manager.cancelDownload() }, onInstall = start) + } + } + mandatory != null -> MandatoryUpdateSheet(mandatory.first, mandatory.second, onUpdate = start) + sheet == UpdateSheet.REVIEW -> + { + val info = (state as? UpdateState.Available)?.info + if (info != null) UpdateReviewSheet(info, onInstall = start, onDismiss = { UpdateSheets.show(null) }) + else androidx.compose.runtime.SideEffect { UpdateSheets.show(null) } + } + } +} + +/** «Updates» page (App1): the available version, automatic mode, channel, safety note, manual check. */ +@Composable +fun UpdatesPage() +{ + val context = LocalContext.current + val c = LocalShxColors.current + val manager = remember { UpdateManager.getInstance(context) } + val prefs = remember { PreferenceManager.getDefaultSharedPreferences(context) } + val state by manager.state.collectAsStateWithLifecycle() + val info = (state as? UpdateState.Available)?.info ?: (state as? UpdateState.Downloading)?.info + val busy = state is UpdateState.Checking || state is UpdateState.Downloading + var beta by remember { mutableStateOf(manager.isBetaChannel()) } + var askCode by remember { mutableStateOf(false) } + + Column(Modifier.padding(horizontal = ShxSpacing.lg), verticalArrangement = Arrangement.spacedBy(ShxSpacing.md)) + { + Column( + verticalArrangement = Arrangement.spacedBy(14.dp), + modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(24.dp)).background(c.accentContainer).padding(ShxSpacing.lg), + ) + { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) + { + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(52.dp).clip(RoundedCornerShape(16.dp)).background(c.accent)) + { + Icon(ShxIcons.Refresh, contentDescription = null, tint = c.onAccent, modifier = Modifier.size(26.dp)) + } + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) + { + Text( + when + { + info != null -> stringResource(R.string.update_available_fmt, info.versionName) + state is UpdateState.UpToDate -> stringResource(R.string.update_card_uptodate) + else -> "SHX VPN ${BuildConfig.VERSION_NAME}" + }, + style = ShxType.cardHeading, color = c.onAccentContainerStrong, + ) + Text( + when + { + info != null -> stringResource(R.string.updates_have_size, BuildConfig.VERSION_NAME, sizeText(info.size)) + state is UpdateState.Checking -> stringResource(R.string.update_card_checking) + state is UpdateState.Failed -> stringResource(R.string.update_card_failed) + state is UpdateState.UpToDate -> stringResource(R.string.update_card_version, BuildConfig.VERSION_NAME) + else -> stringResource(R.string.update_status_idle) + }, + style = ShxType.sub, color = c.onAccentContainer, + ) + } + } + if (info != null) + { + val downloading = state as? UpdateState.Downloading + ShxButton( + if (downloading != null) stringResource(R.string.update_downloading_fmt, downloading.progress) else stringResource(R.string.updates_review), + { UpdateSheets.show(if (downloading != null) UpdateSheet.ATTEMPT else UpdateSheet.REVIEW) }, + height = 52.dp, modifier = Modifier.fillMaxWidth(), + ) + } + } + + ShxSectionHeader(stringResource(R.string.updates_section_auto)) + ShxPanel() + { + var autoCheck by remember { mutableStateOf(prefs.getBoolean(Constants.PREF_AUTO_UPDATE_CHECK, true)) } + var silent by remember { mutableStateOf(prefs.getBoolean(Constants.PREF_SILENT_UPDATES, false)) } + val hours = remember { manager.checkIntervalHours().toInt() } + ShxToggleRow( + stringResource(R.string.updates_auto_check), autoCheck, + { autoCheck = it; prefs.edit().putBoolean(Constants.PREF_AUTO_UPDATE_CHECK, it).apply() }, + subtitle = pluralStringResource(R.plurals.updates_auto_check_sub, hours, hours), divider = true, + ) + ShxToggleRow( + stringResource(R.string.updates_silent), silent, + { silent = it; prefs.edit().putBoolean(Constants.PREF_SILENT_UPDATES, it).apply() }, + subtitle = stringResource(R.string.updates_silent_sub), + ) + } + + ShxSectionHeader(stringResource(R.string.updates_section_channel)) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) + { + ShxChoiceCard( + stringResource(R.string.updates_channel_stable), stringResource(R.string.updates_channel_stable_sub), !beta, + { + if (beta) + { + beta = false + manager.setChannel(beta = false, token = null) + } + }, + modifier = Modifier.weight(1f), + ) + ShxChoiceCard( + stringResource(R.string.updates_channel_beta), stringResource(R.string.updates_channel_beta_sub), beta, + { if (!beta) askCode = true }, + modifier = Modifier.weight(1f), + ) + } + Text(stringResource(R.string.updates_channel_hint), style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub, modifier = Modifier.padding(horizontal = 6.dp)) + + UpdateSafeNote(stringResource(R.string.install_perm_safe)) + + ShxButton( + stringResource(R.string.updates_check_now), { manager.checkNow() }, + style = ShxButtonStyle.SOFT, height = 52.dp, enabled = !busy, modifier = Modifier.fillMaxWidth(), + ) + val last = remember(state) { prefs.getLong(Constants.PREF_LAST_UPDATE_CHECK, 0L) } + Text( + when (val s = state) + { + is UpdateState.Checking -> stringResource(R.string.update_card_checking) + is UpdateState.Failed -> stringResource(updateErrorText(s.error)) + else -> if (last > 0) stringResource(R.string.updates_last_check, lastCheckText(context, last)) + else stringResource(R.string.updates_never_checked) + }, + style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub, + textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(), + ) + } + + if (askCode) + { + BetaCodeSheet( + onEnable = { code -> + askCode = false + beta = true + manager.setChannel(beta = true, token = code) + }, + onDismiss = { askCode = false }, + ) + } +} + +/** Invitation code of the beta channel. */ +@Composable +private fun BetaCodeSheet(onEnable: (String) -> Unit, onDismiss: () -> Unit) +{ + var code by remember { mutableStateOf("") } + ShxSheet(onDismiss = onDismiss) + { + ShxSheetTitle(stringResource(R.string.updates_beta_title)) + ShxIntro(stringResource(R.string.updates_beta_text)) + ShxFormField(label = stringResource(R.string.updates_beta_code), value = code, onValueChange = { code = it }, mono = true) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) + { + ShxButton(stringResource(android.R.string.cancel), onDismiss, style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f)) + ShxButton(stringResource(R.string.updates_beta_enable), { onEnable(code.trim()) }, enabled = code.isNotBlank(), modifier = Modifier.weight(2f)) + } + } +} + +/** «A new version is available» (App2): version, what is new from the release notes, Later / Download and install. */ +@Composable +private fun UpdateReviewSheet(info: UpdateInfo, onInstall: () -> Unit, onDismiss: () -> Unit) +{ + val c = LocalShxColors.current + val locale = LocalConfiguration.current.locales[0] + val lines = noteLines(pickNotes(info.notes, "${locale.language}-${locale.country}", locale.language)) + ShxSheet(onDismiss = onDismiss) + { + UpdateSheetHeader( + "SHX VPN ${info.versionName}", + stringResource(R.string.updates_review_sub, BuildConfig.VERSION_NAME, sizeText(info.size)), + ) + if (lines.isNotEmpty()) + { + ShxPanel(padding = androidx.compose.foundation.layout.PaddingValues(ShxSpacing.lg)) + { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) + { + Text(stringResource(R.string.update_card_whats_new).uppercase(), style = ShxType.section, color = c.accentText) + lines.forEach { Bullet(it, c.accent) } + } + } + } + UpdateSafeNote(stringResource(R.string.updates_review_safe)) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) + { + ShxButton(stringResource(R.string.cert_later), onDismiss, style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f)) + ShxButton(stringResource(R.string.update_download_install), onInstall, modifier = Modifier.weight(2f)) + } + } +} + +/** Download (App3): progress and the four steps of the update. */ +@Composable +private fun UpdateProgressContent(state: UpdateState, onCancel: () -> Unit, onInstall: () -> Unit) +{ + val c = LocalShxColors.current + val info = (state as? UpdateState.Downloading)?.info ?: (state as? UpdateState.Available)?.info + val progress = (state as? UpdateState.Downloading)?.progress ?: 0 + val downloading = state is UpdateState.Downloading + UpdateSheetHeader( + stringResource(R.string.updates_downloading_title, info?.versionName ?: ""), + stringResource(R.string.updates_downloading_sub), + ) + Column(verticalArrangement = Arrangement.spacedBy(ShxSpacing.sm)) + { + Row(Modifier.fillMaxWidth()) + { + Text(stringResource(R.string.update_card_downloading), style = ShxType.chip, color = c.textSoft, modifier = Modifier.weight(1f)) + if (info != null && info.size > 0) + { + Text( + stringResource(R.string.updates_progress, progress, sizeText(info.size * progress / 100), sizeText(info.size)), + style = ShxType.chip, color = c.textSoft, + ) + } + } + Box(Modifier.fillMaxWidth().height(8.dp).clip(RoundedCornerShape(4.dp)).background(c.cardBorder)) + { + Box(Modifier.fillMaxWidth(progress / 100f).height(8.dp).clip(RoundedCornerShape(4.dp)).background(c.accent)) + } + } + /* steps: the list is known once a version is shown; at 100% the file is being checked */ + val verifying = downloading && progress >= 100 + ShxPanel(padding = androidx.compose.foundation.layout.PaddingValues(horizontal = ShxSpacing.lg, vertical = 6.dp)) + { + UpdateStep(StepState.DONE, stringResource(R.string.updates_step_list), stringResource(R.string.updates_step_list_sub), divider = true) + UpdateStep( + if (verifying) StepState.DONE else StepState.RUNNING, stringResource(R.string.updates_step_download), + if (info != null && info.size > 0) sizeText(info.size) else stringResource(R.string.update_card_downloading), divider = true, + ) + UpdateStep(if (verifying) StepState.RUNNING else StepState.WAITING, stringResource(R.string.updates_step_verify), stringResource(R.string.updates_step_verify_sub), divider = true) + UpdateStep(StepState.WAITING, stringResource(R.string.updates_step_install), stringResource(R.string.updates_step_install_sub), divider = false) + } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) + { + ShxButton(stringResource(R.string.updates_cancel), onCancel, style = ShxButtonStyle.OUTLINE, enabled = downloading, modifier = Modifier.weight(1f)) + /* while downloading the app installs on its own; Available here means a background download + * finished or failed without an install request, so the button starts it */ + ShxButton(stringResource(R.string.update_card_install), onInstall, enabled = state is UpdateState.Available, modifier = Modifier.weight(2f)) + } +} + +private enum class StepState { DONE, RUNNING, WAITING } + +@Composable +private fun UpdateStep(step: StepState, title: String, sub: String, divider: Boolean) +{ + val c = LocalShxColors.current + Column() + { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(ShxSpacing.md), + modifier = Modifier.fillMaxWidth().padding(vertical = ShxSpacing.md), + ) + { + val shape = CircleShape + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.size(24.dp).clip(shape).then( + when (step) + { + StepState.DONE -> Modifier.background(c.success) + StepState.RUNNING -> Modifier.background(c.accentContainer).border(2.dp, c.accent, shape) + StepState.WAITING -> Modifier.background(c.card).border(1.dp, c.outline, shape) + } + ), + ) + { + when (step) + { + StepState.DONE -> Icon(ShxIcons.Check, contentDescription = null, tint = Color.White, modifier = Modifier.size(14.dp)) + StepState.RUNNING -> Text("…", style = ShxType.meta.copy(fontWeight = FontWeight.ExtraBold), color = c.onAccentContainer) + StepState.WAITING -> Unit + } + } + Column(verticalArrangement = Arrangement.spacedBy(1.dp)) + { + Text(title, style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = if (step == StepState.WAITING) c.iconMuted else c.text) + Text(sub, style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub) + } + } + if (divider) + { + Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider)) + } + } +} + +/** Mandatory update (App4): cannot be closed; update now or leave the app. */ +@Composable +private fun MandatoryUpdateSheet(versionName: String, size: Long, onUpdate: () -> Unit) +{ + val c = LocalShxColors.current + val activity = LocalActivity.current + ShxSheet(onDismiss = {}, dismissible = false) + { + UpdateSheetHeader( + stringResource(R.string.updates_mandatory_title), + stringResource(R.string.updates_mandatory_sub, BuildConfig.VERSION_NAME), + icon = ShxIcons.Alert, iconBackground = c.warningContainer, iconTint = c.onWarningContainer, round = true, + ) + ShxPanel(padding = androidx.compose.foundation.layout.PaddingValues(horizontal = ShxSpacing.lg, vertical = 14.dp)) + { + Column(verticalArrangement = Arrangement.spacedBy(ShxSpacing.sm)) + { + Text(stringResource(R.string.updates_mandatory_what).uppercase(), style = ShxType.section.copy(fontSize = 12.sp), color = c.accentText) + Bullet(stringResource(R.string.updates_mandatory_reason), c.warning) + Bullet(stringResource(R.string.updates_mandatory_install, versionName, sizeText(size)), c.accent) + } + } + ShxNote(stringResource(R.string.updates_mandatory_locked), tone = ShxTone.WARNING) + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) + { + ShxButton(stringResource(R.string.cert_update_now), onUpdate, modifier = Modifier.fillMaxWidth()) + ShxButton(stringResource(R.string.updates_exit), { activity?.finishAndRemoveTask() }, style = ShxButtonStyle.SOFT, height = 52.dp, modifier = Modifier.fillMaxWidth()) + } + } +} + +/** Update not installed (App5): reasons by error kind, retry, the download page, details for support. */ +@Composable +private fun UpdateFailedContent(state: UpdateState.Failed, onRetry: () -> Unit) +{ + val c = LocalShxColors.current + val context = LocalContext.current + val checkFailed = state.error == UpdateError.HASH || state.error == UpdateError.SIGNATURE + UpdateSheetHeader( + stringResource(R.string.updates_failed_title), + stringResource( + when (state.error) + { + UpdateError.HASH, UpdateError.SIGNATURE -> R.string.updates_failed_sub_check + UpdateError.NETWORK -> R.string.updates_failed_sub_network + UpdateError.INSTALL -> R.string.updates_failed_sub_install + UpdateError.OTHER -> R.string.updates_failed_sub_other + } + ), + icon = ShxIcons.Shield, iconBackground = c.errorContainer, iconTint = c.error, round = true, + ) + val (title, detail) = when (state.error) + { + UpdateError.HASH -> R.string.updates_reason_hash to R.string.updates_reason_hash_d + UpdateError.SIGNATURE -> R.string.updates_reason_signature to R.string.updates_reason_signature_d + UpdateError.NETWORK -> R.string.updates_reason_network to R.string.updates_reason_network_d + UpdateError.INSTALL -> R.string.updates_reason_install to R.string.updates_reason_install_d + UpdateError.OTHER -> R.string.updates_reason_other to R.string.updates_reason_other_d + } + ShxPanel(padding = androidx.compose.foundation.layout.PaddingValues(horizontal = ShxSpacing.lg, vertical = 14.dp)) + { + Column(verticalArrangement = Arrangement.spacedBy(ShxSpacing.sm)) + { + Text(stringResource(R.string.updates_failed_what).uppercase(), style = ShxType.section.copy(fontSize = 12.sp), color = c.accentText) + Reason(stringResource(title), stringResource(detail), if (checkFailed) c.error else c.accent) + Reason(stringResource(R.string.updates_reason_same), stringResource(R.string.updates_reason_same_d, BuildConfig.VERSION_NAME), c.accent) + Reason(stringResource(R.string.updates_reason_later), stringResource(R.string.updates_reason_later_d), c.accent) + } + } + ShxNote(stringResource(R.string.updates_manual_note)) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) + { + ShxButton(stringResource(R.string.updates_retry), onRetry, height = 52.dp, modifier = Modifier.weight(1f)) + ShxButton( + stringResource(R.string.updates_from_site), + { runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(BuildConfig.UPDATE_DOWNLOAD_PAGE_URL))) } }, + style = ShxButtonStyle.SOFT, height = 52.dp, modifier = Modifier.weight(1f), + ) + } + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) + { + Text( + stringResource(R.string.updates_support), + style = ShxType.chip, color = c.accentText, + modifier = Modifier.clip(RoundedCornerShape(8.dp)) + .clickable(role = Role.Button) { copySupportDetails(context, state) }.padding(horizontal = ShxSpacing.sm, vertical = 6.dp), + ) + } +} + +/** Error kind, message and versions for support; the beta code stays out. */ +private fun copySupportDetails(context: Context, state: UpdateState.Failed) +{ + val manager = UpdateManager.getInstance(context) + val text = buildString { + appendLine("SHX VPN update error: ${state.error}") + appendLine("Detail: ${state.detail ?: "-"}") + appendLine("Installed: ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})") + appendLine("Channel: ${if (manager.isBetaChannel()) "beta" else "release"}") + appendLine("Android: ${Build.VERSION.RELEASE} (SDK ${Build.VERSION.SDK_INT}), ${Build.MANUFACTURER} ${Build.MODEL}, ${Build.SUPPORTED_ABIS.firstOrNull() ?: "?"}") + } + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return + clipboard.setPrimaryClip(ClipData.newPlainText("SHX VPN", text)) + /* Android 13+ confirms the copy itself */ + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) + { + Toast.makeText(context, R.string.updates_copied, Toast.LENGTH_SHORT).show() + } +} + +/** Icon well, sheet title and subtitle of the update sheets. */ +@Composable +private fun UpdateSheetHeader( + title: String, + subtitle: String, + icon: ImageVector? = null, + iconBackground: Color = LocalShxColors.current.accent, + iconTint: Color = Color.White, + round: Boolean = false, +) +{ + val c = LocalShxColors.current + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + modifier = Modifier.padding(start = 4.dp, end = 4.dp, top = 4.dp), + ) + { + val size = if (round) 52.dp else 56.dp + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(size).clip(if (round) CircleShape else RoundedCornerShape(16.dp)).background(iconBackground)) + { + if (icon != null) Icon(icon, contentDescription = null, tint = iconTint, modifier = Modifier.size(24.dp)) + else Icon(painterResource(R.drawable.ic_notification), contentDescription = null, tint = iconTint, modifier = Modifier.size(36.dp)) + } + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) + { + Text(title, style = ShxType.pageTitle, color = c.text) + Text(subtitle, style = ShxType.sub, color = c.textSub) + } + } +} + +/** Green «safe» box with a shield (App1, App2, App6). */ +@Composable +internal fun UpdateSafeNote(text: String) +{ + val c = LocalShxColors.current + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).background(c.successContainer).padding(horizontal = 14.dp, vertical = ShxSpacing.md), + ) + { + Icon(ShxIcons.Shield, contentDescription = null, tint = c.onSuccess, modifier = Modifier.size(20.dp)) + Text(text, style = ShxType.note, color = c.onSuccess) + } +} + +@Composable +private fun Bullet(text: String, dot: Color) +{ + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) + { + Box(Modifier.padding(top = 8.dp).size(6.dp).clip(CircleShape).background(dot)) + Text(text, style = ShxType.note.copy(fontSize = 14.sp), color = LocalShxColors.current.text) + } +} + +/** «Title — detail» line of App5. */ +@Composable +private fun Reason(title: String, detail: String, dot: Color) +{ + val c = LocalShxColors.current + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) + { + Box(Modifier.padding(top = 8.dp).size(6.dp).clip(CircleShape).background(dot)) + Text( + buildAnnotatedString { + withStyle(SpanStyle(fontWeight = FontWeight.Bold, color = c.text)) { append(title) } + append(" — ") + withStyle(SpanStyle(color = c.textSoft)) { append(detail) } + }, + style = ShxType.note.copy(fontSize = 14.sp), color = c.text, + ) + } +} + +private fun sizeText(bytes: Long): String = if (bytes > 0) formatBytes(bytes) else "—" + +private fun lastCheckText(context: Context, time: Long): String = + DateUtils.getRelativeDateTimeString(context, time, DateUtils.DAY_IN_MILLIS, DateUtils.WEEK_IN_MILLIS, 0).toString() + +internal fun updateErrorText(error: UpdateError): Int = when (error) +{ + UpdateError.NETWORK -> R.string.update_error_network + UpdateError.HASH -> R.string.update_error_hash + UpdateError.SIGNATURE -> R.string.update_error_signature + UpdateError.INSTALL -> R.string.update_error_install + UpdateError.OTHER -> R.string.update_error_generic +} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/design/ShxKit.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/design/ShxKit.kt index 653a8aa78..c421c9bbe 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/design/ShxKit.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/design/ShxKit.kt @@ -331,7 +331,7 @@ fun ShxButton( .background(if (enabled) bg else bg.copy(alpha = 0.38f)) .then(if (style == ShxButtonStyle.OUTLINE) Modifier.border(1.dp, c.outline, shape) else Modifier) .clickable(enabled = enabled, role = Role.Button, onClick = onClick) - .padding(horizontal = if (height >= 44.dp) 16.dp else 14.dp), + .padding(horizontal = if (height >= 44.dp) 10.dp else 14.dp), ) { if (icon != null) @@ -339,7 +339,17 @@ fun ShxButton( Icon(icon, contentDescription = null, tint = fg, modifier = Modifier.size(18.dp)) Spacer(Modifier.width(8.dp)) } - Text(text, style = textStyle, color = if (enabled) fg else fg.copy(alpha = 0.6f), maxLines = 1, overflow = TextOverflow.Ellipsis) + /* two lines on large buttons: on 320dp screens «Скачать и установить» beside «Позже» was cut to «Скачать и установ…» */ + Text( + text, + style = textStyle, + color = if (enabled) fg else fg.copy(alpha = 0.6f), + /* a single word must not wrap mid-word («Отмени / ть»): it shrinks instead */ + maxLines = if (height >= 52.dp && text.trim().contains(' ')) 2 else 1, + overflow = TextOverflow.Ellipsis, + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + autoSize = androidx.compose.foundation.text.TextAutoSize.StepBased(minFontSize = 11.sp, maxFontSize = textStyle.fontSize), + ) } } @@ -555,14 +565,18 @@ fun ShxTag(text: String, background: Color, color: Color) ) } -/** Bottom sheet of the mockup: radius 32, 36x4 handle, page background. */ +/** + * Bottom sheet of the mockup: radius 32, 36x4 handle, page background. + * [dismissible] = false: swipe, scrim tap and «Back» do not close it (a mandatory update). + */ @Composable -fun ShxSheet(onDismiss: () -> Unit, content: @Composable ColumnScope.() -> Unit) +fun ShxSheet(onDismiss: () -> Unit, dismissible: Boolean = true, content: @Composable ColumnScope.() -> Unit) { val c = LocalShxColors.current ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + onDismissRequest = { if (dismissible) onDismiss() }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true, confirmValueChange = { dismissible || it != androidx.compose.material3.SheetValue.Hidden }), + properties = androidx.compose.material3.ModalBottomSheetProperties(shouldDismissOnBackPress = dismissible, shouldDismissOnClickOutside = dismissible), containerColor = c.background, scrimColor = c.scrim, shape = RoundedCornerShape(topStart = 32.dp, topEnd = 32.dp), @@ -571,6 +585,8 @@ fun ShxSheet(onDismiss: () -> Unit, content: @Composable ColumnScope.() -> Unit) }, ) { + /* belt and braces for a mandatory sheet: «Back» is swallowed even if the dialog forwards it */ + androidx.activity.compose.BackHandler(enabled = !dismissible) {} Column( verticalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.fillMaxWidth().verticalScroll(androidx.compose.foundation.rememberScrollState()).padding(start = 16.dp, end = 16.dp, bottom = 28.dp), diff --git a/src/frontends/android/app/src/main/res/values-de/strings_reports.xml b/src/frontends/android/app/src/main/res/values-de/strings_reports.xml new file mode 100644 index 000000000..10fb5fdb5 --- /dev/null +++ b/src/frontends/android/app/src/main/res/values-de/strings_reports.xml @@ -0,0 +1,44 @@ + + + + Der Bericht geht über einen geschützten Kanal an das SHX-Team. Mit dem Berichtscode findet der Support Ihr Protokoll. + Inhalt des Berichts + Verbindungsprotokoll von heute + App-Version und Telefonmodell + Netzwerktyp und aktiver Server + IP-Adressen der Server aus dem Protokoll + Diagnose anhängen + Ergebnisse der Netzwerkprüfung und erweitertes App-Protokoll + Was ist passiert? (optional) + Abbrechen + Senden + Per E-Mail senden + Bericht gesendet + Nennen Sie diesen Code dem Support + Berichtscode + Code kopieren + Code kopiert + Der Code ist unter „Ereignisse“ gespeichert und lässt sich später finden. + Fertig + Bericht nicht gesendet + Der Server hat nicht geantwortet + Der Server hat den Bericht abgelehnt + Mögliche Ursachen + Kein Internet + Netzwerk prüfen und wiederholen + Server nicht erreichbar + in ein paar Minuten wiederholen + Telefonuhr geht falsch + automatische Zeit einschalten und wiederholen + Zu häufig + in einer Stunde erneut versuchen + Protokoll zu groß + per E-Mail senden + Anfrage abgelehnt + Protokoll per E-Mail senden + Das Protokoll lässt sich wie bisher auch per E-Mail senden. + Wiederholen + Per E-Mail senden + Bericht gesendet, Code %1$s + Nennen Sie diesen Code dem Support, damit er Ihr Protokoll findet. + diff --git a/src/frontends/android/app/src/main/res/values-de/strings_updates.xml b/src/frontends/android/app/src/main/res/values-de/strings_updates.xml new file mode 100644 index 000000000..b6ecf735e --- /dev/null +++ b/src/frontends/android/app/src/main/res/values-de/strings_updates.xml @@ -0,0 +1,72 @@ + + + + Installiert: %1$s · %2$s + Ansehen und installieren + Automatisch + Selbst prüfen + Still installieren + Über WLAN laden und installieren, wenn das VPN aus ist + Kanal + Stabil + Geprüfte Versionen + Beta + Früher, aber mit Fehlern + Die Beta wird mit einem Einladungscode des Administrators freigeschaltet. + Einladungscode für die Beta + Gib den Code ein, den du vom Administrator erhalten hast. Ohne gültigen Code bleibt die App bei stabilen Versionen. + Einladungscode + Beta einschalten + Jetzt prüfen + Letzte Prüfung: %1$s + Noch nicht geprüft + Installiert: %1$s · %2$s · die Datei wird vor der Installation geprüft + Die App prüft die Prüfsumme und die SHX-Signatur. Eine gefälschte Datei wird nicht installiert. + %1$s wird geladen + Du kannst dieses Fenster schließen: Der Download läuft weiter + %1$d%% · %2$s von %3$s + Versionsliste erhalten + Über einen geschützten Kanal + Datei wird geladen + Datei wird geprüft + Prüfsumme und SHX-Signatur + Installation + Android fragt nach einer Bestätigung + Abbrechen + SHX VPN muss aktualisiert werden + Version %1$s wird nicht mehr unterstützt + Was das bedeutet + Der Server nimmt keine Verbindungen der alten Version mehr an + Installiere %1$s (%2$s), deine Server und Einstellungen bleiben erhalten + Dieses Fenster lässt sich erst nach dem Update schließen. + App beenden + Update nicht installiert + Die Datei hat die Prüfung nicht bestanden + Die Datei wurde nicht geladen + Android hat die Datei nicht installiert + Etwas ist schiefgelaufen + Was passiert ist + Prüfsumme stimmt nicht + die Datei wurde unterwegs beschädigt, die Kopie ist gelöscht + Signatur stimmt nicht + die Datei ist nicht von SHX signiert, die Kopie ist gelöscht + Keine Verbindung zum Update-Server + der Download wurde unterbrochen + Android hat die Installation abgelehnt + das Installationsprogramm meldete einen Fehler + Unerwarteter Fehler + Details für den Support findest du unten + Version unverändert + du hast weiterhin %1$s + Versuche es später erneut + oder wechsle das Netz: WLAN statt Mobilfunk + Du kannst die App manuell über die Download-Seite installieren. Deine Server und Einstellungen bleiben erhalten. + Wiederholen + Von der Website laden + Details für den Support + Kopiert + + Alle %d Stunde, während das Telefon lädt + Alle %d Stunden, während das Telefon lädt + + diff --git a/src/frontends/android/app/src/main/res/values-pl/strings_reports.xml b/src/frontends/android/app/src/main/res/values-pl/strings_reports.xml new file mode 100644 index 000000000..22336ad1b --- /dev/null +++ b/src/frontends/android/app/src/main/res/values-pl/strings_reports.xml @@ -0,0 +1,44 @@ + + + + Raport trafi do zespołu SHX bezpiecznym kanałem. Po kodzie raportu wsparcie znajdzie Twój dziennik. + Co trafi do raportu + Dziennik połączenia z dzisiaj + Wersja aplikacji i model telefonu + Typ sieci i aktywny serwer + Adresy IP serwerów z dziennika + Dołącz diagnostykę + Wyniki sprawdzenia sieci i rozszerzony dziennik aplikacji + Co się stało (opcjonalnie) + Anuluj + Wyślij + Wyślij e-mailem + Raport wysłany + Podaj ten kod wsparciu + Kod raportu + Kopiuj kod + Kod skopiowany + Kod zapisano w „Zdarzeniach”, można go znaleźć później. + Gotowe + Raport nie został wysłany + Serwer nie odpowiedział + Serwer odrzucił raport + Możliwe przyczyny + Brak internetu + sprawdź sieć i ponów + Serwer niedostępny + ponów za kilka minut + Zły czas w telefonie + włącz automatyczny czas i ponów + Zbyt często + spróbuj za godzinę + Dziennik jest za duży + wyślij go e-mailem + Żądanie odrzucone + wyślij dziennik e-mailem + Dziennik można też wysłać e-mailem, jak dotąd. + Ponów + Wyślij e-mailem + Raport wysłany, kod %1$s + Podaj ten kod wsparciu, aby znalazło Twój dziennik. + diff --git a/src/frontends/android/app/src/main/res/values-pl/strings_updates.xml b/src/frontends/android/app/src/main/res/values-pl/strings_updates.xml new file mode 100644 index 000000000..0d209a321 --- /dev/null +++ b/src/frontends/android/app/src/main/res/values-pl/strings_updates.xml @@ -0,0 +1,74 @@ + + + + Masz %1$s · %2$s + Zobacz i zainstaluj + Automatycznie + Sprawdzaj samodzielnie + Instaluj po cichu + Pobierz przez Wi-Fi i zainstaluj, gdy VPN jest wyłączony + Kanał + Stabilny + Sprawdzone wersje + Beta + Wcześniej, ale z błędami + Beta jest dostępna z kodem zaproszenia od administratora. + Kod zaproszenia do bety + Wpisz kod od administratora. Bez prawidłowego kodu aplikacja zostanie na stabilnych wersjach. + Kod zaproszenia + Włącz betę + Sprawdź teraz + Ostatnie sprawdzenie: %1$s + Jeszcze nie sprawdzano + Masz %1$s · %2$s · plik sprawdzimy przed instalacją + Aplikacja sprawdzi sumę kontrolną i podpis SHX. Podrobiony plik się nie zainstaluje. + Pobieramy %1$s + Możesz zamknąć to okno: pobieranie będzie trwać + %1$d%% · %2$s z %3$s + Lista wersji pobrana + Przez chroniony kanał + Pobieranie pliku + Sprawdzanie pliku + Suma kontrolna i podpis SHX + Instalacja + Android poprosi o potwierdzenie + Anuluj + SHX VPN wymaga aktualizacji + Wersja %1$s nie jest już obsługiwana + Co to oznacza + Serwer nie przyjmuje już połączeń ze starej wersji + Zainstaluj %1$s (%2$s), Twoje serwery i ustawienia zostaną zachowane + Tego okna nie można zamknąć, dopóki aplikacja nie zostanie zaktualizowana. + Zamknij aplikację + Aktualizacja nie została zainstalowana + Plik nie przeszedł weryfikacji + Nie udało się pobrać pliku + Android nie zainstalował pliku + Coś poszło nie tak + Co się stało + Suma kontrolna się nie zgadza + plik uszkodził się po drodze, kopia została usunięta + Podpis się nie zgadza + plik nie jest podpisany przez SHX, kopia została usunięta + Brak połączenia z serwerem aktualizacji + pobieranie zostało przerwane + Android odrzucił instalację + instalator systemowy zwrócił błąd + Nieoczekiwany błąd + szczegóły dla wsparcia znajdziesz poniżej + Wersja się nie zmieniła + nadal masz %1$s + Spróbuj później + lub zmień sieć: Wi-Fi zamiast komórkowej + Możesz zainstalować ręcznie ze strony pobierania. Twoje serwery i ustawienia pozostaną bez zmian. + Ponów + Pobierz ze strony + Szczegóły dla wsparcia + Skopiowano + + Co %d godzinę, gdy telefon się ładuje + Co %d godziny, gdy telefon się ładuje + Co %d godzin, gdy telefon się ładuje + Co %d godziny, gdy telefon się ładuje + + diff --git a/src/frontends/android/app/src/main/res/values-ru/strings_reports.xml b/src/frontends/android/app/src/main/res/values-ru/strings_reports.xml new file mode 100644 index 000000000..0cfabbc09 --- /dev/null +++ b/src/frontends/android/app/src/main/res/values-ru/strings_reports.xml @@ -0,0 +1,44 @@ + + + + Отчёт уйдёт команде SHX по защищённому каналу. По коду отчёта поддержка найдёт ваш журнал. + Что войдёт в отчёт + Журнал подключения за сегодня + Версия приложения и модель телефона + Тип сети и активный сервер + IP-адреса серверов из журнала + Приложить диагностику + Результаты проверки сети и расширенный журнал приложения + Что случилось (необязательно) + Отмена + Отправить + Отправить по почте + Отчёт отправлен + Назовите этот код поддержке + Код отчёта + Скопировать код + Код скопирован + Код сохранён в «Событиях», его можно найти позже. + Готово + Отчёт не отправлен + Сервер не ответил + Сервер отклонил отчёт + Возможные причины + Нет интернета + проверьте сеть и повторите + Сервер недоступен + повторите через несколько минут + Часы телефона сбиты + включите автоматическое время и повторите + Слишком часто + попробуйте через час + Журнал слишком большой + отправьте его письмом + Запрос отклонён + отправьте журнал письмом + Журнал можно отправить и письмом, как раньше. + Повторить + Отправить письмом + Отчёт отправлен, код %1$s + Назовите этот код поддержке, чтобы она нашла ваш журнал. + diff --git a/src/frontends/android/app/src/main/res/values-ru/strings_updates.xml b/src/frontends/android/app/src/main/res/values-ru/strings_updates.xml new file mode 100644 index 000000000..eb02f5f6f --- /dev/null +++ b/src/frontends/android/app/src/main/res/values-ru/strings_updates.xml @@ -0,0 +1,74 @@ + + + + У вас %1$s · %2$s + Посмотреть и установить + Автоматически + Проверять сами + Ставить тихо + Скачать по Wi-Fi и установить, когда VPN выключен + Канал + Стабильный + Проверенные версии + Бета + Раньше, но с ошибками + Бета открывается по коду приглашения от администратора. + Код приглашения в бету + Введите код, который дал администратор. Без верного кода приложение останется на стабильных версиях. + Код приглашения + Включить бету + Проверить сейчас + Последняя проверка: %1$s + Ещё не проверяли + У вас %1$s · %2$s · файл проверим перед установкой + Приложение сверит контрольную сумму и подпись SHX. Подделанный файл не установится. + Скачиваем %1$s + Можно закрыть шторку: загрузка продолжится + %1$d%% · %2$s из %3$s + Список версий получен + По защищённому каналу + Скачивание файла + Проверка файла + Контрольная сумма и подпись SHX + Установка + Android спросит подтверждение + Отменить + Нужно обновить SHX VPN + Версия %1$s больше не поддерживается + Что это значит + Сервер больше не принимает подключения со старой версии + Установите %1$s (%2$s), ваши серверы и настройки сохранятся + Закрыть эту шторку нельзя, пока приложение не обновлено. + Выйти из приложения + Обновление не установлено + Файл не прошёл проверку + Файл не скачался + Android не установил файл + Что-то пошло не так + Что произошло + Контрольная сумма не совпала + файл повреждён по дороге, копия удалена + Подпись не совпала + файл подписан не SHX, копия удалена + Нет связи с сервером обновлений + загрузка прервалась + Android отклонил установку + системный установщик вернул ошибку + Непредвиденная ошибка + подробности для поддержки — ниже + Версия не изменилась + у вас по-прежнему %1$s + Попробуйте позже + или смените сеть: Wi-Fi вместо мобильной + Установить вручную можно со страницы скачивания. Ваши серверы и настройки останутся на месте. + Повторить + Скачать с сайта + Подробности для поддержки + Скопировано + + Раз в %d час, когда телефон на зарядке + Раз в %d часа, когда телефон на зарядке + Раз в %d часов, когда телефон на зарядке + Раз в %d часа, когда телефон на зарядке + + diff --git a/src/frontends/android/app/src/main/res/values-uk/strings_reports.xml b/src/frontends/android/app/src/main/res/values-uk/strings_reports.xml new file mode 100644 index 000000000..969f6b093 --- /dev/null +++ b/src/frontends/android/app/src/main/res/values-uk/strings_reports.xml @@ -0,0 +1,44 @@ + + + + Звіт піде команді SHX захищеним каналом. За кодом звіту підтримка знайде ваш журнал. + Що увійде у звіт + Журнал підключення за сьогодні + Версія застосунку й модель телефона + Тип мережі й активний сервер + IP-адреси серверів із журналу + Додати діагностику + Результати перевірки мережі та розширений журнал застосунку + Що сталося (необов’язково) + Скасувати + Надіслати + Надіслати поштою + Звіт надіслано + Назвіть цей код підтримці + Код звіту + Скопіювати код + Код скопійовано + Код збережено в «Подіях», його можна знайти пізніше. + Готово + Звіт не надіслано + Сервер не відповів + Сервер відхилив звіт + Можливі причини + Немає інтернету + перевірте мережу й повторіть + Сервер недоступний + повторіть за кілька хвилин + Годинник телефона збитий + увімкніть автоматичний час і повторіть + Надто часто + спробуйте за годину + Журнал завеликий + надішліть його листом + Запит відхилено + надішліть журнал листом + Журнал можна надіслати й листом, як раніше. + Повторити + Надіслати листом + Звіт надіслано, код %1$s + Назвіть цей код підтримці, щоб вона знайшла ваш журнал. + diff --git a/src/frontends/android/app/src/main/res/values-uk/strings_updates.xml b/src/frontends/android/app/src/main/res/values-uk/strings_updates.xml new file mode 100644 index 000000000..d2e3a4723 --- /dev/null +++ b/src/frontends/android/app/src/main/res/values-uk/strings_updates.xml @@ -0,0 +1,74 @@ + + + + У вас %1$s · %2$s + Переглянути й установити + Автоматично + Перевіряти самостійно + Установлювати тихо + Завантажити через Wi-Fi й установити, коли VPN вимкнено + Канал + Стабільний + Перевірені версії + Бета + Раніше, але з помилками + Бета відкривається за кодом запрошення від адміністратора. + Код запрошення до бети + Введіть код, який дав адміністратор. Без правильного коду застосунок залишиться на стабільних версіях. + Код запрошення + Увімкнути бету + Перевірити зараз + Остання перевірка: %1$s + Ще не перевіряли + У вас %1$s · %2$s · файл перевіримо перед установленням + Застосунок звірить контрольну суму й підпис SHX. Підроблений файл не встановиться. + Завантажуємо %1$s + Можна закрити шторку: завантаження триватиме + %1$d%% · %2$s з %3$s + Список версій отримано + Через захищений канал + Завантаження файлу + Перевірка файлу + Контрольна сума й підпис SHX + Установлення + Android попросить підтвердження + Скасувати + Потрібно оновити SHX VPN + Версія %1$s більше не підтримується + Що це означає + Сервер більше не приймає підключення зі старої версії + Установіть %1$s (%2$s), ваші сервери й налаштування збережуться + Цю шторку не можна закрити, доки застосунок не оновлено. + Вийти із застосунку + Оновлення не встановлено + Файл не пройшов перевірку + Файл не завантажився + Android не встановив файл + Щось пішло не так + Що сталося + Контрольна сума не збіглася + файл пошкоджено дорогою, копію видалено + Підпис не збігся + файл підписано не SHX, копію видалено + Немає зв’язку із сервером оновлень + завантаження перервалося + Android відхилив установлення + системний установник повернув помилку + Непередбачена помилка + подробиці для підтримки — нижче + Версія не змінилася + у вас і далі %1$s + Спробуйте пізніше + або змініть мережу: Wi-Fi замість мобільної + Установити вручну можна зі сторінки завантаження. Ваші сервери й налаштування залишаться на місці. + Повторити + Завантажити із сайту + Подробиці для підтримки + Скопійовано + + Раз на %d годину, коли телефон заряджається + Раз на %d години, коли телефон заряджається + Раз на %d годин, коли телефон заряджається + Раз на %d години, коли телефон заряджається + + diff --git a/src/frontends/android/app/src/main/res/values-zh-rCN/strings_reports.xml b/src/frontends/android/app/src/main/res/values-zh-rCN/strings_reports.xml new file mode 100644 index 000000000..d6623912b --- /dev/null +++ b/src/frontends/android/app/src/main/res/values-zh-rCN/strings_reports.xml @@ -0,0 +1,44 @@ + + + + 报告将通过加密通道发送给 SHX 团队。客服凭报告代码即可找到你的日志。 + 报告包含的内容 + 今天的连接日志 + 应用版本和手机型号 + 网络类型和当前服务器 + 日志中的服务器 IP 地址 + 附加诊断信息 + 网络检查结果和应用的详细日志 + 发生了什么(可选) + 取消 + 发送 + 通过邮件发送 + 报告已发送 + 请把此代码告诉客服 + 报告代码 + 复制代码 + 代码已复制 + 代码已保存在“事件”中,之后可以在那里找到。 + 完成 + 报告未发送 + 服务器没有响应 + 服务器拒绝了报告 + 可能的原因 + 没有网络 + 检查网络后重试 + 服务器不可用 + 几分钟后重试 + 手机时间不准 + 开启自动时间后重试 + 过于频繁 + 一小时后再试 + 日志过大 + 请通过邮件发送 + 请求被拒绝 + 请通过邮件发送日志 + 也可以像以前一样通过邮件发送日志。 + 重试 + 通过邮件发送 + 报告已发送,代码 %1$s + 把此代码告诉客服,以便找到你的日志。 + diff --git a/src/frontends/android/app/src/main/res/values-zh-rCN/strings_updates.xml b/src/frontends/android/app/src/main/res/values-zh-rCN/strings_updates.xml new file mode 100644 index 000000000..ba713a64f --- /dev/null +++ b/src/frontends/android/app/src/main/res/values-zh-rCN/strings_updates.xml @@ -0,0 +1,71 @@ + + + + 当前 %1$s · %2$s + 查看并安装 + 自动 + 自动检查 + 静默安装 + 通过 Wi-Fi 下载,并在 VPN 关闭时安装 + 渠道 + 稳定版 + 经过测试的版本 + 测试版 + 更早获得,但可能有错误 + 测试版需要管理员提供的邀请码。 + 测试版邀请码 + 输入管理员提供的邀请码。没有有效的邀请码时,应用仍使用稳定版。 + 邀请码 + 启用测试版 + 立即检查 + 上次检查:%1$s + 尚未检查 + 当前 %1$s · %2$s · 安装前会校验文件 + 应用会校验校验和与 SHX 签名,伪造的文件无法安装。 + 正在下载 %1$s + 可以关闭此面板,下载会继续 + %1$d%% · %2$s / %3$s + 已获取版本列表 + 通过受保护的通道 + 下载文件 + 校验文件 + 校验和与 SHX 签名 + 安装 + Android 会请求确认 + 取消 + 需要更新 SHX VPN + 版本 %1$s 已不再受支持 + 这意味着什么 + 服务器不再接受旧版本的连接 + 安装 %1$s(%2$s),你的服务器和设置会保留 + 应用更新前无法关闭此面板。 + 退出应用 + 更新未安装 + 文件未通过校验 + 文件未能下载 + Android 未安装该文件 + 出现问题 + 发生了什么 + 校验和不匹配 + 文件在传输中损坏,副本已删除 + 签名不匹配 + 文件不是由 SHX 签名的,副本已删除 + 无法连接更新服务器 + 下载已中断 + Android 拒绝了安装 + 系统安装程序返回了错误 + 意外错误 + 可在下方复制给支持人员的详细信息 + 版本未变化 + 当前仍为 %1$s + 请稍后再试 + 或切换网络:用 Wi-Fi 代替移动数据 + 也可以从下载页面手动安装,你的服务器和设置会保留。 + 重试 + 从网站下载 + 复制给支持人员的详细信息 + 已复制 + + 手机充电时每 %d 小时检查一次 + + diff --git a/src/frontends/android/app/src/main/res/values-zh-rTW/strings_reports.xml b/src/frontends/android/app/src/main/res/values-zh-rTW/strings_reports.xml new file mode 100644 index 000000000..cafb119c2 --- /dev/null +++ b/src/frontends/android/app/src/main/res/values-zh-rTW/strings_reports.xml @@ -0,0 +1,44 @@ + + + + 報告將透過加密通道傳送給 SHX 團隊。客服憑報告代碼即可找到你的日誌。 + 報告包含的內容 + 今天的連線日誌 + 應用程式版本和手機型號 + 網路類型和目前伺服器 + 日誌中的伺服器 IP 位址 + 附加診斷資訊 + 網路檢查結果和應用程式的詳細日誌 + 發生了什麼(選填) + 取消 + 傳送 + 透過電子郵件傳送 + 報告已傳送 + 請把此代碼告訴客服 + 報告代碼 + 複製代碼 + 代碼已複製 + 代碼已儲存在「事件」中,之後可以在那裡找到。 + 完成 + 報告未傳送 + 伺服器沒有回應 + 伺服器拒絕了報告 + 可能的原因 + 沒有網路 + 檢查網路後重試 + 伺服器無法使用 + 幾分鐘後重試 + 手機時間不準 + 開啟自動時間後重試 + 過於頻繁 + 一小時後再試 + 日誌過大 + 請透過電子郵件傳送 + 要求被拒絕 + 請透過電子郵件傳送日誌 + 也可以像以前一樣透過電子郵件傳送日誌。 + 重試 + 透過電子郵件傳送 + 報告已傳送,代碼 %1$s + 把此代碼告訴客服,以便找到你的日誌。 + diff --git a/src/frontends/android/app/src/main/res/values-zh-rTW/strings_updates.xml b/src/frontends/android/app/src/main/res/values-zh-rTW/strings_updates.xml new file mode 100644 index 000000000..74341dff1 --- /dev/null +++ b/src/frontends/android/app/src/main/res/values-zh-rTW/strings_updates.xml @@ -0,0 +1,71 @@ + + + + 目前 %1$s · %2$s + 檢視並安裝 + 自動 + 自動檢查 + 靜默安裝 + 透過 Wi-Fi 下載,並在 VPN 關閉時安裝 + 頻道 + 穩定版 + 經過測試的版本 + 測試版 + 更早取得,但可能有錯誤 + 測試版需要管理員提供的邀請碼。 + 測試版邀請碼 + 輸入管理員提供的邀請碼。沒有有效的邀請碼時,應用程式仍使用穩定版。 + 邀請碼 + 啟用測試版 + 立即檢查 + 上次檢查:%1$s + 尚未檢查 + 目前 %1$s · %2$s · 安裝前會驗證檔案 + 應用程式會驗證檢查碼與 SHX 簽章,偽造的檔案無法安裝。 + 正在下載 %1$s + 可以關閉此面板,下載會繼續 + %1$d%% · %2$s / %3$s + 已取得版本清單 + 透過受保護的通道 + 下載檔案 + 驗證檔案 + 檢查碼與 SHX 簽章 + 安裝 + Android 會要求確認 + 取消 + 需要更新 SHX VPN + 版本 %1$s 已不再支援 + 這代表什麼 + 伺服器不再接受舊版本的連線 + 安裝 %1$s(%2$s),你的伺服器和設定會保留 + 應用程式更新前無法關閉此面板。 + 結束應用程式 + 更新未安裝 + 檔案未通過驗證 + 檔案未能下載 + Android 未安裝該檔案 + 發生問題 + 發生了什麼 + 檢查碼不相符 + 檔案在傳輸中損壞,副本已刪除 + 簽章不相符 + 檔案不是由 SHX 簽署,副本已刪除 + 無法連線到更新伺服器 + 下載已中斷 + Android 拒絕了安裝 + 系統安裝程式傳回了錯誤 + 非預期的錯誤 + 可在下方複製給支援人員的詳細資訊 + 版本未變更 + 目前仍為 %1$s + 請稍後再試 + 或切換網路:用 Wi-Fi 取代行動數據 + 也可以從下載頁面手動安裝,你的伺服器和設定會保留。 + 重試 + 從網站下載 + 複製給支援人員的詳細資訊 + 已複製 + + 手機充電時每 %d 小時檢查一次 + + diff --git a/src/frontends/android/app/src/main/res/values/strings_reports.xml b/src/frontends/android/app/src/main/res/values/strings_reports.xml new file mode 100644 index 000000000..7421021b3 --- /dev/null +++ b/src/frontends/android/app/src/main/res/values/strings_reports.xml @@ -0,0 +1,44 @@ + + + + The report goes to the SHX team over a secure channel. With the report code, support will find your log. + What the report includes + Today’s connection log + App version and phone model + Network type and active server + Server IP addresses from the log + Attach diagnostics + Network check results and the app’s extended log + What happened (optional) + Cancel + Send + Send by email + Report sent + Tell this code to support + Report code + Copy code + Code copied + The code is saved in “Events”, you can find it there later. + Done + Report not sent + The server did not answer + The server refused the report + Possible reasons + No internet + check the network and retry + Server unavailable + retry in a few minutes + Phone clock is off + turn on automatic time and retry + Too often + try again in an hour + Log is too large + send it by email + Request refused + send the log by email + The log can also be sent by email, as before. + Retry + Send by email + Report sent, code %1$s + Tell this code to support so they can find your log. + diff --git a/src/frontends/android/app/src/main/res/values/strings_updates.xml b/src/frontends/android/app/src/main/res/values/strings_updates.xml new file mode 100644 index 000000000..3ff93a756 --- /dev/null +++ b/src/frontends/android/app/src/main/res/values/strings_updates.xml @@ -0,0 +1,72 @@ + + + + You have %1$s · %2$s + View and install + Automatically + Check by itself + Install quietly + Download over Wi-Fi and install while the VPN is off + Channel + Stable + Tested versions + Beta + Earlier, but with bugs + Beta opens with an invitation code from the administrator. + Beta invitation code + Enter the code the administrator gave you. Without a valid code the app stays on stable versions. + Invitation code + Turn on beta + Check now + Last check: %1$s + Not checked yet + You have %1$s · %2$s · the file is checked before installing + The app checks the checksum and the SHX signature. A forged file will not install. + Downloading %1$s + You can close this sheet: the download goes on + %1$d%% · %2$s of %3$s + Version list received + Over a protected channel + Downloading the file + Checking the file + Checksum and SHX signature + Installation + Android will ask for confirmation + Cancel + SHX VPN needs an update + Version %1$s is no longer supported + What this means + The server no longer accepts connections from the old version + Install %1$s (%2$s): your servers and settings stay + This sheet can’t be closed until the app is updated. + Exit the app + Update not installed + The file did not pass the check + The file did not download + Android did not install the file + Something went wrong + What happened + Checksum mismatch + the file was damaged on the way, the copy is deleted + Signature mismatch + the file is not signed by SHX, the copy is deleted + No connection to the update server + the download was interrupted + Android rejected the installation + the system installer returned an error + Unexpected error + the details for support are below + Version unchanged + you still have %1$s + Try again later + or switch networks: Wi-Fi instead of mobile data + You can install it manually from the download page. Your servers and settings stay in place. + Retry + Download from the site + Details for support + Copied + + Every %d hour while the phone is charging + Every %d hours while the phone is charging + + diff --git a/src/frontends/android/app/src/test/java/org/strongswan/android/logic/updates/ReportContentTest.kt b/src/frontends/android/app/src/test/java/org/strongswan/android/logic/updates/ReportContentTest.kt new file mode 100644 index 000000000..4012cb27e --- /dev/null +++ b/src/frontends/android/app/src/test/java/org/strongswan/android/logic/updates/ReportContentTest.kt @@ -0,0 +1,172 @@ +/* + * Copyright (C) 2026 SHX VPN + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the + * Free Software Foundation; either version 2 of the License, or (at your + * option) any later version. See . + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + */ + +package org.strongswan.android.logic.updates + +import org.assertj.core.api.Assertions.assertThat +import org.json.JSONObject +import org.junit.Test +import java.io.File + +class ReportContentTest +{ + @Test + fun checksGoBetweenTheLogAndDiagAsAJsonArray() + { + val checks = listOf( + ReportContent.Check("Интернет", "Wi-Fi", true), + ReportContent.Check("Сервер \"nl1\"", "нет ответа\n15 с", false), + ReportContent.Check("DNS", "проверяется", null), + ) + val text = ReportContent.build("a\n", "d\n", checks) + assertThat(text).isEqualTo( + "=== charon.log ===\na\n\n=== checks.json ===\n" + + "[{\"title\":\"Интернет\",\"value\":\"Wi-Fi\",\"ok\":true}," + + "{\"title\":\"Сервер \\\"nl1\\\"\",\"value\":\"нет ответа\\n15 с\",\"ok\":false}," + + "{\"title\":\"DNS\",\"value\":\"проверяется\",\"ok\":null}]\n" + + "\n=== diag.log ===\nd\n" + ) + assertThat(ReportContent.build("a\n", null, emptyList())).doesNotContain("checks.json") + } + + @Test + fun apiBaseAddsTheApiPrefixOnlyWhenMissing() + { + /* the proxy routes only /api/v1/ and the HMAC covers the full path */ + assertThat(ReportUploader.apiBase("https://get.shnt.top")).isEqualTo("https://get.shnt.top/api/v1") + assertThat(ReportUploader.apiBase("https://get.shnt.top/")).isEqualTo("https://get.shnt.top/api/v1") + assertThat(ReportUploader.apiBase("https://get.shnt.top/api/v1/")).isEqualTo("https://get.shnt.top/api/v1") + assertThat(java.net.URL(ReportUploader.apiBase("http://127.0.0.1:4000") + "/reports").path).isEqualTo("/api/v1/reports") + } + + @Test + fun parseResponseAcceptsOnlyA201WithAValidCode() + { + assertThat(ReportUploader.parseResponse(201, """{"code":"SHX-7F3K2"}""")).isEqualTo(ReportUploader.Result.Sent("SHX-7F3K2")) + val broken = ReportUploader.parseResponse(201, "proxy") + assertThat(broken).isInstanceOf(ReportUploader.Result.Failed::class.java) + assertThat((broken as ReportUploader.Result.Failed).httpCode).isEqualTo(201) + assertThat(ReportUploader.parseResponse(201, """{"code":"nope"}""")).isInstanceOf(ReportUploader.Result.Failed::class.java) + val limited = ReportUploader.parseResponse(429, """{"error":{"code":"rate_limited"}}""") as ReportUploader.Result.Failed + assertThat(limited.httpCode).isEqualTo(429) + assertThat(limited.detail).contains("rate_limited") + } + + @Test + fun failureMapsServerAnswersToReasons() + { + assertThat(ReportContent.failure(0)).isEqualTo(ReportContent.Failure.NO_NETWORK) + assertThat(ReportContent.failure(401)).isEqualTo(ReportContent.Failure.SIGNATURE) + assertThat(ReportContent.failure(413)).isEqualTo(ReportContent.Failure.TOO_LARGE) + assertThat(ReportContent.failure(429)).isEqualTo(ReportContent.Failure.TOO_OFTEN) + assertThat(ReportContent.failure(502)).isEqualTo(ReportContent.Failure.SERVER_DOWN) + assertThat(ReportContent.failure(404)).isEqualTo(ReportContent.Failure.SERVER_DOWN) + assertThat(ReportContent.failure(400)).isEqualTo(ReportContent.Failure.REJECTED) + assertThat(ReportContent.failure(201)).isEqualTo(ReportContent.Failure.REJECTED) + } + + @Test + fun todayOnlyKeepsTodaysLinesWithTheirContinuations() + { + val log = """ + Nov 7 23:59:58 06[IKE] old line + old continuation + Nov 8 00:00:01 06[IKE] initiating IKE_SA + payload continuation + Nov 8 14:02:30 07[IKE] CHILD_SA established + """.trimIndent() + assertThat(ReportContent.todayOnly(log, 11, 8)).isEqualTo( + "Nov 8 00:00:01 06[IKE] initiating IKE_SA\n payload continuation\nNov 8 14:02:30 07[IKE] CHILD_SA established\n" + ) + assertThat(ReportContent.todayOnly("Dec 18 10:00:00 01[IKE] two-digit day\n", 12, 18)).contains("two-digit day") + } + + @Test + fun todayOnlyFallsBackToTheWholeLogWhenNothingIsFromToday() + { + val log = "Nov 7 10:00:00 06[IKE] yesterday\n" + assertThat(ReportContent.todayOnly(log, 11, 8)).isEqualTo(log) + /* a cut log starts with an undated mark: it alone must not count as "today" */ + val cut = ReportContent.CUT_MARK + log + assertThat(ReportContent.todayOnly(cut, 11, 8)).isEqualTo(cut) + } + + @Test + fun tailBytesCutsTheStartAtALineBoundaryAndKeepsCyrillicWhole() + { + val text = (1..200).joinToString("") { "строка $it: нет ответа\n" } + val cut = ReportContent.tailBytes(text, 1000) + assertThat(cut).startsWith(ReportContent.CUT_MARK) + val body = cut.removePrefix(ReportContent.CUT_MARK) + assertThat(body.toByteArray(Charsets.UTF_8).size).isLessThanOrEqualTo(1000) + assertThat(body).startsWith("строка ").endsWith("строка 200: нет ответа\n") + assertThat(body).doesNotContain("�") + assertThat(ReportContent.tailBytes("short\n", 1000)).isEqualTo("short\n") + } + + @Test + fun tailBytesOfOneHugeLineDoesNotSplitACharacter() + { + val cut = ReportContent.tailBytes("ж".repeat(1000), 101).removePrefix(ReportContent.CUT_MARK) + assertThat(cut).doesNotContain("�") + assertThat(cut).isEqualTo("ж".repeat(50)) + } + + @Test + fun readTailDropsThePartialFirstLine() + { + val file = File.createTempFile("charon", ".log") + try + { + file.writeText("first line\nsecond line\nthird line\n") + assertThat(ReportContent.readTail(file, 1000)).isEqualTo("first line\nsecond line\nthird line\n") + assertThat(ReportContent.readTail(file, 15)).isEqualTo(ReportContent.CUT_MARK + "third line\n") + assertThat(ReportContent.readTail(File(file.path + ".missing"), 100)).isEmpty() + } + finally + { + file.delete() + } + } + + @Test + fun buildAddsDiagOnlyWhenAskedAndStaysWithinTheLimits() + { + val plain = ReportContent.build("Nov 8 14:02:31 08[IKE] tunnel up\n", null) + assertThat(plain).isEqualTo("=== charon.log ===\nNov 8 14:02:31 08[IKE] tunnel up\n") + val withDiag = ReportContent.build("", "11-08 14:02:00.000 Watchdog: ok\n") + assertThat(withDiag).contains("=== charon.log ===\n(empty)").contains("=== diag.log ===\n11-08 14:02:00.000 Watchdog: ok") + val huge = "x".repeat(99) + "\n" + val big = ReportContent.build(huge.repeat(40_000), huge.repeat(10_000)) + assertThat(big.toByteArray(Charsets.UTF_8).size).isLessThanOrEqualTo(ReportContent.CHARON_MAX_BYTES + ReportContent.DIAG_MAX_BYTES + 200) + /* what the panel unpacks for the report card (5 MB cap) */ + assertThat(big.length).isLessThan(5 * 1024 * 1024) + } + + @Test + fun metaDescribesThePhoneAndPassesTheServerSchema() + { + val meta = ReportContent.meta("2.19.2", 170, "Google", "Pixel 7", "14", 34, "Wi-Fi", " Не подключается в метро ", "nl1 · nl1.shx.one") + assertThat(meta.device).isEqualTo("Google Pixel 7") + assertThat(ReportContent.meta("2.19.2", 170, "samsung", "SAMSUNG SM-S911B", "14", 34, "mobile", "", "").device).isEqualTo("SAMSUNG SM-S911B") + assertThat(meta.os).isEqualTo("Android 14 (API 34)") + assertThat(meta.comment).isEqualTo("Не подключается в метро") + val json = JSONObject(ReportUploader.metaJson(meta)) + /* ReportMetaSchema: source is "log" | "diagnostics", versionCode an integer */ + assertThat(json.getString("source")).isEqualTo("log") + assertThat(json.getInt("versionCode")).isEqualTo(170) + assertThat(json.getString("network")).isEqualTo("Wi-Fi") + assertThat(json.getString("profile")).isEqualTo("nl1 · nl1.shx.one") + } +} diff --git a/src/frontends/android/app/src/test/java/org/strongswan/android/logic/updates/ReportUploaderTest.kt b/src/frontends/android/app/src/test/java/org/strongswan/android/logic/updates/ReportUploaderTest.kt new file mode 100644 index 000000000..69b62d77d --- /dev/null +++ b/src/frontends/android/app/src/test/java/org/strongswan/android/logic/updates/ReportUploaderTest.kt @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2026 SHX VPN + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the + * Free Software Foundation; either version 2 of the License, or (at your + * option) any later version. See . + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + */ + +package org.strongswan.android.logic.updates + +import org.assertj.core.api.Assertions.assertThat +import org.json.JSONObject +import org.junit.Test +import java.io.ByteArrayInputStream +import java.util.zip.GZIPInputStream + +class ReportUploaderTest +{ + private val meta = ReportUploader.Meta("2.19.1", 169, "Pixel 7", "Android 14", "LTE", "log", "Не подключается", "nl1") + + @Test + fun gzipRoundTripsTheLogAndStartsWithTheGzipMagic() + { + val gz = ReportUploader.gzip("09:38:11 IKE_SA_INIT → nl1 :4500\n") + assertThat(gz[0]).isEqualTo(0x1f.toByte()) + assertThat(gz[1]).isEqualTo(0x8b.toByte()) + assertThat(GZIPInputStream(ByteArrayInputStream(gz)).readBytes().decodeToString()).isEqualTo("09:38:11 IKE_SA_INIT → nl1 :4500\n") + } + + @Test + fun metaJsonCarriesTheFieldsTheServerValidatesAndTruncatesLongOnes() + { + val json = JSONObject(ReportUploader.metaJson(meta.copy(comment = "x".repeat(2000), device = "d".repeat(500)))) + assertThat(json.getString("versionName")).isEqualTo("2.19.1") + assertThat(json.getInt("versionCode")).isEqualTo(169) + assertThat(json.getString("comment")).hasSize(1000) + assertThat(json.getString("device")).hasSize(120) + assertThat(json.getString("source")).isEqualTo("log") + } + + @Test + fun multipartBodyHasTheLogAndMetaPartsInTheExpectedShape() + { + val body = ReportUploader.multipartBody(byteArrayOf(0x1f, 0x8b.toByte(), 1, 2), "{\"a\":1}").decodeToString(throwOnInvalidSequence = false) + assertThat(body).contains("name=\"log\"; filename=\"log.gz\"") + assertThat(body).contains("name=\"meta\"") + assertThat(body).contains("{\"a\":1}") + /* the meta part must stay a plain field (see multipartBody) */ + assertThat(body.substringAfter("name=\"meta\"").substringBefore("{")).doesNotContain("Content-Type") + assertThat(body).endsWith("--${ReportUploader.BOUNDARY}--\r\n") + } + + @Test + fun anOversizedReportIsRefusedBeforeAnyRequest() + { + /* 62 symbols are ~6 bits each: 4 MB of text stays above 2 MB after gzip */ + val alphabet = ('a'..'z') + ('A'..'Z') + ('0'..'9') + val huge = java.util.Random(1).let { r -> buildString { repeat(4 * 1024 * 1024) { append(alphabet[r.nextInt(alphabet.size)]) } } } + val result = ReportUploader("https://api.example/api/v1", "k", "install-0001").send(huge, meta) + assertThat(result).isInstanceOf(ReportUploader.Result.Failed::class.java) + assertThat((result as ReportUploader.Result.Failed).httpCode).isEqualTo(413) + } +} diff --git a/src/frontends/android/app/src/test/java/org/strongswan/android/logic/updates/UpdateApiTest.kt b/src/frontends/android/app/src/test/java/org/strongswan/android/logic/updates/UpdateApiTest.kt new file mode 100644 index 000000000..3e46ed6c4 --- /dev/null +++ b/src/frontends/android/app/src/test/java/org/strongswan/android/logic/updates/UpdateApiTest.kt @@ -0,0 +1,196 @@ +/* + * Copyright (C) 2026 SHX VPN + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the + * Free Software Foundation; either version 2 of the License, or (at your + * option) any later version. See . + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + */ + +package org.strongswan.android.logic.updates + +import org.assertj.core.api.Assertions.assertThat +import org.json.JSONObject +import org.junit.Test +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** + * Checks the client against the shared vectors of the web panel + * (packages/contracts/vectors/signing.json). If the server contract changes, + * the vectors are regenerated there and copied to src/test/resources. + */ +@OptIn(ExperimentalEncodingApi::class) +class UpdateApiTest +{ + private val vectors = JSONObject( + javaClass.classLoader!!.getResourceAsStream("shx-signing-vectors.json")!!.use { it.readBytes().decodeToString() }) + private val publicKeyRaw = vectors.getJSONObject("ed25519").getString("publicKeyRawBase64") + private val manifest = vectors.getJSONObject("manifest") + private val hmacKey = vectors.getJSONObject("request").getString("hmacKey") + + private fun fields(o: JSONObject) = UpdateSigning.Fields( + update = o.getBoolean("update"), + mandatory = o.getBoolean("mandatory"), + versionCode = o.optLong("versionCode", 0L), + versionName = o.optString("versionName", ""), + sha256 = o.optString("sha256", ""), + size = o.optLong("size", 0L), + apkUrl = o.optString("apkUrl", ""), + issuedAt = o.getLong("issuedAt"), + checkHours = o.getInt("checkHours"), + wifiOnly = o.getBoolean("wifiOnly"), + ) + + private fun responseJson(v: JSONObject, patch: (JSONObject) -> Unit = {}): String + { + val f = v.getJSONObject("fields") + val body = JSONObject(f.toString()) + body.put("signature", v.getString("signatureBase64")) + if (f.getBoolean("update")) + { + body.put("notes", JSONObject().put("ru", "Исправления").put("en", "Fixes")) + } + patch(body) + return body.toString() + } + + private class FakeHttp(private val response: UpdateHttp.Response) : UpdateHttp + { + var lastUrl: String? = null + var lastHeaders: Map = emptyMap() + override fun get(url: String, headers: Map): UpdateHttp.Response + { + lastUrl = url + lastHeaders = headers + return response + } + } + + private fun client(http: UpdateHttp, nowSeconds: Long = manifest.getJSONObject("fields").getLong("issuedAt")) = + UpdateApiClient("https://api.example/api/v1", hmacKey, publicKeyRaw, "install-0001", http) { nowSeconds * 1000 } + + @Test + fun manifestSigningInputMatchesTheServerVector() + { + assertThat(UpdateSigning.manifestInput(fields(manifest.getJSONObject("fields")))).isEqualTo(manifest.getString("signingInput")) + val none = vectors.getJSONObject("noUpdate") + assertThat(UpdateSigning.manifestInput(fields(none.getJSONObject("fields")))).isEqualTo(none.getString("signingInput")) + } + + @Test + fun ed25519SignatureFromTheServerVerifiesAndTamperingIsRejected() + { + val key = Base64.Default.decode(publicKeyRaw) + val input = manifest.getString("signingInput") + val signature = manifest.getString("signatureBase64") + assertThat(UpdateSigning.verifyEd25519(key, input, signature)).isTrue() + assertThat(UpdateSigning.verifyEd25519(key, input + "x", signature)).isFalse() + assertThat(UpdateSigning.verifyEd25519(key, input.replace("250003", "250004"), signature)).isFalse() + assertThat(UpdateSigning.verifyEd25519(ByteArray(32), input, signature)).isFalse() + } + + @Test + fun requestSignatureMatchesTheServerVector() + { + val r = vectors.getJSONObject("request") + val input = UpdateSigning.requestInput(r.getString("method"), r.getString("path"), r.getString("timestamp").toLong()) + assertThat(input).isEqualTo(r.getString("signingInput")) + assertThat(UpdateSigning.hmacBase64(hmacKey, input)).isEqualTo(r.getString("signatureBase64")) + } + + @Test + fun aBareHostGetsTheApiPrefix() + { + /* UPDATE_API_URL=https://get.shnt.top (no /api/v1) used to hit /updates/check -> 404 -> silent legacy fallback */ + val http = FakeHttp(UpdateHttp.Response(200, responseJson(manifest))) + val bare = UpdateApiClient("https://api.example/", hmacKey, publicKeyRaw, "install-0001", http) { + manifest.getJSONObject("fields").getLong("issuedAt") * 1000 + } + assertThat(bare.check("one.shx.strongswan.ext", 168, "release", "arm64-v8a", 34, null)).isInstanceOf(UpdateApiResult.Update::class.java) + assertThat(http.lastUrl).startsWith("https://api.example/api/v1/updates/check?") + } + + @Test + fun clientReturnsTheOfferedUpdateAndSendsASignedRequest() + { + val http = FakeHttp(UpdateHttp.Response(200, responseJson(manifest))) + val result = client(http).check("one.shx.strongswan.ext", 168, "release", "arm64-v8a", 34, null) + assertThat(result).isInstanceOf(UpdateApiResult.Update::class.java) + val update = result as UpdateApiResult.Update + assertThat(update.info.versionName).isEqualTo("2.5.0-beta.3") + assertThat(update.info.versionCode).isEqualTo(250003L) + assertThat(update.info.fromApi).isTrue() + assertThat(update.info.notes["ru"]).isEqualTo("Исправления") + assertThat(update.policy).isEqualTo(UpdatePolicy(12, true)) + assertThat(http.lastUrl).startsWith("https://api.example/api/v1/updates/check?package=one.shx.strongswan.ext&versionCode=168") + assertThat(http.lastHeaders["X-Install-Id"]).isEqualTo("install-0001") + val ts = http.lastHeaders["X-Timestamp"]!!.toLong() + assertThat(http.lastHeaders["X-Signature"]).isEqualTo( + UpdateSigning.hmacBase64(hmacKey, UpdateSigning.requestInput("GET", "/api/v1/updates/check", ts))) + } + + @Test + fun clientAcceptsASignedNoUpdateAnswer() + { + val http = FakeHttp(UpdateHttp.Response(200, responseJson(vectors.getJSONObject("noUpdate")))) + val result = client(http).check("one.shx.strongswan.ext", 169, "release", null, 34, null) + assertThat(result).isInstanceOf(UpdateApiResult.NoUpdate::class.java) + } + + @Test + fun tamperedFieldsAreRejectedAsASignatureFailureAndNeverFallBack() + { + for (field in listOf("sha256", "apkUrl", "versionCode", "versionName", "size", "mandatory", "checkHours", "wifiOnly")) + { + val body = responseJson(manifest) { o -> + when (field) + { + "mandatory", "wifiOnly" -> o.put(field, !o.getBoolean(field)) + "versionCode", "size", "checkHours" -> o.put(field, o.getLong(field) + 1) + "sha256" -> o.put(field, "cd".repeat(32)) + else -> o.put(field, o.getString(field) + "x") + } + } + val result = client(FakeHttp(UpdateHttp.Response(200, body))).check("one.shx.strongswan.ext", 1, "release", null, 34, null) + assertThat(result).describedAs(field).isInstanceOf(UpdateApiResult.Failure::class.java) + assertThat((result as UpdateApiResult.Failure).kind).describedAs(field).isEqualTo(UpdateError.SIGNATURE) + } + } + + @Test + fun aStaleManifestIsRejectedEvenWithAValidSignature() + { + val issued = manifest.getJSONObject("fields").getLong("issuedAt") + val result = client(FakeHttp(UpdateHttp.Response(200, responseJson(manifest))), nowSeconds = issued + UpdateApiClient.MAX_SKEW_SECONDS + 60) + .check("one.shx.strongswan.ext", 1, "release", null, 34, null) + assertThat((result as UpdateApiResult.Failure).kind).isEqualTo(UpdateError.SIGNATURE) + } + + @Test + fun aWrongPublicKeyFailsAndServerErrorsAreClassified() + { + val http = FakeHttp(UpdateHttp.Response(200, responseJson(manifest))) + val wrongKey = UpdateApiClient("https://api.example/api/v1", hmacKey, Base64.Default.encode(ByteArray(32) { 7 }), "install-0001", http) { 1760438401_000L } + assertThat((wrongKey.check("p", 1, "release", null, 34, null) as UpdateApiResult.Failure).kind).isEqualTo(UpdateError.SIGNATURE) + // 404/5xx: сеть или сервер, допускается запасной путь; 401/429: нет + assertThat((client(FakeHttp(UpdateHttp.Response(503, ""))).check("p", 1, "release", null, 34, null) as UpdateApiResult.Failure).kind).isEqualTo(UpdateError.NETWORK) + assertThat((client(FakeHttp(UpdateHttp.Response(404, ""))).check("p", 1, "release", null, 34, null) as UpdateApiResult.Failure).kind).isEqualTo(UpdateError.NETWORK) + assertThat((client(FakeHttp(UpdateHttp.Response(401, ""))).check("p", 1, "release", null, 34, null) as UpdateApiResult.Failure).kind).isEqualTo(UpdateError.OTHER) + assertThat((client(FakeHttp(UpdateHttp.Response(429, ""))).check("p", 1, "release", null, 34, null) as UpdateApiResult.Failure).kind).isEqualTo(UpdateError.OTHER) + } + + @Test + fun theClientIsConfiguredOnlyWithAllThreeValues() + { + assertThat(UpdateApiClient.isConfigured("https://api", "key", "pub")).isTrue() + assertThat(UpdateApiClient.isConfigured("", "key", "pub")).isFalse() + assertThat(UpdateApiClient.isConfigured("https://api", "", "pub")).isFalse() + assertThat(UpdateApiClient.isConfigured("https://api", "key", "")).isFalse() + } +} diff --git a/src/frontends/android/app/src/test/java/org/strongswan/android/logic/updates/UpdateNotesTest.kt b/src/frontends/android/app/src/test/java/org/strongswan/android/logic/updates/UpdateNotesTest.kt new file mode 100644 index 000000000..a18a5e4a5 --- /dev/null +++ b/src/frontends/android/app/src/test/java/org/strongswan/android/logic/updates/UpdateNotesTest.kt @@ -0,0 +1,84 @@ +package org.strongswan.android.logic.updates + +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.Test +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.InputStream +import java.security.MessageDigest +import kotlin.coroutines.cancellation.CancellationException + +class UpdateNotesTest +{ + private val notes = mapOf("ru" to "Русский", "en" to "English", "zh-tw" to "繁體", "zh" to "中文") + + @Test + fun picksAppLanguageThenRussianThenEnglish() + { + assertThat(pickNotes(notes, "en-US", "en")).isEqualTo("English") + assertThat(pickNotes(notes, "zh-TW", "zh")).isEqualTo("繁體") + assertThat(pickNotes(notes, "zh-CN", "zh")).isEqualTo("中文") + assertThat(pickNotes(notes, "de-DE", "de")).isEqualTo("Русский") + assertThat(pickNotes(mapOf("en" to "English", "ru" to " "), "de-DE", "de")).isEqualTo("English") + assertThat(pickNotes(emptyMap(), "ru-RU", "ru")).isNull() + } + + @Test + fun everyNonEmptyLineIsAnItem() + { + assertThat(noteLines("Серверы\n\n - История трафика \r\n• Исправлен сбой\n")).containsExactly("Серверы", "История трафика", "Исправлен сбой") + assertThat(noteLines("Одна строка. Без переносов.")).containsExactly("Одна строка. Без переносов.") + assertThat(noteLines(null)).isEmpty() + } + + @Test + fun copiesWithDigestAndProgress() + { + val data = ByteArray(200_000) { it.toByte() } + val out = ByteArrayOutputStream() + val digest = MessageDigest.getInstance("SHA-256") + var last = 0L + val total = copyDownload(ByteArrayInputStream(data), out, digest, { false }) { last = it } + assertThat(total).isEqualTo(data.size.toLong()) + assertThat(last).isEqualTo(data.size.toLong()) + assertThat(out.toByteArray()).isEqualTo(data) + assertThat(digest.digest()).isEqualTo(MessageDigest.getInstance("SHA-256").digest(data)) + } + + @Test + fun cancelStopsBetweenReads() + { + var cancelled = false + val out = ByteArrayOutputStream() + assertThatThrownBy { + copyDownload(ByteArrayInputStream(ByteArray(300_000)), out, MessageDigest.getInstance("SHA-256"), { cancelled }) { cancelled = true } + }.isInstanceOf(CancellationException::class.java) + /* one 64 KiB chunk, then the loop stopped */ + assertThat(out.size()).isEqualTo(64 * 1024) + } + + @Test + fun failedReadAfterCancelIsACancellation() + { + var cancelled = false + /* what disconnect() does to a blocked read */ + val broken = object : InputStream() + { + override fun read(): Int = throw IOException("socket closed") + override fun read(b: ByteArray, off: Int, len: Int): Int + { + cancelled = true + throw IOException("socket closed") + } + } + assertThatThrownBy { + copyDownload(broken, ByteArrayOutputStream(), MessageDigest.getInstance("SHA-256"), { cancelled }) {} + }.isInstanceOf(CancellationException::class.java) + /* without a cancel the network error stays a network error */ + assertThatThrownBy { + copyDownload(object : InputStream() { override fun read(): Int = throw IOException("reset") }, ByteArrayOutputStream(), MessageDigest.getInstance("SHA-256"), { false }) {} + }.isInstanceOf(IOException::class.java) + } +} diff --git a/src/frontends/android/app/src/test/resources/shx-signing-vectors.json b/src/frontends/android/app/src/test/resources/shx-signing-vectors.json new file mode 100644 index 000000000..a625a251b --- /dev/null +++ b/src/frontends/android/app/src/test/resources/shx-signing-vectors.json @@ -0,0 +1,43 @@ +{ + "note": "ТЕСТОВЫЙ ключ, только для проверки формата подписи.", + "ed25519": { + "privateKeyPem": "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIA9Mac/nzlc6O6s6/5p7z3BfaggTulVsGYSLJyiG9/H8\n-----END PRIVATE KEY-----\n", + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEASE5f2zN0uBApPHejtQ7Ud0+AcNN+UjvbBgHYIo/lmnY=\n-----END PUBLIC KEY-----\n", + "publicKeyRawBase64": "SE5f2zN0uBApPHejtQ7Ud0+AcNN+UjvbBgHYIo/lmnY=" + }, + "manifest": { + "fields": { + "update": true, + "mandatory": false, + "versionCode": 250003, + "versionName": "2.5.0-beta.3", + "checkHours": 12, + "wifiOnly": true, + "sha256": "abababababababababababababababababababababababababababababababab", + "size": 38900000, + "apkUrl": "https://s3.example/app.apk?sig=1", + "issuedAt": 1760438401 + }, + "signingInput": "shx-manifest-v1\n1\n0\n250003\n2.5.0-beta.3\nabababababababababababababababababababababababababababababababab\n38900000\nhttps://s3.example/app.apk?sig=1\n1760438401\n12\n1", + "signatureBase64": "KkPqAQX4DwqhgNWdJecOePHsnPzOGCYAGNG25mQganJGQiD2MTl9YSrcHHeO5hoGeLoOa4rEU40rV++YtODGCw==" + }, + "noUpdate": { + "fields": { + "update": false, + "mandatory": false, + "issuedAt": 1760438401, + "checkHours": 12, + "wifiOnly": true + }, + "signingInput": "shx-manifest-v1\n0\n0\n0\n\n\n0\n\n1760438401\n12\n1", + "signatureBase64": "8Q5+fb7t6ot5iXYBPbv9CCspNzJOEVmQqHLh/6k2lNNtMxeyuTfbldKaKFzQV4orknVKijbf5ChgYDlC5pNvDQ==" + }, + "request": { + "hmacKey": "test-hmac-key", + "method": "GET", + "path": "/api/v1/updates/check", + "timestamp": "1760438400", + "signingInput": "GET|/api/v1/updates/check|1760438400", + "signatureBase64": "m6eQoNLWfDApDH2E1/q3aW/qG06t01/2zvxL1RD+Ckw=" + } +} diff --git a/src/frontends/android/scripts/upload-apk.mjs b/src/frontends/android/scripts/upload-apk.mjs new file mode 100644 index 000000000..bc2a8f518 --- /dev/null +++ b/src/frontends/android/scripts/upload-apk.mjs @@ -0,0 +1,107 @@ +#!/usr/bin/env node +// Загрузка собранного APK в панель SHX VPN с компьютера сборки. Нужен только Node 18+, без зависимостей. +// +// node upload-apk.mjs app-release.apk --type phone --channel beta [--publish] [--rollout 100] [--notes "Что нового"] +// +// Окружение: +// SHX_URL адрес консоли, например https://apk.shnt.top +// SHX_TOKEN токен CI из «Настройки → Токены» (upload — только черновик, publish — можно публиковать) +// +// versionName и versionCode берутся из output-metadata.json, который Gradle кладёт рядом с APK, +// либо из --version-name / --version-code. +// +// Шаги: билет на загрузку → файл напрямую в S3 по presigned PUT → сервер проверяет SHA-256 и оформляет версию. +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, readFileSync, statSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { parseArgs } from "node:util"; + +const { values: o, positionals } = parseArgs({ + allowPositionals: true, + options: { + type: { type: "string" }, + channel: { type: "string", default: "beta" }, + "version-name": { type: "string" }, + "version-code": { type: "string" }, + notes: { type: "string", default: "" }, + "notes-en": { type: "string", default: "" }, + publish: { type: "boolean", default: false }, + rollout: { type: "string", default: "100" }, + help: { type: "boolean", short: "h", default: false }, + }, +}); + +const die = (msg) => { console.error("✗ " + msg); process.exit(1); }; +if (o.help || positionals.length !== 1) { + console.log("node upload-apk.mjs <файл.apk> --type [--channel beta|release] [--publish] [--rollout 1-100] [--notes текст] [--version-name 2.20.0 --version-code 220]"); + process.exit(o.help ? 0 : 1); +} + +// Windows: переменные, заданные через [Environment]::SetEnvironmentVariable(..., "User"), видны только новым +// процессам. Если терминал (или Gradle daemon) открыт раньше, читаем их из реестра пользователя. +function userEnv(name) { + if (process.env[name]) return process.env[name]; + if (process.platform !== "win32") return ""; + try { + const out = execFileSync("reg", ["query", "HKCU\\Environment", "/v", name], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }); + return /REG_(?:EXPAND_)?SZ\s+(.*)$/m.exec(out)?.[1]?.trim() ?? ""; + } catch { + return ""; + } +} +const base = userEnv("SHX_URL").replace(/\/$/, ""); +const token = userEnv("SHX_TOKEN"); +if (!base) die("Задайте SHX_URL (адрес консоли)"); +if (!token) die("Задайте SHX_TOKEN (Настройки → Токены в консоли)"); +if (!o.type) die("Укажите --type (id типа приложения из раздела «Типы приложений»)"); +if (!["beta", "release"].includes(o.channel)) die("--channel: beta или release"); +const rolloutPct = Number(o.rollout); +if (!Number.isInteger(rolloutPct) || rolloutPct < 1 || rolloutPct > 100) die("--rollout: целое от 1 до 100"); + +const file = positionals[0]; +if (!existsSync(file)) die("Нет файла " + file); + +let versionName = o["version-name"]; +let versionCode = o["version-code"] ? Number(o["version-code"]) : undefined; +const meta = join(dirname(file), "output-metadata.json"); +if ((!versionName || !versionCode) && existsSync(meta)) { + const el = JSON.parse(readFileSync(meta, "utf8")).elements?.[0]; + versionName ??= el?.versionName; + versionCode ??= el?.versionCode; +} +if (!versionName || !Number.isInteger(versionCode)) die("Не удалось определить версию: передайте --version-name и --version-code"); + +const bytes = readFileSync(file); +const sha256 = createHash("sha256").update(bytes).digest("hex"); +const mb = (statSync(file).size / 1048576).toFixed(1); + +async function api(path, body) { + const r = await fetch(base + path, { + method: "POST", + headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, + body: JSON.stringify(body), + }); + const data = await r.json().catch(() => ({})); + if (!r.ok) die(`${path}: ${r.status} ${data?.error?.message ?? r.statusText}`); + return data; +} + +console.log(`→ ${o.type} / ${o.channel} / ${versionName} (${versionCode}), ${mb} МБ, sha256 ${sha256.slice(0, 12)}…`); + +const ticket = await api("/api/admin/versions/uploads", { typeId: o.type, channel: o.channel, versionName, versionCode }); +if (bytes.length > ticket.maxBytes) die("Файл больше допустимого размера"); + +const put = await fetch(ticket.url, { method: ticket.method, headers: ticket.headers, body: bytes }); +if (!put.ok) die(`Загрузка в хранилище: ${put.status} ${await put.text().catch(() => "")}`.slice(0, 300)); +console.log("✓ файл в хранилище"); + +const v = await api("/api/admin/versions", { + uploadId: ticket.uploadId, + sha256, + notes: { ru: o.notes, en: o["notes-en"] }, + publish: o.publish, + rolloutPct, +}); +console.log(`✓ версия оформлена: ${v.versionName}, статус ${v.status}${o.publish ? `, раскатка ${rolloutPct}%` : " (черновик: опубликуйте в консоли)"}`); +console.log(` ${base}/versions/${v.id}`);