android: screenshots can be attached to a report: the «Send report» sheet has a row with up to 3 images picked in the system photo picker (no storage permission); each one is re-encoded on picking as a JPEG with the long side of 1600 px and at most 1 MB and goes in the same request as a shot part, the 2 MB limit still covers the log and the meta only; a panel without screenshot support refuses such a report, so the panel is deployed first
Android / pre-check (push) Failing after 4s
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 3s
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 3s
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 3s
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 3s
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 Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Fable 5.2 committed 2026-11-15 16:35:13 +07:00
1 parent 20f9040e48
commit e372e53598
12 files changed
+202 -9

No files matched your search

@@ -0,0 +1,70 @@
/*
* 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 android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import java.io.ByteArrayOutputStream
/**
* Screenshots attached to a report: each picked image is re-encoded right away as a JPEG
* the panel accepts (the `shot` parts of [ReportUploader]). Re-encoding also drops EXIF.
*/
object ReportShots
{
/** Server limits: up to 3 parts, 1 MB each */
const val MAX = 3
const val MAX_BYTES = 1024 * 1024
/* a 1080x2400 screenshot becomes 720x1600: text stays readable, the file is ~150-300 KB */
const val LONG_SIDE = 1600
/** The largest power of two that keeps the long side at [longSide] or more */
fun sampleSize(width: Int, height: Int, longSide: Int = LONG_SIDE): Int
{
var sample = 1
while (maxOf(width, height) / (sample * 2) >= longSide)
{
sample *= 2
}
return sample
}
/** JPEG of the image behind [uri], or null when it cannot be read or does not fit the limit */
fun encode(context: Context, uri: Uri): ByteArray? = try
{
val resolver = context.contentResolver
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
resolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, bounds) }
val options = BitmapFactory.Options().apply { inSampleSize = sampleSize(bounds.outWidth, bounds.outHeight) }
/* shortcut: EXIF rotation is ignored (screenshots have none), a camera photo may come out sideways */
resolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, options) }?.let { decoded ->
val long = maxOf(decoded.width, decoded.height)
val bitmap = if (long <= LONG_SIDE) decoded else Bitmap.createScaledBitmap(decoded, decoded.width * LONG_SIDE / long, decoded.height * LONG_SIDE / long, true)
sequenceOf(80, 60, 40)
.map { quality -> ByteArrayOutputStream().also { bitmap.compress(Bitmap.CompressFormat.JPEG, quality, it) }.toByteArray() }
.firstOrNull { it.size <= MAX_BYTES }
}
}
catch (e: Exception)
{
null
}
catch (e: OutOfMemoryError)
{
null
}
}
@@ -21,7 +21,7 @@ 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
* Sends a diagnostic log and optional screenshots 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].
*/
@@ -51,7 +51,7 @@ class ReportUploader(
companion object
{
/** Server limit: the whole request body must stay below 2 MB */
/** Server limit: the log and the meta must stay below 2 MB (screenshots have their own limit, see [ReportShots]) */
const val MAX_BYTES = 2 * 1024 * 1024
const val BOUNDARY = "----shxreport"
private val CODE = Regex("^SHX-[0-9A-Z]{5}$")
@@ -87,13 +87,17 @@ class ReportUploader(
.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
/** multipart/form-data with the gzip log, the JPEG screenshots and the meta JSON */
fun multipartBody(logGz: ByteArray, meta: String, shots: List<ByteArray> = emptyList()): 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)
shots.forEach {
w("\r\n--$BOUNDARY\r\nContent-Disposition: form-data; name=\"shot\"; filename=\"shot.jpg\"\r\nContent-Type: image/jpeg\r\n\r\n")
out.write(it)
}
/* 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)
@@ -102,10 +106,10 @@ class ReportUploader(
}
}
fun send(logText: String, meta: Meta): Result
fun send(logText: String, meta: Meta, shots: List<ByteArray> = emptyList()): Result
{
val body = multipartBody(gzip(logText), metaJson(meta))
if (body.size > MAX_BYTES)
val body = multipartBody(gzip(logText), metaJson(meta), shots)
if (body.size - shots.sumOf { it.size } > MAX_BYTES)
{
return Result.Failed(413, "report is larger than 2 MB")
}
@@ -18,10 +18,16 @@ import android.app.Application
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.graphics.BitmapFactory
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.Uri
import android.os.Build
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -39,13 +45,17 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
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.asImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
@@ -71,6 +81,7 @@ 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.ReportShots
import org.strongswan.android.logic.updates.ReportUploader
import org.strongswan.android.logic.updates.UpdateManager
import org.strongswan.android.ui.compose.design.ShxButton
@@ -103,6 +114,24 @@ class ReportViewModel(app: Application) : AndroidViewModel(app)
private set
var comment by mutableStateOf("")
var attachDiag by mutableStateOf(true)
/** Screenshots as the JPEGs to upload: encoded on picking, so no Uri grant has to live until the send */
val shots = mutableStateListOf<ByteArray>()
fun addShots(uris: List<Uri>)
{
val app = getApplication<Application>()
viewModelScope.launch {
/* the picker of old Androids ignores the limit */
for (uri in uris.take(ReportShots.MAX))
{
val jpeg = withContext(Dispatchers.IO) { ReportShots.encode(app, uri) }
if (jpeg != null && shots.size < ReportShots.MAX)
{
shots.add(jpeg)
}
}
}
}
/* a swipe may hide the sheet mid-upload: the result then opens it again */
private var job: Job? = null
@@ -127,6 +156,7 @@ class ReportViewModel(app: Application) : AndroidViewModel(app)
val app = getApplication<Application>()
val note = comment
val diag = attachDiag
val images = shots.toList()
job = viewModelScope.launch {
/* the same checks as the Diagnostics screen (1-8 s); a hang must not block the report */
val checks = if (!diag) null else withTimeoutOrNull(15_000) {
@@ -144,7 +174,7 @@ class ReportViewModel(app: Application) : AndroidViewModel(app)
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)
ReportUploader(BuildConfig.UPDATE_API_URL, updates.apiHmacKey(), updates.installId()).send(text, meta, images)
}
when (result)
{
@@ -152,6 +182,7 @@ class ReportViewModel(app: Application) : AndroidViewModel(app)
{
Events.reportSent(app, result.code)
comment = ""
shots.clear()
ui = Ui.Sent(result.code)
}
is ReportUploader.Result.Failed ->
@@ -226,6 +257,7 @@ private fun ReportForm(model: ReportViewModel, sending: Boolean, onSend: () -> U
subtitle = stringResource(R.string.report_diag_sub), enabled = !sending,
)
}
ShotsRow(model, enabled = !sending)
ShxFormField(
label = stringResource(R.string.report_comment_label),
value = model.comment,
@@ -251,6 +283,53 @@ private fun ReportForm(model: ReportViewModel, sending: Boolean, onSend: () -> U
}
}
/** Picked screenshots as 64dp tiles (a tap removes one) and the «add» tile that opens the system photo picker */
@Composable
private fun ShotsRow(model: ReportViewModel, enabled: Boolean)
{
val c = LocalShxColors.current
val shape = RoundedCornerShape(14.dp)
val pick = rememberLauncherForActivityResult(ActivityResultContracts.PickMultipleVisualMedia(ReportShots.MAX), model::addShots)
Column(verticalArrangement = Arrangement.spacedBy(8.dp))
{
Text(stringResource(R.string.report_shots_label), style = ShxType.sub, color = c.textSub, modifier = Modifier.padding(horizontal = 4.dp))
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
model.shots.forEachIndexed { index, jpeg ->
val image = remember(jpeg) {
BitmapFactory.decodeByteArray(jpeg, 0, jpeg.size, BitmapFactory.Options().apply { inSampleSize = 4 })?.asImageBitmap()
}
Box(
Modifier.size(64.dp).clip(shape).background(c.tile).border(1.dp, c.cardBorder, shape)
.clickable(enabled = enabled, role = Role.Button, onClickLabel = stringResource(R.string.report_shot_remove)) { model.shots.removeAt(index) },
)
{
if (image != null)
{
Image(image, contentDescription = stringResource(R.string.report_shot_remove), contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize())
}
Box(contentAlignment = Alignment.Center, modifier = Modifier.align(Alignment.TopEnd).padding(4.dp).size(20.dp).clip(CircleShape).background(c.card))
{
Icon(ShxIcons.Close, contentDescription = null, tint = c.text, modifier = Modifier.size(12.dp))
}
}
}
if (model.shots.size < ReportShots.MAX)
{
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(64.dp).clip(shape).background(c.tile).clickable(enabled = enabled, role = Role.Button) {
pick.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
},
)
{
Icon(ShxIcons.Plus, contentDescription = stringResource(R.string.report_shot_add), tint = c.onAccentContainer, modifier = Modifier.size(22.dp))
}
}
}
}
}
/* App9 */
@Composable
private fun ReportSent(code: String, onDone: () -> Unit)
@@ -9,6 +9,9 @@
<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_shots_label">Screenshots (optional, bis zu 3)</string>
<string name="report_shot_add">Screenshot hinzufügen</string>
<string name="report_shot_remove">Screenshot entfernen</string>
<string name="report_comment_label">Was ist passiert? (optional)</string>
<string name="report_cancel">Abbrechen</string>
<string name="report_send">Senden</string>
@@ -9,6 +9,9 @@
<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_shots_label">Zrzuty ekranu (opcjonalnie, do 3)</string>
<string name="report_shot_add">Dodaj zrzut ekranu</string>
<string name="report_shot_remove">Usuń zrzut ekranu</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>
@@ -9,6 +9,9 @@
<string name="report_item_ips">IP-адреса серверов из журнала</string>
<string name="report_diag_title">Приложить диагностику</string>
<string name="report_diag_sub">Результаты проверки сети и расширенный журнал приложения</string>
<string name="report_shots_label">Скриншоты (необязательно, до 3)</string>
<string name="report_shot_add">Добавить скриншот</string>
<string name="report_shot_remove">Убрать скриншот</string>
<string name="report_comment_label">Что случилось (необязательно)</string>
<string name="report_cancel">Отмена</string>
<string name="report_send">Отправить</string>
@@ -9,6 +9,9 @@
<string name="report_item_ips">IP-адреси серверів із журналу</string>
<string name="report_diag_title">Додати діагностику</string>
<string name="report_diag_sub">Результати перевірки мережі та розширений журнал застосунку</string>
<string name="report_shots_label">Знімки екрана (необов’язково, до 3)</string>
<string name="report_shot_add">Додати знімок екрана</string>
<string name="report_shot_remove">Прибрати знімок екрана</string>
<string name="report_comment_label">Що сталося (необов’язково)</string>
<string name="report_cancel">Скасувати</string>
<string name="report_send">Надіслати</string>
@@ -9,6 +9,9 @@
<string name="report_item_ips">日志中的服务器 IP 地址</string>
<string name="report_diag_title">附加诊断信息</string>
<string name="report_diag_sub">网络检查结果和应用的详细日志</string>
<string name="report_shots_label">截图(可选,最多 3 张)</string>
<string name="report_shot_add">添加截图</string>
<string name="report_shot_remove">移除截图</string>
<string name="report_comment_label">发生了什么(可选)</string>
<string name="report_cancel">取消</string>
<string name="report_send">发送</string>
@@ -9,6 +9,9 @@
<string name="report_item_ips">日誌中的伺服器 IP 位址</string>
<string name="report_diag_title">附加診斷資訊</string>
<string name="report_diag_sub">網路檢查結果和應用程式的詳細日誌</string>
<string name="report_shots_label">螢幕截圖(選填,最多 3 張)</string>
<string name="report_shot_add">新增螢幕截圖</string>
<string name="report_shot_remove">移除螢幕截圖</string>
<string name="report_comment_label">發生了什麼(選填)</string>
<string name="report_cancel">取消</string>
<string name="report_send">傳送</string>
@@ -9,6 +9,9 @@
<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_shots_label">Screenshots (optional, up to 3)</string>
<string name="report_shot_add">Add a screenshot</string>
<string name="report_shot_remove">Remove the screenshot</string>
<string name="report_comment_label">What happened (optional)</string>
<string name="report_cancel">Cancel</string>
<string name="report_send">Send</string>
@@ -56,6 +56,25 @@ class ReportUploaderTest
assertThat(body).endsWith("--${ReportUploader.BOUNDARY}--\r\n")
}
@Test
fun screenshotsGoAsShotPartsBetweenTheLogAndTheMeta()
{
val shot = byteArrayOf(0xff.toByte(), 0xd8.toByte(), 0xff.toByte(), 7)
val body = ReportUploader.multipartBody(byteArrayOf(0x1f, 0x8b.toByte()), "{}", listOf(shot, shot)).decodeToString(throwOnInvalidSequence = false)
assertThat(body.split("name=\"shot\"; filename=\"shot.jpg\"\r\nContent-Type: image/jpeg\r\n\r\n")).hasSize(3)
assertThat(body.indexOf("name=\"log\"")).isLessThan(body.indexOf("name=\"shot\""))
assertThat(body.indexOf("name=\"shot\"")).isLessThan(body.indexOf("name=\"meta\""))
}
@Test
fun theSampleSizeKeepsTheLongSideAtOrAboveTheTarget()
{
assertThat(ReportShots.sampleSize(1080, 2400)).isEqualTo(1)
assertThat(ReportShots.sampleSize(3200, 1440)).isEqualTo(2)
assertThat(ReportShots.sampleSize(4000, 12000)).isEqualTo(4)
assertThat(ReportShots.sampleSize(0, 0)).isEqualTo(1)
}
@Test
fun anOversizedReportIsRefusedBeforeAnyRequest()
{
+1 -1
View File
@@ -16,7 +16,7 @@
| Сценарии | Правила для сетей (доверенная Wi-Fi / мобильная или чужая сеть) и свои сценарии: Wi-Fi, мобильная сеть, приложение открыто, время, роуминг, белые списки → включить, выключить, пауза, сменить сервер. Общий порядок, верхний важнее; ручное действие перекрывает сценарий до его окончания |
| Защита | DNS через туннель: из профиля, Cloudflare, AdGuard (реклама и трекеры), Cloudflare 1.1.1.2 (фишинг), свой адрес; свой DoH (`https://…`) или DoT (`tls://…`): DNS туннеля — виртуальный `198.18.0.53`, нативный DNS-прокси отдаёт запросы локальному резолверу `SecureDns` (127.0.0.1), тот шифрует их к серверу пользователя через физическую сеть; проба при подключении, иначе DNS профиля и событие; ошибки → SERVFAIL; всё пишется в diag.log без имён запросов; «Без VPN — без сети» (инструкция к системному Always-on) |
| Статистика | Трафик по дням/неделям/месяцам: через VPN и напрямую, по приложениям (нужен доступ к статистике использования), итоги недели |
| Диагностика | Интернет, сайты РФ и зарубежные, сервер, DNS, белые списки за ~10 секунд; проверка сайта; отчёт в поддержку |
| Диагностика | Интернет, сайты РФ и зарубежные, сервер, DNS, белые списки за ~10 секунд; проверка сайта; отчёт в поддержку (журнал, диагностика и до 3 скриншотов из системного фотопикера, без разрешения на хранилище) |
| События | Центр событий в приложении и push по каналам (статус, проблемы, новые приложения, обновления, итоги недели); «Приложение аварийно завершилось» с кнопкой «Отправить отчёт» (`CrashWatch`: Java, нативный демон, ANR — на Android 11+ по истории завершений процесса); «Постоянный VPN выключили», если блокировка без VPN была включена (`AlwaysOnVpn`: статус спрашивает работающий `VpnService`, Android 10+) |
| Система | Виджеты трёх размеров (Glance), плитка быстрых настроек, ярлыки профилей, автообновление с проверкой SHA-256 и подписи |