Šepot — end-to-end encrypted messenger

Initial public release. Anonymous on-device identity (no phone number,
e-mail, or password), Signal-protocol cryptography (X3DH + PQXDH, Double
Ratchet, sealed sender) via libsignal, a metadata-minimizing relay,
group chats, disappearing messages, encrypted local storage, optional
Tor transport, and a UI localized into Czech, English, German, Russian,
and Italian.

Monorepo: Flutter client (app/), Rust relay + wire types (rust/),
protobuf definitions (proto/), self-host deploy (deploy/).

Licensed under AGPL-3.0 with an app-store distribution exception (§7).
This commit is contained in:
Julius Joska
2026-07-11 17:06:39 +02:00
commit 8081d98c67
309 changed files with 44102 additions and 0 deletions
+115
View File
@@ -0,0 +1,115 @@
/// Příprava obrázků před odesláním.
///
/// Každá fotka se před šifrováním RE-ENKÓDUJE (resize na max 2048 px,
/// JPEG q80) — tím se zahodí VŠECHNA metadata včetně EXIF/GPS (privacy:
/// poloha focení nesmí odjet ani E2E kanálem, protistrana ji nemá co
/// vidět). PNG (screenshoty, průhlednost) zůstává PNG, GIF se posílá
/// beze změny (animace; EXIF u něj nehrozí).
///
/// Náhled (JPEG ≤ 48 KiB) jede přímo uvnitř E2E plaintextu zprávy —
/// příjemce vidí obrázek okamžitě, bez čekání na download blobu.
library;
import 'dart:isolate';
import 'dart:math';
import 'dart:typed_data';
import 'package:image/image.dart' as img;
class ProcessedImage {
const ProcessedImage({
required this.bytes,
required this.mime,
required this.width,
required this.height,
required this.thumbnailJpeg,
});
final Uint8List bytes;
final String mime;
final int width;
final int height;
final Uint8List thumbnailJpeg;
}
class ImageProcessor {
ImageProcessor._();
static const int maxDimension = 2048;
static const int jpegQuality = 80;
static const int thumbDimension = 320;
/// Náhled musí bezpečně projet obálkou (limit 256 KiB na CELOU
/// sealed-sender obálku) — držet výrazně pod tím.
static const int thumbMaxBytes = 48 * 1024;
/// Zpracuje obrázek v izolátu (dekódování megapixelů by blokovalo UI).
/// Vrací null, když bajty nejsou dekódovatelný obrázek — volající je
/// pak pošle jako dokument.
static Future<ProcessedImage?> process(
Uint8List original, {
String? mimeHint,
}) {
return Isolate.run(() {
final decoded = img.decodeImage(original);
if (decoded == null) return null;
// GIF beze změny — re-encode by zabil animaci
if (mimeHint == 'image/gif') {
return ProcessedImage(
bytes: original,
mime: 'image/gif',
width: decoded.width,
height: decoded.height,
thumbnailJpeg: _thumbnail(decoded),
);
}
var image = img.bakeOrientation(decoded);
if (max(image.width, image.height) > maxDimension) {
image = image.width >= image.height
? img.copyResize(image, width: maxDimension)
: img.copyResize(image, height: maxDimension);
}
// PNG s průhledností zůstává PNG (JPEG alfa kanál neumí, a re-encode
// PNG→PNG metadata stejně zahodí); vše ostatní JPEG q80.
final keepPng = mimeHint == 'image/png' && decoded.numChannels == 4;
final bytes = keepPng
? Uint8List.fromList(img.encodePng(image))
: Uint8List.fromList(img.encodeJpg(image, quality: jpegQuality));
return ProcessedImage(
bytes: bytes,
mime: keepPng ? 'image/png' : 'image/jpeg',
width: image.width,
height: image.height,
thumbnailJpeg: _thumbnail(image),
);
});
}
static Uint8List _thumbnail(img.Image source) {
var thumb = max(source.width, source.height) > thumbDimension
? (source.width >= source.height
? img.copyResize(source, width: thumbDimension)
: img.copyResize(source, height: thumbDimension))
: source;
// JPEG náhled nemá alfu — průhlednost složit na tmavé pozadí
if (thumb.numChannels == 4) {
final flat = img.Image(width: thumb.width, height: thumb.height);
img.fill(flat, color: img.ColorRgb8(20, 20, 31));
img.compositeImage(flat, thumb);
thumb = flat;
}
var quality = 70;
var encoded = Uint8List.fromList(img.encodeJpg(thumb, quality: quality));
// Náhled je jen placeholder, než dorazí ostrá fotka — kvalitu držíme
// rozumně (min 40), radši menší rozměr než rozsypaný obraz.
while (encoded.length > thumbMaxBytes && quality > 40) {
quality -= 10;
encoded = Uint8List.fromList(img.encodeJpg(thumb, quality: quality));
}
return encoded;
}
}
File diff suppressed because it is too large Load Diff