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
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:
1 parent
20f9040e48
commit
e372e53598
12 files changed
+202
-9
No files matched your search
+70
@@ -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
|
||||
}
|
||||
}
|
||||
+11
-7
@@ -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>
|
||||
|
||||
+19
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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 и подписи |
|
||||
|
||||
|
||||
Reference in new issue
Block a user