Author SHA1 Message Date
DenozordecandClaude Opus 5.5 68ee051ab2 android: version 2.19.4 (172) — обновления и отчёты через панель SHX VPN
Android / pre-check (push) Failing after 8s
Android / android (push) Skipped
CIFuzz / pre-check (push) Failing after 3s
CIFuzz / fuzzing (address) (push) Skipped
CIFuzz / fuzzing (undefined) (push) Skipped
CodeQL / pre-check (push) Failing after 4s
CodeQL / analyze (python) (push) Skipped
CodeQL / analyze (ruby) (push) Skipped
CodeQL / analyze-cpp (push) Skipped
FreeBSD / pre-check (push) Failing after 3s
FreeBSD / freebsd (14.4) (push) Skipped
FreeBSD / freebsd (15.0) (push) Skipped
Linux / pre-check (push) Failing after 4s
Linux / latest (apidoc) (push) Skipped
Linux / latest (clang, no, all) (push) Skipped
Linux / latest (clang, no, default) (push) Skipped
Linux / latest (clang, no, printf-builtin) (push) Skipped
Linux / latest (clang, no-dbg) (push) Skipped
Linux / latest (clang, no-testable-ke) (push) Skipped
Linux / latest (clang, yes, all) (push) Skipped
Linux / latest (clang, yes, default) (push) Skipped
Linux / latest (clang, yes, fuzzing) (push) Skipped
Linux / latest (coverage) (push) Skipped
Linux / latest (dist) (push) Skipped
Linux / latest (gcc, no, all) (push) Skipped
Linux / latest (gcc, no, default) (push) Skipped
Linux / latest (gcc, no, printf-builtin) (push) Skipped
Linux / latest (gcc, yes, all) (push) Skipped
Linux / latest (gcc, yes, default) (push) Skipped
Linux / latest (nm) (push) Skipped
Linux / latest (no-dbg) (push) Skipped
Linux / latest (no-testable-ke) (push) Skipped
Linux / latest (yes, ld) (push) Skipped
Linux / crypto (ubuntu-22.04, openssl-sys) (push) Skipped
Linux / crypto (ubuntu-latest, botan) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-3) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-4) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-awslc) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-sys) (push) Skipped
Linux / crypto (ubuntu-latest, wolfssl) (push) Skipped
Linux / older (clang, ubuntu-22.04, all) (push) Skipped
Linux / older (gcc, ubuntu-22.04, all) (push) Skipped
Linux / older (gcc, ubuntu-22.04, nm) (push) Skipped
Linux / alpine (push) Skipped
macOS / pre-check (push) Failing after 4s
macOS / macos (macos-14) (push) Skipped
macOS / macos (macos-latest) (push) Skipped
SonarCloud / pre-check (push) Failing after 3s
SonarCloud / sonarcloud (push) Skipped
TKM / pre-check (push) Failing after 3s
TKM / tkm (push) Skipped
Windows / pre-check (push) Failing after 4s
Windows / cross-compile (win32) (push) Skipped
Windows / cross-compile (win64) (push) Skipped
Windows / native (i686, mingw32, win32) (push) Skipped
Windows / native (x86_64, mingw64, win64) (push) Skipped
- проверка обновлений через API панели (get.shnt.top/api/v1): подписанный запрос (HMAC),
  манифест с подписью Ed25519; старый бакет — только запасной путь при сетевой ошибке
- экраны обновления по макету: страница «Обновления» с каналом и кодом беты, шторки
  «Что нового», «Скачивание» (с отменой), «Обязательное обновление», «Не установлено»
- отправка отчёта из журнала в панель: код отчёта, запись в «Событиях», письмо как запасной путь;
  к отчёту прикладываются результаты проверки сети (checks.json) и diag.log
- исправлен вылет при скачивании обновления на Android 16.0 (setRequestPromotedOngoing есть только с API 36.1)
- кнопки шторок переносят текст на вторую строку, однословные уменьшаются
- задача Gradle uploadShx: сборка и загрузка APK в панель (scripts/upload-apk.mjs)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 22:38:45 +07:00
DenozordecandClaude Sonnet 5.5 0e35a794c9 android: version 2.19.1 (169)
Android / pre-check (push) Failing after 5s
Android / android (push) Skipped
CIFuzz / pre-check (push) Failing after 4s
CIFuzz / fuzzing (address) (push) Skipped
CIFuzz / fuzzing (undefined) (push) Skipped
CodeQL / pre-check (push) Failing after 4s
CodeQL / analyze (python) (push) Skipped
CodeQL / analyze (ruby) (push) Skipped
CodeQL / analyze-cpp (push) Skipped
FreeBSD / pre-check (push) Failing after 4s
FreeBSD / freebsd (14.4) (push) Skipped
FreeBSD / freebsd (15.0) (push) Skipped
Linux / pre-check (push) Failing after 4s
Linux / latest (apidoc) (push) Skipped
Linux / latest (clang, no, all) (push) Skipped
Linux / latest (clang, no, default) (push) Skipped
Linux / latest (clang, no, printf-builtin) (push) Skipped
Linux / latest (clang, no-dbg) (push) Skipped
Linux / latest (clang, no-testable-ke) (push) Skipped
Linux / latest (clang, yes, all) (push) Skipped
Linux / latest (clang, yes, default) (push) Skipped
Linux / latest (clang, yes, fuzzing) (push) Skipped
Linux / latest (coverage) (push) Skipped
Linux / latest (dist) (push) Skipped
Linux / latest (gcc, no, all) (push) Skipped
Linux / latest (gcc, no, default) (push) Skipped
Linux / latest (gcc, no, printf-builtin) (push) Skipped
Linux / latest (gcc, yes, all) (push) Skipped
Linux / latest (gcc, yes, default) (push) Skipped
Linux / latest (nm) (push) Skipped
Linux / latest (no-dbg) (push) Skipped
Linux / latest (no-testable-ke) (push) Skipped
Linux / latest (yes, ld) (push) Skipped
Linux / crypto (ubuntu-22.04, openssl-sys) (push) Skipped
Linux / crypto (ubuntu-latest, botan) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-3) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-4) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-awslc) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-sys) (push) Skipped
Linux / crypto (ubuntu-latest, wolfssl) (push) Skipped
Linux / older (clang, ubuntu-22.04, all) (push) Skipped
Linux / older (gcc, ubuntu-22.04, all) (push) Skipped
Linux / older (gcc, ubuntu-22.04, nm) (push) Skipped
Linux / alpine (push) Skipped
macOS / pre-check (push) Failing after 4s
macOS / macos (macos-14) (push) Skipped
macOS / macos (macos-latest) (push) Skipped
SonarCloud / pre-check (push) Failing after 4s
SonarCloud / sonarcloud (push) Skipped
TKM / pre-check (push) Failing after 4s
TKM / tkm (push) Skipped
Windows / pre-check (push) Failing after 4s
Windows / cross-compile (win32) (push) Skipped
Windows / cross-compile (win64) (push) Skipped
Windows / native (i686, mingw32, win32) (push) Skipped
Windows / native (x86_64, mingw64, win64) (push) Skipped
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 12:55:25 +07:00
DenozordecandClaude Sonnet 5.5 5ffb5a2dbb android: «How traffic goes» no longer says the app is not found on the first try: the check waits for the installed-apps list instead of a fixed 300 ms delay
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 12:50:50 +07:00
40 changed files with 3535 additions and 311 deletions

No files matched your search

