diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/updates/ReportShots.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/updates/ReportShots.kt new file mode 100644 index 000000000..65f3e8f63 --- /dev/null +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/updates/ReportShots.kt @@ -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 . + * + * 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 + } +} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/updates/ReportUploader.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/updates/ReportUploader.kt index a9b257d66..5d66ec98d 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/updates/ReportUploader.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/updates/ReportUploader.kt @@ -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 = 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 = 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") } diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/ReportSheet.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/ReportSheet.kt index eef3c5e9b..1716eaebf 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/ReportSheet.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/ReportSheet.kt @@ -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() + + fun addShots(uris: List) + { + val app = getApplication() + 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() 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) diff --git a/src/frontends/android/app/src/main/res/values-de/strings_reports.xml b/src/frontends/android/app/src/main/res/values-de/strings_reports.xml index 10fb5fdb5..0138587ed 100644 --- a/src/frontends/android/app/src/main/res/values-de/strings_reports.xml +++ b/src/frontends/android/app/src/main/res/values-de/strings_reports.xml @@ -9,6 +9,9 @@ IP-Adressen der Server aus dem Protokoll Diagnose anhängen Ergebnisse der Netzwerkprüfung und erweitertes App-Protokoll + Screenshots (optional, bis zu 3) + Screenshot hinzufügen + Screenshot entfernen Was ist passiert? (optional) Abbrechen Senden diff --git a/src/frontends/android/app/src/main/res/values-pl/strings_reports.xml b/src/frontends/android/app/src/main/res/values-pl/strings_reports.xml index 22336ad1b..19e8ed883 100644 --- a/src/frontends/android/app/src/main/res/values-pl/strings_reports.xml +++ b/src/frontends/android/app/src/main/res/values-pl/strings_reports.xml @@ -9,6 +9,9 @@ Adresy IP serwerów z dziennika Dołącz diagnostykę Wyniki sprawdzenia sieci i rozszerzony dziennik aplikacji + Zrzuty ekranu (opcjonalnie, do 3) + Dodaj zrzut ekranu + Usuń zrzut ekranu Co się stało (opcjonalnie) Anuluj Wyślij diff --git a/src/frontends/android/app/src/main/res/values-ru/strings_reports.xml b/src/frontends/android/app/src/main/res/values-ru/strings_reports.xml index 0cfabbc09..50dfda455 100644 --- a/src/frontends/android/app/src/main/res/values-ru/strings_reports.xml +++ b/src/frontends/android/app/src/main/res/values-ru/strings_reports.xml @@ -9,6 +9,9 @@ IP-адреса серверов из журнала Приложить диагностику Результаты проверки сети и расширенный журнал приложения + Скриншоты (необязательно, до 3) + Добавить скриншот + Убрать скриншот Что случилось (необязательно) Отмена Отправить diff --git a/src/frontends/android/app/src/main/res/values-uk/strings_reports.xml b/src/frontends/android/app/src/main/res/values-uk/strings_reports.xml index 969f6b093..6bdefd00a 100644 --- a/src/frontends/android/app/src/main/res/values-uk/strings_reports.xml +++ b/src/frontends/android/app/src/main/res/values-uk/strings_reports.xml @@ -9,6 +9,9 @@ IP-адреси серверів із журналу Додати діагностику Результати перевірки мережі та розширений журнал застосунку + Знімки екрана (необов’язково, до 3) + Додати знімок екрана + Прибрати знімок екрана Що сталося (необов’язково) Скасувати Надіслати diff --git a/src/frontends/android/app/src/main/res/values-zh-rCN/strings_reports.xml b/src/frontends/android/app/src/main/res/values-zh-rCN/strings_reports.xml index d6623912b..910c2602a 100644 --- a/src/frontends/android/app/src/main/res/values-zh-rCN/strings_reports.xml +++ b/src/frontends/android/app/src/main/res/values-zh-rCN/strings_reports.xml @@ -9,6 +9,9 @@ 日志中的服务器 IP 地址 附加诊断信息 网络检查结果和应用的详细日志 + 截图(可选,最多 3 张) + 添加截图 + 移除截图 发生了什么(可选) 取消 发送 diff --git a/src/frontends/android/app/src/main/res/values-zh-rTW/strings_reports.xml b/src/frontends/android/app/src/main/res/values-zh-rTW/strings_reports.xml index cafb119c2..7730fd1f9 100644 --- a/src/frontends/android/app/src/main/res/values-zh-rTW/strings_reports.xml +++ b/src/frontends/android/app/src/main/res/values-zh-rTW/strings_reports.xml @@ -9,6 +9,9 @@ 日誌中的伺服器 IP 位址 附加診斷資訊 網路檢查結果和應用程式的詳細日誌 + 螢幕截圖(選填,最多 3 張) + 新增螢幕截圖 + 移除螢幕截圖 發生了什麼(選填) 取消 傳送 diff --git a/src/frontends/android/app/src/main/res/values/strings_reports.xml b/src/frontends/android/app/src/main/res/values/strings_reports.xml index 7421021b3..601c13581 100644 --- a/src/frontends/android/app/src/main/res/values/strings_reports.xml +++ b/src/frontends/android/app/src/main/res/values/strings_reports.xml @@ -9,6 +9,9 @@ Server IP addresses from the log Attach diagnostics Network check results and the app’s extended log + Screenshots (optional, up to 3) + Add a screenshot + Remove the screenshot What happened (optional) Cancel Send diff --git a/src/frontends/android/app/src/test/java/org/strongswan/android/logic/updates/ReportUploaderTest.kt b/src/frontends/android/app/src/test/java/org/strongswan/android/logic/updates/ReportUploaderTest.kt index 69b62d77d..c2ba90c2c 100644 --- a/src/frontends/android/app/src/test/java/org/strongswan/android/logic/updates/ReportUploaderTest.kt +++ b/src/frontends/android/app/src/test/java/org/strongswan/android/logic/updates/ReportUploaderTest.kt @@ -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() { diff --git a/src/frontends/android/docs/README.md b/src/frontends/android/docs/README.md index 6c598481f..8a9b55f86 100644 --- a/src/frontends/android/docs/README.md +++ b/src/frontends/android/docs/README.md @@ -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 и подписи |