+18
View File
@@ -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=<id>`.
- Заметки «Что нового» берутся из `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.*` и
требует отдельного подтверждения.
+46 -2
View File
@@ -45,8 +45,8 @@ android {
minSdk = 24
targetSdk = 36
versionCode 168
versionName "2.19.0"
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
}
}
@@ -64,3 +64,6 @@
-keep class * extends androidx.room.RoomDatabase {
<init>();
}
# i2p eddsa (manifest signature check) references sun.security.x509 only for JDK interop, absent on Android
-dontwarn sun.security.x509.**
@@ -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)
@@ -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 <http://www.fsf.org/copyleft/gpl.txt>.
*
* 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<Check>): 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<Check>? = 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,
)
}
@@ -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 <http://www.fsf.org/copyleft/gpl.txt>.
*
* 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()
}
}
}
@@ -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 <http://www.fsf.org/copyleft/gpl.txt>.
*
* 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<String, String>): Response
}
class HttpUrlConnectionHttp(private val timeoutMs: Int = 10_000) : UpdateHttp
{
override fun get(url: String, headers: Map<String, String>): 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<String, String>()
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")
}
@@ -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<String, String>,
/* 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<UpdateCheckWorker>(12, TimeUnit.HOURS)
/* the interval comes from the server policy (signed manifest), 12 h until it is known */
val request = PeriodicWorkRequestBuilder<UpdateCheckWorker>(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<String, Long>?
{
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<String, String>
{
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
}
@@ -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 <http://www.fsf.org/copyleft/gpl.txt>.
*
* 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<String, String>, 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<String>
{
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)
}
}
@@ -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<Int>(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)
@@ -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 <http://www.fsf.org/copyleft/gpl.txt>.
*
* 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>(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<Application>()
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<Pair<String, String?>>)
{
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),
)
}
}
@@ -55,6 +55,7 @@ class SettingsActivity : AppCompatActivity()
Box(Modifier.padding(padding)) {
SettingsScreen(profiles = profiles, showTitle = false)
}
org.strongswan.android.ui.compose.UpdateSheetHost()
}
}
}
@@ -92,7 +92,7 @@ data class DiagReport(val steps: List<DiagStep>, 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
@@ -124,10 +124,23 @@ fun RouteCheckScreen(onBack: () -> Unit, initialMode: RouteCheckMode = RouteChec
var result by remember { mutableStateOf<CheckResult?>(null) }
var recent by remember { mutableStateOf(prefs.getString(PREF_RECENT, "")!!.split('\n').filter { it.isNotBlank() }) }
val rulesActive = remember { CharonVpnService.isUserspaceRoutingEnabled(context) }
val apps by produceState<List<InstalledApp>>(emptyList(), mode) {
if (mode == RouteCheckMode.APP && value.isEmpty())
var apps by remember { mutableStateOf<List<InstalledApp>>(emptyList()) }
/** The installed apps; loads them on first use, so a check never runs against a list that is still empty. */
suspend fun loadApps(): List<InstalledApp>
{
if (apps.isEmpty())
{
value = withContext(Dispatchers.IO) { DirectApps.loadInstalledApps(context.packageManager, context.packageName) }
apps = withContext(Dispatchers.IO) { DirectApps.loadInstalledApps(context.packageManager, context.packageName) }
}
return apps
}
LaunchedEffect(mode)
{
if (mode == RouteCheckMode.APP)
{
loadApps()
}
}
@@ -150,7 +163,7 @@ fun RouteCheckScreen(onBack: () -> Unit, initialMode: RouteCheckMode = RouteChec
{
RouteCheckMode.SITE -> checkSite(context, manager, input, rulesActive)
RouteCheckMode.IP -> checkIp(context, manager, input, rulesActive)
RouteCheckMode.APP -> apps.firstOrNull { it.packageName == input || it.label.equals(input, ignoreCase = true) }
RouteCheckMode.APP -> loadApps().firstOrNull { it.packageName == input || it.label.equals(input, ignoreCase = true) }
?.let { CheckResult.App(it) } ?: CheckResult.BadInput
}
if (result !is CheckResult.BadInput)
@@ -173,10 +186,6 @@ fun RouteCheckScreen(onBack: () -> Unit, initialMode: RouteCheckMode = RouteChec
{
if (initialQuery.isNotBlank())
{
if (mode == RouteCheckMode.APP)
{
delay(300)
}
run()
}
}
@@ -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))
@@ -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<VpnProfile>, 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<VpnProfile>, 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<VpnProfile>, 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<VpnProfile>, 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
}
)
}
}
/**
@@ -260,6 +260,8 @@ fun ShxApp(viewModel: HomeViewModel = viewModel())
{
AddServerSheet(onDismiss = { addSheetOpen = false })
}
/* update sheets, the mandatory update included, over every tab */
UpdateSheetHost()
}
}
}
@@ -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<UpdateSheet?>(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
}
@@ -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),
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- "Send report" sheet of the log screen (mockups App7-App10) -->
<resources>
<string name="report_intro">Der Bericht geht über einen geschützten Kanal an das SHX-Team. Mit dem Berichtscode findet der Support Ihr Protokoll.</string>
<string name="report_contents">Inhalt des Berichts</string>
<string name="report_item_log">Verbindungsprotokoll von heute</string>
<string name="report_item_device">App-Version und Telefonmodell</string>
<string name="report_item_network">Netzwerktyp und aktiver Server</string>
<string name="report_item_ips">IP-Adressen der Server aus dem Protokoll</string>
<string name="report_diag_title">Diagnose anhängen</string>
<string name="report_diag_sub">Ergebnisse der Netzwerkprüfung und erweitertes App-Protokoll</string>
<string name="report_comment_label">Was ist passiert? (optional)</string>
<string name="report_cancel">Abbrechen</string>
<string name="report_send">Senden</string>
<string name="report_by_mail">Per E-Mail senden</string>
<string name="report_sent_title">Bericht gesendet</string>
<string name="report_sent_sub">Nennen Sie diesen Code dem Support</string>
<string name="report_code_label">Berichtscode</string>
<string name="report_copy_code">Code kopieren</string>
<string name="report_code_copied">Code kopiert</string>
<string name="report_saved_note">Der Code ist unter „Ereignisse“ gespeichert und lässt sich später finden.</string>
<string name="report_done">Fertig</string>
<string name="report_failed_title">Bericht nicht gesendet</string>
<string name="report_failed_no_answer">Der Server hat nicht geantwortet</string>
<string name="report_failed_refused">Der Server hat den Bericht abgelehnt</string>
<string name="report_reasons">Mögliche Ursachen</string>
<string name="report_reason_offline">Kein Internet</string>
<string name="report_reason_offline_d">Netzwerk prüfen und wiederholen</string>
<string name="report_reason_server">Server nicht erreichbar</string>
<string name="report_reason_server_d">in ein paar Minuten wiederholen</string>
<string name="report_reason_clock">Telefonuhr geht falsch</string>
<string name="report_reason_clock_d">automatische Zeit einschalten und wiederholen</string>
<string name="report_reason_often">Zu häufig</string>
<string name="report_reason_often_d">in einer Stunde erneut versuchen</string>
<string name="report_reason_large">Protokoll zu groß</string>
<string name="report_reason_large_d">per E-Mail senden</string>
<string name="report_reason_rejected">Anfrage abgelehnt</string>
<string name="report_reason_rejected_d">Protokoll per E-Mail senden</string>
<string name="report_mail_note">Das Protokoll lässt sich wie bisher auch per E-Mail senden.</string>
<string name="report_retry">Wiederholen</string>
<string name="report_by_letter">Per E-Mail senden</string>
<string name="report_event_title">Bericht gesendet, Code %1$s</string>
<string name="report_event_text">Nennen Sie diesen Code dem Support, damit er Ihr Protokoll findet.</string>
</resources>
@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- App self-update: the Updates page and the update sheets -->
<resources>
<string name="updates_have_size">Installiert: %1$s · %2$s</string>
<string name="updates_review">Ansehen und installieren</string>
<string name="updates_section_auto">Automatisch</string>
<string name="updates_auto_check">Selbst prüfen</string>
<string name="updates_silent">Still installieren</string>
<string name="updates_silent_sub">Über WLAN laden und installieren, wenn das VPN aus ist</string>
<string name="updates_section_channel">Kanal</string>
<string name="updates_channel_stable">Stabil</string>
<string name="updates_channel_stable_sub">Geprüfte Versionen</string>
<string name="updates_channel_beta">Beta</string>
<string name="updates_channel_beta_sub">Früher, aber mit Fehlern</string>
<string name="updates_channel_hint">Die Beta wird mit einem Einladungscode des Administrators freigeschaltet.</string>
<string name="updates_beta_title">Einladungscode für die Beta</string>
<string name="updates_beta_text">Gib den Code ein, den du vom Administrator erhalten hast. Ohne gültigen Code bleibt die App bei stabilen Versionen.</string>
<string name="updates_beta_code">Einladungscode</string>
<string name="updates_beta_enable">Beta einschalten</string>
<string name="updates_check_now">Jetzt prüfen</string>
<string name="updates_last_check">Letzte Prüfung: %1$s</string>
<string name="updates_never_checked">Noch nicht geprüft</string>
<string name="updates_review_sub">Installiert: %1$s · %2$s · die Datei wird vor der Installation geprüft</string>
<string name="updates_review_safe">Die App prüft die Prüfsumme und die SHX-Signatur. Eine gefälschte Datei wird nicht installiert.</string>
<string name="updates_downloading_title">%1$s wird geladen</string>
<string name="updates_downloading_sub">Du kannst dieses Fenster schließen: Der Download läuft weiter</string>
<string name="updates_progress">%1$d%% · %2$s von %3$s</string>
<string name="updates_step_list">Versionsliste erhalten</string>
<string name="updates_step_list_sub">Über einen geschützten Kanal</string>
<string name="updates_step_download">Datei wird geladen</string>
<string name="updates_step_verify">Datei wird geprüft</string>
<string name="updates_step_verify_sub">Prüfsumme und SHX-Signatur</string>
<string name="updates_step_install">Installation</string>
<string name="updates_step_install_sub">Android fragt nach einer Bestätigung</string>
<string name="updates_cancel">Abbrechen</string>
<string name="updates_mandatory_title">SHX VPN muss aktualisiert werden</string>
<string name="updates_mandatory_sub">Version %1$s wird nicht mehr unterstützt</string>
<string name="updates_mandatory_what">Was das bedeutet</string>
<string name="updates_mandatory_reason">Der Server nimmt keine Verbindungen der alten Version mehr an</string>
<string name="updates_mandatory_install">Installiere %1$s (%2$s), deine Server und Einstellungen bleiben erhalten</string>
<string name="updates_mandatory_locked">Dieses Fenster lässt sich erst nach dem Update schließen.</string>
<string name="updates_exit">App beenden</string>
<string name="updates_failed_title">Update nicht installiert</string>
<string name="updates_failed_sub_check">Die Datei hat die Prüfung nicht bestanden</string>
<string name="updates_failed_sub_network">Die Datei wurde nicht geladen</string>
<string name="updates_failed_sub_install">Android hat die Datei nicht installiert</string>
<string name="updates_failed_sub_other">Etwas ist schiefgelaufen</string>
<string name="updates_failed_what">Was passiert ist</string>
<string name="updates_reason_hash">Prüfsumme stimmt nicht</string>
<string name="updates_reason_hash_d">die Datei wurde unterwegs beschädigt, die Kopie ist gelöscht</string>
<string name="updates_reason_signature">Signatur stimmt nicht</string>
<string name="updates_reason_signature_d">die Datei ist nicht von SHX signiert, die Kopie ist gelöscht</string>
<string name="updates_reason_network">Keine Verbindung zum Update-Server</string>
<string name="updates_reason_network_d">der Download wurde unterbrochen</string>
<string name="updates_reason_install">Android hat die Installation abgelehnt</string>
<string name="updates_reason_install_d">das Installationsprogramm meldete einen Fehler</string>
<string name="updates_reason_other">Unerwarteter Fehler</string>
<string name="updates_reason_other_d">Details für den Support findest du unten</string>
<string name="updates_reason_same">Version unverändert</string>
<string name="updates_reason_same_d">du hast weiterhin %1$s</string>
<string name="updates_reason_later">Versuche es später erneut</string>
<string name="updates_reason_later_d">oder wechsle das Netz: WLAN statt Mobilfunk</string>
<string name="updates_manual_note">Du kannst die App manuell über die Download-Seite installieren. Deine Server und Einstellungen bleiben erhalten.</string>
<string name="updates_retry">Wiederholen</string>
<string name="updates_from_site">Von der Website laden</string>
<string name="updates_support">Details für den Support</string>
<string name="updates_copied">Kopiert</string>
<plurals name="updates_auto_check_sub">
<item quantity="one">Alle %d Stunde, während das Telefon lädt</item>
<item quantity="other">Alle %d Stunden, während das Telefon lädt</item>
</plurals>
</resources>
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- "Send report" sheet of the log screen (mockups App7-App10) -->
<resources>
<string name="report_intro">Raport trafi do zespołu SHX bezpiecznym kanałem. Po kodzie raportu wsparcie znajdzie Twój dziennik.</string>
<string name="report_contents">Co trafi do raportu</string>
<string name="report_item_log">Dziennik połączenia z dzisiaj</string>
<string name="report_item_device">Wersja aplikacji i model telefonu</string>
<string name="report_item_network">Typ sieci i aktywny serwer</string>
<string name="report_item_ips">Adresy IP serwerów z dziennika</string>
<string name="report_diag_title">Dołącz diagnostykę</string>
<string name="report_diag_sub">Wyniki sprawdzenia sieci i rozszerzony dziennik aplikacji</string>
<string name="report_comment_label">Co się stało (opcjonalnie)</string>
<string name="report_cancel">Anuluj</string>
<string name="report_send">Wyślij</string>
<string name="report_by_mail">Wyślij e-mailem</string>
<string name="report_sent_title">Raport wysłany</string>
<string name="report_sent_sub">Podaj ten kod wsparciu</string>
<string name="report_code_label">Kod raportu</string>
<string name="report_copy_code">Kopiuj kod</string>
<string name="report_code_copied">Kod skopiowany</string>
<string name="report_saved_note">Kod zapisano w „Zdarzeniach”, można go znaleźć później.</string>
<string name="report_done">Gotowe</string>
<string name="report_failed_title">Raport nie został wysłany</string>
<string name="report_failed_no_answer">Serwer nie odpowiedział</string>
<string name="report_failed_refused">Serwer odrzucił raport</string>
<string name="report_reasons">Możliwe przyczyny</string>
<string name="report_reason_offline">Brak internetu</string>
<string name="report_reason_offline_d">sprawdź sieć i ponów</string>
<string name="report_reason_server">Serwer niedostępny</string>
<string name="report_reason_server_d">ponów za kilka minut</string>
<string name="report_reason_clock">Zły czas w telefonie</string>
<string name="report_reason_clock_d">włącz automatyczny czas i ponów</string>
<string name="report_reason_often">Zbyt często</string>
<string name="report_reason_often_d">spróbuj za godzinę</string>
<string name="report_reason_large">Dziennik jest za duży</string>
<string name="report_reason_large_d">wyślij go e-mailem</string>
<string name="report_reason_rejected">Żądanie odrzucone</string>
<string name="report_reason_rejected_d">wyślij dziennik e-mailem</string>
<string name="report_mail_note">Dziennik można też wysłać e-mailem, jak dotąd.</string>
<string name="report_retry">Ponów</string>
<string name="report_by_letter">Wyślij e-mailem</string>
<string name="report_event_title">Raport wysłany, kod %1$s</string>
<string name="report_event_text">Podaj ten kod wsparciu, aby znalazło Twój dziennik.</string>
</resources>
@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- App self-update: the Updates page and the update sheets -->
<resources>
<string name="updates_have_size">Masz %1$s · %2$s</string>
<string name="updates_review">Zobacz i zainstaluj</string>
<string name="updates_section_auto">Automatycznie</string>
<string name="updates_auto_check">Sprawdzaj samodzielnie</string>
<string name="updates_silent">Instaluj po cichu</string>
<string name="updates_silent_sub">Pobierz przez Wi-Fi i zainstaluj, gdy VPN jest wyłączony</string>
<string name="updates_section_channel">Kanał</string>
<string name="updates_channel_stable">Stabilny</string>
<string name="updates_channel_stable_sub">Sprawdzone wersje</string>
<string name="updates_channel_beta">Beta</string>
<string name="updates_channel_beta_sub">Wcześniej, ale z błędami</string>
<string name="updates_channel_hint">Beta jest dostępna z kodem zaproszenia od administratora.</string>
<string name="updates_beta_title">Kod zaproszenia do bety</string>
<string name="updates_beta_text">Wpisz kod od administratora. Bez prawidłowego kodu aplikacja zostanie na stabilnych wersjach.</string>
<string name="updates_beta_code">Kod zaproszenia</string>
<string name="updates_beta_enable">Włącz betę</string>
<string name="updates_check_now">Sprawdź teraz</string>
<string name="updates_last_check">Ostatnie sprawdzenie: %1$s</string>
<string name="updates_never_checked">Jeszcze nie sprawdzano</string>
<string name="updates_review_sub">Masz %1$s · %2$s · plik sprawdzimy przed instalacją</string>
<string name="updates_review_safe">Aplikacja sprawdzi sumę kontrolną i podpis SHX. Podrobiony plik się nie zainstaluje.</string>
<string name="updates_downloading_title">Pobieramy %1$s</string>
<string name="updates_downloading_sub">Możesz zamknąć to okno: pobieranie będzie trwać</string>
<string name="updates_progress">%1$d%% · %2$s z %3$s</string>
<string name="updates_step_list">Lista wersji pobrana</string>
<string name="updates_step_list_sub">Przez chroniony kanał</string>
<string name="updates_step_download">Pobieranie pliku</string>
<string name="updates_step_verify">Sprawdzanie pliku</string>
<string name="updates_step_verify_sub">Suma kontrolna i podpis SHX</string>
<string name="updates_step_install">Instalacja</string>
<string name="updates_step_install_sub">Android poprosi o potwierdzenie</string>
<string name="updates_cancel">Anuluj</string>
<string name="updates_mandatory_title">SHX VPN wymaga aktualizacji</string>
<string name="updates_mandatory_sub">Wersja %1$s nie jest już obsługiwana</string>
<string name="updates_mandatory_what">Co to oznacza</string>
<string name="updates_mandatory_reason">Serwer nie przyjmuje już połączeń ze starej wersji</string>
<string name="updates_mandatory_install">Zainstaluj %1$s (%2$s), Twoje serwery i ustawienia zostaną zachowane</string>
<string name="updates_mandatory_locked">Tego okna nie można zamknąć, dopóki aplikacja nie zostanie zaktualizowana.</string>
<string name="updates_exit">Zamknij aplikację</string>
<string name="updates_failed_title">Aktualizacja nie została zainstalowana</string>
<string name="updates_failed_sub_check">Plik nie przeszedł weryfikacji</string>
<string name="updates_failed_sub_network">Nie udało się pobrać pliku</string>
<string name="updates_failed_sub_install">Android nie zainstalował pliku</string>
<string name="updates_failed_sub_other">Coś poszło nie tak</string>
<string name="updates_failed_what">Co się stało</string>
<string name="updates_reason_hash">Suma kontrolna się nie zgadza</string>
<string name="updates_reason_hash_d">plik uszkodził się po drodze, kopia została usunięta</string>
<string name="updates_reason_signature">Podpis się nie zgadza</string>
<string name="updates_reason_signature_d">plik nie jest podpisany przez SHX, kopia została usunięta</string>
<string name="updates_reason_network">Brak połączenia z serwerem aktualizacji</string>
<string name="updates_reason_network_d">pobieranie zostało przerwane</string>
<string name="updates_reason_install">Android odrzucił instalację</string>
<string name="updates_reason_install_d">instalator systemowy zwrócił błąd</string>
<string name="updates_reason_other">Nieoczekiwany błąd</string>
<string name="updates_reason_other_d">szczegóły dla wsparcia znajdziesz poniżej</string>
<string name="updates_reason_same">Wersja się nie zmieniła</string>
<string name="updates_reason_same_d">nadal masz %1$s</string>
<string name="updates_reason_later">Spróbuj później</string>
<string name="updates_reason_later_d">lub zmień sieć: Wi-Fi zamiast komórkowej</string>
<string name="updates_manual_note">Możesz zainstalować ręcznie ze strony pobierania. Twoje serwery i ustawienia pozostaną bez zmian.</string>
<string name="updates_retry">Ponów</string>
<string name="updates_from_site">Pobierz ze strony</string>
<string name="updates_support">Szczegóły dla wsparcia</string>
<string name="updates_copied">Skopiowano</string>
<plurals name="updates_auto_check_sub">
<item quantity="one">Co %d godzinę, gdy telefon się ładuje</item>
<item quantity="few">Co %d godziny, gdy telefon się ładuje</item>
<item quantity="many">Co %d godzin, gdy telefon się ładuje</item>
<item quantity="other">Co %d godziny, gdy telefon się ładuje</item>
</plurals>
</resources>
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- "Send report" sheet of the log screen (mockups App7-App10) -->
<resources>
<string name="report_intro">Отчёт уйдёт команде SHX по защищённому каналу. По коду отчёта поддержка найдёт ваш журнал.</string>
<string name="report_contents">Что войдёт в отчёт</string>
<string name="report_item_log">Журнал подключения за сегодня</string>
<string name="report_item_device">Версия приложения и модель телефона</string>
<string name="report_item_network">Тип сети и активный сервер</string>
<string name="report_item_ips">IP-адреса серверов из журнала</string>
<string name="report_diag_title">Приложить диагностику</string>
<string name="report_diag_sub">Результаты проверки сети и расширенный журнал приложения</string>
<string name="report_comment_label">Что случилось (необязательно)</string>
<string name="report_cancel">Отмена</string>
<string name="report_send">Отправить</string>
<string name="report_by_mail">Отправить по почте</string>
<string name="report_sent_title">Отчёт отправлен</string>
<string name="report_sent_sub">Назовите этот код поддержке</string>
<string name="report_code_label">Код отчёта</string>
<string name="report_copy_code">Скопировать код</string>
<string name="report_code_copied">Код скопирован</string>
<string name="report_saved_note">Код сохранён в «Событиях», его можно найти позже.</string>
<string name="report_done">Готово</string>
<string name="report_failed_title">Отчёт не отправлен</string>
<string name="report_failed_no_answer">Сервер не ответил</string>
<string name="report_failed_refused">Сервер отклонил отчёт</string>
<string name="report_reasons">Возможные причины</string>
<string name="report_reason_offline">Нет интернета</string>
<string name="report_reason_offline_d">проверьте сеть и повторите</string>
<string name="report_reason_server">Сервер недоступен</string>
<string name="report_reason_server_d">повторите через несколько минут</string>
<string name="report_reason_clock">Часы телефона сбиты</string>
<string name="report_reason_clock_d">включите автоматическое время и повторите</string>
<string name="report_reason_often">Слишком часто</string>
<string name="report_reason_often_d">попробуйте через час</string>
<string name="report_reason_large">Журнал слишком большой</string>
<string name="report_reason_large_d">отправьте его письмом</string>
<string name="report_reason_rejected">Запрос отклонён</string>
<string name="report_reason_rejected_d">отправьте журнал письмом</string>
<string name="report_mail_note">Журнал можно отправить и письмом, как раньше.</string>
<string name="report_retry">Повторить</string>
<string name="report_by_letter">Отправить письмом</string>
<string name="report_event_title">Отчёт отправлен, код %1$s</string>
<string name="report_event_text">Назовите этот код поддержке, чтобы она нашла ваш журнал.</string>
</resources>
@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- App self-update: the Updates page and the update sheets -->
<resources>
<string name="updates_have_size">У вас %1$s · %2$s</string>
<string name="updates_review">Посмотреть и установить</string>
<string name="updates_section_auto">Автоматически</string>
<string name="updates_auto_check">Проверять сами</string>
<string name="updates_silent">Ставить тихо</string>
<string name="updates_silent_sub">Скачать по Wi-Fi и установить, когда VPN выключен</string>
<string name="updates_section_channel">Канал</string>
<string name="updates_channel_stable">Стабильный</string>
<string name="updates_channel_stable_sub">Проверенные версии</string>
<string name="updates_channel_beta">Бета</string>
<string name="updates_channel_beta_sub">Раньше, но с ошибками</string>
<string name="updates_channel_hint">Бета открывается по коду приглашения от администратора.</string>
<string name="updates_beta_title">Код приглашения в бету</string>
<string name="updates_beta_text">Введите код, который дал администратор. Без верного кода приложение останется на стабильных версиях.</string>
<string name="updates_beta_code">Код приглашения</string>
<string name="updates_beta_enable">Включить бету</string>
<string name="updates_check_now">Проверить сейчас</string>
<string name="updates_last_check">Последняя проверка: %1$s</string>
<string name="updates_never_checked">Ещё не проверяли</string>
<string name="updates_review_sub">У вас %1$s · %2$s · файл проверим перед установкой</string>
<string name="updates_review_safe">Приложение сверит контрольную сумму и подпись SHX. Подделанный файл не установится.</string>
<string name="updates_downloading_title">Скачиваем %1$s</string>
<string name="updates_downloading_sub">Можно закрыть шторку: загрузка продолжится</string>
<string name="updates_progress">%1$d%% · %2$s из %3$s</string>
<string name="updates_step_list">Список версий получен</string>
<string name="updates_step_list_sub">По защищённому каналу</string>
<string name="updates_step_download">Скачивание файла</string>
<string name="updates_step_verify">Проверка файла</string>
<string name="updates_step_verify_sub">Контрольная сумма и подпись SHX</string>
<string name="updates_step_install">Установка</string>
<string name="updates_step_install_sub">Android спросит подтверждение</string>
<string name="updates_cancel">Отменить</string>
<string name="updates_mandatory_title">Нужно обновить SHX VPN</string>
<string name="updates_mandatory_sub">Версия %1$s больше не поддерживается</string>
<string name="updates_mandatory_what">Что это значит</string>
<string name="updates_mandatory_reason">Сервер больше не принимает подключения со старой версии</string>
<string name="updates_mandatory_install">Установите %1$s (%2$s), ваши серверы и настройки сохранятся</string>
<string name="updates_mandatory_locked">Закрыть эту шторку нельзя, пока приложение не обновлено.</string>
<string name="updates_exit">Выйти из приложения</string>
<string name="updates_failed_title">Обновление не установлено</string>
<string name="updates_failed_sub_check">Файл не прошёл проверку</string>
<string name="updates_failed_sub_network">Файл не скачался</string>
<string name="updates_failed_sub_install">Android не установил файл</string>
<string name="updates_failed_sub_other">Что-то пошло не так</string>
<string name="updates_failed_what">Что произошло</string>
<string name="updates_reason_hash">Контрольная сумма не совпала</string>
<string name="updates_reason_hash_d">файл повреждён по дороге, копия удалена</string>
<string name="updates_reason_signature">Подпись не совпала</string>
<string name="updates_reason_signature_d">файл подписан не SHX, копия удалена</string>
<string name="updates_reason_network">Нет связи с сервером обновлений</string>
<string name="updates_reason_network_d">загрузка прервалась</string>
<string name="updates_reason_install">Android отклонил установку</string>
<string name="updates_reason_install_d">системный установщик вернул ошибку</string>
<string name="updates_reason_other">Непредвиденная ошибка</string>
<string name="updates_reason_other_d">подробности для поддержки — ниже</string>
<string name="updates_reason_same">Версия не изменилась</string>
<string name="updates_reason_same_d">у вас по-прежнему %1$s</string>
<string name="updates_reason_later">Попробуйте позже</string>
<string name="updates_reason_later_d">или смените сеть: Wi-Fi вместо мобильной</string>
<string name="updates_manual_note">Установить вручную можно со страницы скачивания. Ваши серверы и настройки останутся на месте.</string>
<string name="updates_retry">Повторить</string>
<string name="updates_from_site">Скачать с сайта</string>
<string name="updates_support">Подробности для поддержки</string>
<string name="updates_copied">Скопировано</string>
<plurals name="updates_auto_check_sub">
<item quantity="one">Раз в %d час, когда телефон на зарядке</item>
<item quantity="few">Раз в %d часа, когда телефон на зарядке</item>
<item quantity="many">Раз в %d часов, когда телефон на зарядке</item>
<item quantity="other">Раз в %d часа, когда телефон на зарядке</item>
</plurals>
</resources>
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- "Send report" sheet of the log screen (mockups App7-App10) -->
<resources>
<string name="report_intro">Звіт піде команді SHX захищеним каналом. За кодом звіту підтримка знайде ваш журнал.</string>
<string name="report_contents">Що увійде у звіт</string>
<string name="report_item_log">Журнал підключення за сьогодні</string>
<string name="report_item_device">Версія застосунку й модель телефона</string>
<string name="report_item_network">Тип мережі й активний сервер</string>
<string name="report_item_ips">IP-адреси серверів із журналу</string>
<string name="report_diag_title">Додати діагностику</string>
<string name="report_diag_sub">Результати перевірки мережі та розширений журнал застосунку</string>
<string name="report_comment_label">Що сталося (необов’язково)</string>
<string name="report_cancel">Скасувати</string>
<string name="report_send">Надіслати</string>
<string name="report_by_mail">Надіслати поштою</string>
<string name="report_sent_title">Звіт надіслано</string>
<string name="report_sent_sub">Назвіть цей код підтримці</string>
<string name="report_code_label">Код звіту</string>
<string name="report_copy_code">Скопіювати код</string>
<string name="report_code_copied">Код скопійовано</string>
<string name="report_saved_note">Код збережено в «Подіях», його можна знайти пізніше.</string>
<string name="report_done">Готово</string>
<string name="report_failed_title">Звіт не надіслано</string>
<string name="report_failed_no_answer">Сервер не відповів</string>
<string name="report_failed_refused">Сервер відхилив звіт</string>
<string name="report_reasons">Можливі причини</string>
<string name="report_reason_offline">Немає інтернету</string>
<string name="report_reason_offline_d">перевірте мережу й повторіть</string>
<string name="report_reason_server">Сервер недоступний</string>
<string name="report_reason_server_d">повторіть за кілька хвилин</string>
<string name="report_reason_clock">Годинник телефона збитий</string>
<string name="report_reason_clock_d">увімкніть автоматичний час і повторіть</string>
<string name="report_reason_often">Надто часто</string>
<string name="report_reason_often_d">спробуйте за годину</string>
<string name="report_reason_large">Журнал завеликий</string>
<string name="report_reason_large_d">надішліть його листом</string>
<string name="report_reason_rejected">Запит відхилено</string>
<string name="report_reason_rejected_d">надішліть журнал листом</string>
<string name="report_mail_note">Журнал можна надіслати й листом, як раніше.</string>
<string name="report_retry">Повторити</string>
<string name="report_by_letter">Надіслати листом</string>
<string name="report_event_title">Звіт надіслано, код %1$s</string>
<string name="report_event_text">Назвіть цей код підтримці, щоб вона знайшла ваш журнал.</string>
</resources>
@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- App self-update: the Updates page and the update sheets -->
<resources>
<string name="updates_have_size">У вас %1$s · %2$s</string>
<string name="updates_review">Переглянути й установити</string>
<string name="updates_section_auto">Автоматично</string>
<string name="updates_auto_check">Перевіряти самостійно</string>
<string name="updates_silent">Установлювати тихо</string>
<string name="updates_silent_sub">Завантажити через Wi-Fi й установити, коли VPN вимкнено</string>
<string name="updates_section_channel">Канал</string>
<string name="updates_channel_stable">Стабільний</string>
<string name="updates_channel_stable_sub">Перевірені версії</string>
<string name="updates_channel_beta">Бета</string>
<string name="updates_channel_beta_sub">Раніше, але з помилками</string>
<string name="updates_channel_hint">Бета відкривається за кодом запрошення від адміністратора.</string>
<string name="updates_beta_title">Код запрошення до бети</string>
<string name="updates_beta_text">Введіть код, який дав адміністратор. Без правильного коду застосунок залишиться на стабільних версіях.</string>
<string name="updates_beta_code">Код запрошення</string>
<string name="updates_beta_enable">Увімкнути бету</string>
<string name="updates_check_now">Перевірити зараз</string>
<string name="updates_last_check">Остання перевірка: %1$s</string>
<string name="updates_never_checked">Ще не перевіряли</string>
<string name="updates_review_sub">У вас %1$s · %2$s · файл перевіримо перед установленням</string>
<string name="updates_review_safe">Застосунок звірить контрольну суму й підпис SHX. Підроблений файл не встановиться.</string>
<string name="updates_downloading_title">Завантажуємо %1$s</string>
<string name="updates_downloading_sub">Можна закрити шторку: завантаження триватиме</string>
<string name="updates_progress">%1$d%% · %2$s з %3$s</string>
<string name="updates_step_list">Список версій отримано</string>
<string name="updates_step_list_sub">Через захищений канал</string>
<string name="updates_step_download">Завантаження файлу</string>
<string name="updates_step_verify">Перевірка файлу</string>
<string name="updates_step_verify_sub">Контрольна сума й підпис SHX</string>
<string name="updates_step_install">Установлення</string>
<string name="updates_step_install_sub">Android попросить підтвердження</string>
<string name="updates_cancel">Скасувати</string>
<string name="updates_mandatory_title">Потрібно оновити SHX VPN</string>
<string name="updates_mandatory_sub">Версія %1$s більше не підтримується</string>
<string name="updates_mandatory_what">Що це означає</string>
<string name="updates_mandatory_reason">Сервер більше не приймає підключення зі старої версії</string>
<string name="updates_mandatory_install">Установіть %1$s (%2$s), ваші сервери й налаштування збережуться</string>
<string name="updates_mandatory_locked">Цю шторку не можна закрити, доки застосунок не оновлено.</string>
<string name="updates_exit">Вийти із застосунку</string>
<string name="updates_failed_title">Оновлення не встановлено</string>
<string name="updates_failed_sub_check">Файл не пройшов перевірку</string>
<string name="updates_failed_sub_network">Файл не завантажився</string>
<string name="updates_failed_sub_install">Android не встановив файл</string>
<string name="updates_failed_sub_other">Щось пішло не так</string>
<string name="updates_failed_what">Що сталося</string>
<string name="updates_reason_hash">Контрольна сума не збіглася</string>
<string name="updates_reason_hash_d">файл пошкоджено дорогою, копію видалено</string>
<string name="updates_reason_signature">Підпис не збігся</string>
<string name="updates_reason_signature_d">файл підписано не SHX, копію видалено</string>
<string name="updates_reason_network">Немає зв’язку із сервером оновлень</string>
<string name="updates_reason_network_d">завантаження перервалося</string>
<string name="updates_reason_install">Android відхилив установлення</string>
<string name="updates_reason_install_d">системний установник повернув помилку</string>
<string name="updates_reason_other">Непередбачена помилка</string>
<string name="updates_reason_other_d">подробиці для підтримки — нижче</string>
<string name="updates_reason_same">Версія не змінилася</string>
<string name="updates_reason_same_d">у вас і далі %1$s</string>
<string name="updates_reason_later">Спробуйте пізніше</string>
<string name="updates_reason_later_d">або змініть мережу: Wi-Fi замість мобільної</string>
<string name="updates_manual_note">Установити вручну можна зі сторінки завантаження. Ваші сервери й налаштування залишаться на місці.</string>
<string name="updates_retry">Повторити</string>
<string name="updates_from_site">Завантажити із сайту</string>
<string name="updates_support">Подробиці для підтримки</string>
<string name="updates_copied">Скопійовано</string>
<plurals name="updates_auto_check_sub">
<item quantity="one">Раз на %d годину, коли телефон заряджається</item>
<item quantity="few">Раз на %d години, коли телефон заряджається</item>
<item quantity="many">Раз на %d годин, коли телефон заряджається</item>
<item quantity="other">Раз на %d години, коли телефон заряджається</item>
</plurals>
</resources>
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- "Send report" sheet of the log screen (mockups App7-App10) -->
<resources>
<string name="report_intro">报告将通过加密通道发送给 SHX 团队。客服凭报告代码即可找到你的日志。</string>
<string name="report_contents">报告包含的内容</string>
<string name="report_item_log">今天的连接日志</string>
<string name="report_item_device">应用版本和手机型号</string>
<string name="report_item_network">网络类型和当前服务器</string>
<string name="report_item_ips">日志中的服务器 IP 地址</string>
<string name="report_diag_title">附加诊断信息</string>
<string name="report_diag_sub">网络检查结果和应用的详细日志</string>
<string name="report_comment_label">发生了什么(可选)</string>
<string name="report_cancel">取消</string>
<string name="report_send">发送</string>
<string name="report_by_mail">通过邮件发送</string>
<string name="report_sent_title">报告已发送</string>
<string name="report_sent_sub">请把此代码告诉客服</string>
<string name="report_code_label">报告代码</string>
<string name="report_copy_code">复制代码</string>
<string name="report_code_copied">代码已复制</string>
<string name="report_saved_note">代码已保存在“事件”中,之后可以在那里找到。</string>
<string name="report_done">完成</string>
<string name="report_failed_title">报告未发送</string>
<string name="report_failed_no_answer">服务器没有响应</string>
<string name="report_failed_refused">服务器拒绝了报告</string>
<string name="report_reasons">可能的原因</string>
<string name="report_reason_offline">没有网络</string>
<string name="report_reason_offline_d">检查网络后重试</string>
<string name="report_reason_server">服务器不可用</string>
<string name="report_reason_server_d">几分钟后重试</string>
<string name="report_reason_clock">手机时间不准</string>
<string name="report_reason_clock_d">开启自动时间后重试</string>
<string name="report_reason_often">过于频繁</string>
<string name="report_reason_often_d">一小时后再试</string>
<string name="report_reason_large">日志过大</string>
<string name="report_reason_large_d">请通过邮件发送</string>
<string name="report_reason_rejected">请求被拒绝</string>
<string name="report_reason_rejected_d">请通过邮件发送日志</string>
<string name="report_mail_note">也可以像以前一样通过邮件发送日志。</string>
<string name="report_retry">重试</string>
<string name="report_by_letter">通过邮件发送</string>
<string name="report_event_title">报告已发送,代码 %1$s</string>
<string name="report_event_text">把此代码告诉客服,以便找到你的日志。</string>
</resources>
@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- App self-update: the Updates page and the update sheets -->
<resources>
<string name="updates_have_size">当前 %1$s · %2$s</string>
<string name="updates_review">查看并安装</string>
<string name="updates_section_auto">自动</string>
<string name="updates_auto_check">自动检查</string>
<string name="updates_silent">静默安装</string>
<string name="updates_silent_sub">通过 Wi-Fi 下载,并在 VPN 关闭时安装</string>
<string name="updates_section_channel">渠道</string>
<string name="updates_channel_stable">稳定版</string>
<string name="updates_channel_stable_sub">经过测试的版本</string>
<string name="updates_channel_beta">测试版</string>
<string name="updates_channel_beta_sub">更早获得,但可能有错误</string>
<string name="updates_channel_hint">测试版需要管理员提供的邀请码。</string>
<string name="updates_beta_title">测试版邀请码</string>
<string name="updates_beta_text">输入管理员提供的邀请码。没有有效的邀请码时,应用仍使用稳定版。</string>
<string name="updates_beta_code">邀请码</string>
<string name="updates_beta_enable">启用测试版</string>
<string name="updates_check_now">立即检查</string>
<string name="updates_last_check">上次检查:%1$s</string>
<string name="updates_never_checked">尚未检查</string>
<string name="updates_review_sub">当前 %1$s · %2$s · 安装前会校验文件</string>
<string name="updates_review_safe">应用会校验校验和与 SHX 签名,伪造的文件无法安装。</string>
<string name="updates_downloading_title">正在下载 %1$s</string>
<string name="updates_downloading_sub">可以关闭此面板,下载会继续</string>
<string name="updates_progress">%1$d%% · %2$s / %3$s</string>
<string name="updates_step_list">已获取版本列表</string>
<string name="updates_step_list_sub">通过受保护的通道</string>
<string name="updates_step_download">下载文件</string>
<string name="updates_step_verify">校验文件</string>
<string name="updates_step_verify_sub">校验和与 SHX 签名</string>
<string name="updates_step_install">安装</string>
<string name="updates_step_install_sub">Android 会请求确认</string>
<string name="updates_cancel">取消</string>
<string name="updates_mandatory_title">需要更新 SHX VPN</string>
<string name="updates_mandatory_sub">版本 %1$s 已不再受支持</string>
<string name="updates_mandatory_what">这意味着什么</string>
<string name="updates_mandatory_reason">服务器不再接受旧版本的连接</string>
<string name="updates_mandatory_install">安装 %1$s(%2$s),你的服务器和设置会保留</string>
<string name="updates_mandatory_locked">应用更新前无法关闭此面板。</string>
<string name="updates_exit">退出应用</string>
<string name="updates_failed_title">更新未安装</string>
<string name="updates_failed_sub_check">文件未通过校验</string>
<string name="updates_failed_sub_network">文件未能下载</string>
<string name="updates_failed_sub_install">Android 未安装该文件</string>
<string name="updates_failed_sub_other">出现问题</string>
<string name="updates_failed_what">发生了什么</string>
<string name="updates_reason_hash">校验和不匹配</string>
<string name="updates_reason_hash_d">文件在传输中损坏,副本已删除</string>
<string name="updates_reason_signature">签名不匹配</string>
<string name="updates_reason_signature_d">文件不是由 SHX 签名的,副本已删除</string>
<string name="updates_reason_network">无法连接更新服务器</string>
<string name="updates_reason_network_d">下载已中断</string>
<string name="updates_reason_install">Android 拒绝了安装</string>
<string name="updates_reason_install_d">系统安装程序返回了错误</string>
<string name="updates_reason_other">意外错误</string>
<string name="updates_reason_other_d">可在下方复制给支持人员的详细信息</string>
<string name="updates_reason_same">版本未变化</string>
<string name="updates_reason_same_d">当前仍为 %1$s</string>
<string name="updates_reason_later">请稍后再试</string>
<string name="updates_reason_later_d">或切换网络:用 Wi-Fi 代替移动数据</string>
<string name="updates_manual_note">也可以从下载页面手动安装,你的服务器和设置会保留。</string>
<string name="updates_retry">重试</string>
<string name="updates_from_site">从网站下载</string>
<string name="updates_support">复制给支持人员的详细信息</string>
<string name="updates_copied">已复制</string>
<plurals name="updates_auto_check_sub">
<item quantity="other">手机充电时每 %d 小时检查一次</item>
</plurals>
</resources>
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- "Send report" sheet of the log screen (mockups App7-App10) -->
<resources>
<string name="report_intro">報告將透過加密通道傳送給 SHX 團隊。客服憑報告代碼即可找到你的日誌。</string>
<string name="report_contents">報告包含的內容</string>
<string name="report_item_log">今天的連線日誌</string>
<string name="report_item_device">應用程式版本和手機型號</string>
<string name="report_item_network">網路類型和目前伺服器</string>
<string name="report_item_ips">日誌中的伺服器 IP 位址</string>
<string name="report_diag_title">附加診斷資訊</string>
<string name="report_diag_sub">網路檢查結果和應用程式的詳細日誌</string>
<string name="report_comment_label">發生了什麼(選填)</string>
<string name="report_cancel">取消</string>
<string name="report_send">傳送</string>
<string name="report_by_mail">透過電子郵件傳送</string>
<string name="report_sent_title">報告已傳送</string>
<string name="report_sent_sub">請把此代碼告訴客服</string>
<string name="report_code_label">報告代碼</string>
<string name="report_copy_code">複製代碼</string>
<string name="report_code_copied">代碼已複製</string>
<string name="report_saved_note">代碼已儲存在「事件」中,之後可以在那裡找到。</string>
<string name="report_done">完成</string>
<string name="report_failed_title">報告未傳送</string>
<string name="report_failed_no_answer">伺服器沒有回應</string>
<string name="report_failed_refused">伺服器拒絕了報告</string>
<string name="report_reasons">可能的原因</string>
<string name="report_reason_offline">沒有網路</string>
<string name="report_reason_offline_d">檢查網路後重試</string>
<string name="report_reason_server">伺服器無法使用</string>
<string name="report_reason_server_d">幾分鐘後重試</string>
<string name="report_reason_clock">手機時間不準</string>
<string name="report_reason_clock_d">開啟自動時間後重試</string>
<string name="report_reason_often">過於頻繁</string>
<string name="report_reason_often_d">一小時後再試</string>
<string name="report_reason_large">日誌過大</string>
<string name="report_reason_large_d">請透過電子郵件傳送</string>
<string name="report_reason_rejected">要求被拒絕</string>
<string name="report_reason_rejected_d">請透過電子郵件傳送日誌</string>
<string name="report_mail_note">也可以像以前一樣透過電子郵件傳送日誌。</string>
<string name="report_retry">重試</string>
<string name="report_by_letter">透過電子郵件傳送</string>
<string name="report_event_title">報告已傳送,代碼 %1$s</string>
<string name="report_event_text">把此代碼告訴客服,以便找到你的日誌。</string>
</resources>
@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- App self-update: the Updates page and the update sheets -->
<resources>
<string name="updates_have_size">目前 %1$s · %2$s</string>
<string name="updates_review">檢視並安裝</string>
<string name="updates_section_auto">自動</string>
<string name="updates_auto_check">自動檢查</string>
<string name="updates_silent">靜默安裝</string>
<string name="updates_silent_sub">透過 Wi-Fi 下載,並在 VPN 關閉時安裝</string>
<string name="updates_section_channel">頻道</string>
<string name="updates_channel_stable">穩定版</string>
<string name="updates_channel_stable_sub">經過測試的版本</string>
<string name="updates_channel_beta">測試版</string>
<string name="updates_channel_beta_sub">更早取得,但可能有錯誤</string>
<string name="updates_channel_hint">測試版需要管理員提供的邀請碼。</string>
<string name="updates_beta_title">測試版邀請碼</string>
<string name="updates_beta_text">輸入管理員提供的邀請碼。沒有有效的邀請碼時,應用程式仍使用穩定版。</string>
<string name="updates_beta_code">邀請碼</string>
<string name="updates_beta_enable">啟用測試版</string>
<string name="updates_check_now">立即檢查</string>
<string name="updates_last_check">上次檢查:%1$s</string>
<string name="updates_never_checked">尚未檢查</string>
<string name="updates_review_sub">目前 %1$s · %2$s · 安裝前會驗證檔案</string>
<string name="updates_review_safe">應用程式會驗證檢查碼與 SHX 簽章,偽造的檔案無法安裝。</string>
<string name="updates_downloading_title">正在下載 %1$s</string>
<string name="updates_downloading_sub">可以關閉此面板,下載會繼續</string>
<string name="updates_progress">%1$d%% · %2$s / %3$s</string>
<string name="updates_step_list">已取得版本清單</string>
<string name="updates_step_list_sub">透過受保護的通道</string>
<string name="updates_step_download">下載檔案</string>
<string name="updates_step_verify">驗證檔案</string>
<string name="updates_step_verify_sub">檢查碼與 SHX 簽章</string>
<string name="updates_step_install">安裝</string>
<string name="updates_step_install_sub">Android 會要求確認</string>
<string name="updates_cancel">取消</string>
<string name="updates_mandatory_title">需要更新 SHX VPN</string>
<string name="updates_mandatory_sub">版本 %1$s 已不再支援</string>
<string name="updates_mandatory_what">這代表什麼</string>
<string name="updates_mandatory_reason">伺服器不再接受舊版本的連線</string>
<string name="updates_mandatory_install">安裝 %1$s(%2$s),你的伺服器和設定會保留</string>
<string name="updates_mandatory_locked">應用程式更新前無法關閉此面板。</string>
<string name="updates_exit">結束應用程式</string>
<string name="updates_failed_title">更新未安裝</string>
<string name="updates_failed_sub_check">檔案未通過驗證</string>
<string name="updates_failed_sub_network">檔案未能下載</string>
<string name="updates_failed_sub_install">Android 未安裝該檔案</string>
<string name="updates_failed_sub_other">發生問題</string>
<string name="updates_failed_what">發生了什麼</string>
<string name="updates_reason_hash">檢查碼不相符</string>
<string name="updates_reason_hash_d">檔案在傳輸中損壞,副本已刪除</string>
<string name="updates_reason_signature">簽章不相符</string>
<string name="updates_reason_signature_d">檔案不是由 SHX 簽署,副本已刪除</string>
<string name="updates_reason_network">無法連線到更新伺服器</string>
<string name="updates_reason_network_d">下載已中斷</string>
<string name="updates_reason_install">Android 拒絕了安裝</string>
<string name="updates_reason_install_d">系統安裝程式傳回了錯誤</string>
<string name="updates_reason_other">非預期的錯誤</string>
<string name="updates_reason_other_d">可在下方複製給支援人員的詳細資訊</string>
<string name="updates_reason_same">版本未變更</string>
<string name="updates_reason_same_d">目前仍為 %1$s</string>
<string name="updates_reason_later">請稍後再試</string>
<string name="updates_reason_later_d">或切換網路:用 Wi-Fi 取代行動數據</string>
<string name="updates_manual_note">也可以從下載頁面手動安裝,你的伺服器和設定會保留。</string>
<string name="updates_retry">重試</string>
<string name="updates_from_site">從網站下載</string>
<string name="updates_support">複製給支援人員的詳細資訊</string>
<string name="updates_copied">已複製</string>
<plurals name="updates_auto_check_sub">
<item quantity="other">手機充電時每 %d 小時檢查一次</item>
</plurals>
</resources>
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- "Send report" sheet of the log screen (mockups App7-App10) -->
<resources>
<string name="report_intro">The report goes to the SHX team over a secure channel. With the report code, support will find your log.</string>
<string name="report_contents">What the report includes</string>
<string name="report_item_log">Today’s connection log</string>
<string name="report_item_device">App version and phone model</string>
<string name="report_item_network">Network type and active server</string>
<string name="report_item_ips">Server IP addresses from the log</string>
<string name="report_diag_title">Attach diagnostics</string>
<string name="report_diag_sub">Network check results and the app’s extended log</string>
<string name="report_comment_label">What happened (optional)</string>
<string name="report_cancel">Cancel</string>
<string name="report_send">Send</string>
<string name="report_by_mail">Send by email</string>
<string name="report_sent_title">Report sent</string>
<string name="report_sent_sub">Tell this code to support</string>
<string name="report_code_label">Report code</string>
<string name="report_copy_code">Copy code</string>
<string name="report_code_copied">Code copied</string>
<string name="report_saved_note">The code is saved in “Events”, you can find it there later.</string>
<string name="report_done">Done</string>
<string name="report_failed_title">Report not sent</string>
<string name="report_failed_no_answer">The server did not answer</string>
<string name="report_failed_refused">The server refused the report</string>
<string name="report_reasons">Possible reasons</string>
<string name="report_reason_offline">No internet</string>
<string name="report_reason_offline_d">check the network and retry</string>
<string name="report_reason_server">Server unavailable</string>
<string name="report_reason_server_d">retry in a few minutes</string>
<string name="report_reason_clock">Phone clock is off</string>
<string name="report_reason_clock_d">turn on automatic time and retry</string>
<string name="report_reason_often">Too often</string>
<string name="report_reason_often_d">try again in an hour</string>
<string name="report_reason_large">Log is too large</string>
<string name="report_reason_large_d">send it by email</string>
<string name="report_reason_rejected">Request refused</string>
<string name="report_reason_rejected_d">send the log by email</string>
<string name="report_mail_note">The log can also be sent by email, as before.</string>
<string name="report_retry">Retry</string>
<string name="report_by_letter">Send by email</string>
<string name="report_event_title">Report sent, code %1$s</string>
<string name="report_event_text">Tell this code to support so they can find your log.</string>
</resources>
@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- App self-update: the Updates page and the update sheets -->
<resources>
<string name="updates_have_size">You have %1$s · %2$s</string>
<string name="updates_review">View and install</string>
<string name="updates_section_auto">Automatically</string>
<string name="updates_auto_check">Check by itself</string>
<string name="updates_silent">Install quietly</string>
<string name="updates_silent_sub">Download over Wi-Fi and install while the VPN is off</string>
<string name="updates_section_channel">Channel</string>
<string name="updates_channel_stable">Stable</string>
<string name="updates_channel_stable_sub">Tested versions</string>
<string name="updates_channel_beta">Beta</string>
<string name="updates_channel_beta_sub">Earlier, but with bugs</string>
<string name="updates_channel_hint">Beta opens with an invitation code from the administrator.</string>
<string name="updates_beta_title">Beta invitation code</string>
<string name="updates_beta_text">Enter the code the administrator gave you. Without a valid code the app stays on stable versions.</string>
<string name="updates_beta_code">Invitation code</string>
<string name="updates_beta_enable">Turn on beta</string>
<string name="updates_check_now">Check now</string>
<string name="updates_last_check">Last check: %1$s</string>
<string name="updates_never_checked">Not checked yet</string>
<string name="updates_review_sub">You have %1$s · %2$s · the file is checked before installing</string>
<string name="updates_review_safe">The app checks the checksum and the SHX signature. A forged file will not install.</string>
<string name="updates_downloading_title">Downloading %1$s</string>
<string name="updates_downloading_sub">You can close this sheet: the download goes on</string>
<string name="updates_progress">%1$d%% · %2$s of %3$s</string>
<string name="updates_step_list">Version list received</string>
<string name="updates_step_list_sub">Over a protected channel</string>
<string name="updates_step_download">Downloading the file</string>
<string name="updates_step_verify">Checking the file</string>
<string name="updates_step_verify_sub">Checksum and SHX signature</string>
<string name="updates_step_install">Installation</string>
<string name="updates_step_install_sub">Android will ask for confirmation</string>
<string name="updates_cancel">Cancel</string>
<string name="updates_mandatory_title">SHX VPN needs an update</string>
<string name="updates_mandatory_sub">Version %1$s is no longer supported</string>
<string name="updates_mandatory_what">What this means</string>
<string name="updates_mandatory_reason">The server no longer accepts connections from the old version</string>
<string name="updates_mandatory_install">Install %1$s (%2$s): your servers and settings stay</string>
<string name="updates_mandatory_locked">This sheet can’t be closed until the app is updated.</string>
<string name="updates_exit">Exit the app</string>
<string name="updates_failed_title">Update not installed</string>
<string name="updates_failed_sub_check">The file did not pass the check</string>
<string name="updates_failed_sub_network">The file did not download</string>
<string name="updates_failed_sub_install">Android did not install the file</string>
<string name="updates_failed_sub_other">Something went wrong</string>
<string name="updates_failed_what">What happened</string>
<string name="updates_reason_hash">Checksum mismatch</string>
<string name="updates_reason_hash_d">the file was damaged on the way, the copy is deleted</string>
<string name="updates_reason_signature">Signature mismatch</string>
<string name="updates_reason_signature_d">the file is not signed by SHX, the copy is deleted</string>
<string name="updates_reason_network">No connection to the update server</string>
<string name="updates_reason_network_d">the download was interrupted</string>
<string name="updates_reason_install">Android rejected the installation</string>
<string name="updates_reason_install_d">the system installer returned an error</string>
<string name="updates_reason_other">Unexpected error</string>
<string name="updates_reason_other_d">the details for support are below</string>
<string name="updates_reason_same">Version unchanged</string>
<string name="updates_reason_same_d">you still have %1$s</string>
<string name="updates_reason_later">Try again later</string>
<string name="updates_reason_later_d">or switch networks: Wi-Fi instead of mobile data</string>
<string name="updates_manual_note">You can install it manually from the download page. Your servers and settings stay in place.</string>
<string name="updates_retry">Retry</string>
<string name="updates_from_site">Download from the site</string>
<string name="updates_support">Details for support</string>
<string name="updates_copied">Copied</string>
<plurals name="updates_auto_check_sub">
<item quantity="one">Every %d hour while the phone is charging</item>
<item quantity="other">Every %d hours while the phone is charging</item>
</plurals>
</resources>
@@ -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 <http://www.fsf.org/copyleft/gpl.txt>.
*
* 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, "<html>proxy</html>")
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")
}
}
@@ -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 <http://www.fsf.org/copyleft/gpl.txt>.
*
* 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)
}
}
@@ -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 <http://www.fsf.org/copyleft/gpl.txt>.
*
* 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<String, String> = emptyMap()
override fun get(url: String, headers: Map<String, String>): 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()
}
}
@@ -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)
}
}
@@ -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="
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
# SHX VPN для Android
Форк Android-клиента strongSwan (IKEv2/IPsec), пакет `one.shx.strongswan.ext`. Текущая версия — **2.19.0 (versionCode 168)**.
Форк Android-клиента strongSwan (IKEv2/IPsec), пакет `one.shx.strongswan.ext`. Текущая версия — **2.19.1 (versionCode 169)**.
Распространяется вне Google Play: APK и манифест обновлений лежат в приватном бакете S3, приложение обновляется само.
- Дизайн: [design/README.md](design/README.md) — макет «Финал 2026» и соответствие артбордов коду.
@@ -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 <id типа> [--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}`);