8081d98c67
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).
521 lines
16 KiB
Dart
521 lines
16 KiB
Dart
/// Obsah bublin s přílohou — obrázek (thumbnail + stav) a dokument
|
|
/// (karta s názvem/velikostí). Plné rozlišení se dešifruje až ve vieweru;
|
|
/// v chatu stačí inline náhled, který dorazil UVNITŘ E2E zprávy.
|
|
library;
|
|
|
|
import 'dart:async';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:audioplayers/audioplayers.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:sepot/core/l10n/error_messages.dart';
|
|
import 'package:sepot/core/l10n/l10n.dart';
|
|
import 'package:sepot/core/theme/app_theme.dart';
|
|
import 'package:sepot/features/conversation/image_viewer_screen.dart';
|
|
import 'package:sepot/models/attachment.dart';
|
|
import 'package:sepot/models/message.dart';
|
|
import 'package:sepot/providers/conversations_provider.dart';
|
|
import 'package:sepot/providers/messaging_provider.dart';
|
|
import 'package:share_plus/share_plus.dart';
|
|
|
|
String formatBytes(AppLocalizations l10n, int bytes) {
|
|
if (bytes < 1024) return l10n.attachmentSizeBytes(bytes);
|
|
if (bytes < 1024 * 1024) {
|
|
return l10n.attachmentSizeKb((bytes / 1024).toStringAsFixed(0));
|
|
}
|
|
return l10n.attachmentSizeMb((bytes / (1024 * 1024)).toStringAsFixed(1));
|
|
}
|
|
|
|
/// Cache dešifrovaných plných obrázků (blob_id → bajty), ať se nedešifruje
|
|
/// při každém překreslení seznamu. Prostý strop, nejstarší se zahazují.
|
|
final _fullImageCache = <String, Uint8List>{};
|
|
const _fullImageCacheMax = 30;
|
|
|
|
void _cachePut(String blobId, Uint8List bytes) {
|
|
_fullImageCache.remove(blobId);
|
|
_fullImageCache[blobId] = bytes;
|
|
while (_fullImageCache.length > _fullImageCacheMax) {
|
|
_fullImageCache.remove(_fullImageCache.keys.first);
|
|
}
|
|
}
|
|
|
|
/// Obrázková příloha: náhled + overlay podle stavu přenosu.
|
|
class ImageAttachmentContent extends ConsumerWidget {
|
|
const ImageAttachmentContent({
|
|
super.key,
|
|
required this.contactId,
|
|
required this.message,
|
|
});
|
|
|
|
final String contactId;
|
|
final Message message;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final meta = message.attachment!;
|
|
final thumb = meta.thumbnail;
|
|
|
|
final aspect = (meta.width != null &&
|
|
meta.height != null &&
|
|
meta.width! > 0 &&
|
|
meta.height! > 0)
|
|
? meta.width! / meta.height!
|
|
: 4 / 3;
|
|
|
|
return GestureDetector(
|
|
onTap: switch (meta.state) {
|
|
AttachmentTransferState.ready => () {
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute<void>(
|
|
builder: (_) => ImageViewerScreen(message: message),
|
|
),
|
|
);
|
|
},
|
|
AttachmentTransferState.failed => () => ref
|
|
.read(conversationsProvider.notifier)
|
|
.retryAttachmentDownload(contactId, message),
|
|
_ => null,
|
|
},
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: AspectRatio(
|
|
aspectRatio: aspect.clamp(0.5, 2.5),
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
// Náhled jako podklad/placeholder (rozmazaný, ne kostičkovaný).
|
|
// Klíč podle blob_id — jinak ListView recykluje widget a
|
|
// s gaplessPlayback ukáže náhled PŘEDCHOZÍ fotky.
|
|
if (thumb != null)
|
|
Image.memory(thumb,
|
|
key: ValueKey('thumb-${meta.blobId}'),
|
|
fit: BoxFit.cover,
|
|
gaplessPlayback: true)
|
|
else
|
|
const ColoredBox(
|
|
color: SepotColors.surfaceVariant,
|
|
child: Icon(Icons.image_outlined,
|
|
size: 48, color: SepotColors.textMuted),
|
|
),
|
|
// Jakmile je blob stažený, překryj OSTROU plnou fotkou.
|
|
// Klíč podle blob_id, ať se stav nedědí z jiné fotky.
|
|
if (meta.state == AttachmentTransferState.ready)
|
|
_FullImage(
|
|
key: ValueKey('full-${meta.blobId}'),
|
|
contactId: contactId,
|
|
message: message),
|
|
if (meta.state != AttachmentTransferState.ready)
|
|
ColoredBox(
|
|
color: Colors.black45,
|
|
child: Center(child: _stateIndicator(context, meta.state)),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _stateIndicator(BuildContext context, AttachmentTransferState state) {
|
|
return switch (state) {
|
|
AttachmentTransferState.downloading ||
|
|
AttachmentTransferState.pending =>
|
|
const SizedBox(
|
|
width: 32,
|
|
height: 32,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2.5,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
AttachmentTransferState.failed => Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Icon(Icons.refresh_rounded, color: Colors.white, size: 32),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
context.l10n.attachmentDownloadFailedTapToRetry,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(color: Colors.white, fontSize: 11),
|
|
),
|
|
],
|
|
),
|
|
AttachmentTransferState.ready => const SizedBox.shrink(),
|
|
};
|
|
}
|
|
}
|
|
|
|
/// Ostrá plná fotka do bubliny (stav ready). Dešifruje blob do paměti
|
|
/// s cache; než je hotová, prosvítá náhled pod ní (žádné kostičky).
|
|
class _FullImage extends ConsumerStatefulWidget {
|
|
const _FullImage(
|
|
{super.key, required this.contactId, required this.message});
|
|
|
|
final String contactId;
|
|
final Message message;
|
|
|
|
@override
|
|
ConsumerState<_FullImage> createState() => _FullImageState();
|
|
}
|
|
|
|
class _FullImageState extends ConsumerState<_FullImage> {
|
|
Uint8List? _bytes;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_load();
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(_FullImage old) {
|
|
super.didUpdateWidget(old);
|
|
// Kdyby se widget přece jen recykloval na jinou fotku (jiný blob_id),
|
|
// zahoď staré bajty a načti čerstvě — žádné zděděné náhledy.
|
|
if (old.message.attachment!.blobId != widget.message.attachment!.blobId) {
|
|
_bytes = null;
|
|
_load();
|
|
}
|
|
}
|
|
|
|
Future<void> _load() async {
|
|
final blobId = widget.message.attachment!.blobId;
|
|
final cached = _fullImageCache[blobId];
|
|
if (cached != null) {
|
|
setState(() => _bytes = cached);
|
|
return;
|
|
}
|
|
final bytes = await ref
|
|
.read(messagingServiceProvider)
|
|
.loadDecryptedAttachment(widget.message);
|
|
if (bytes != null) _cachePut(blobId, bytes);
|
|
if (mounted) setState(() => _bytes = bytes);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final bytes = _bytes;
|
|
if (bytes == null) return const SizedBox.shrink();
|
|
return Image.memory(bytes, fit: BoxFit.cover, gaplessPlayback: true);
|
|
}
|
|
}
|
|
|
|
/// Hlasová zpráva: play/pause, průběh a délka. Blob se dešifruje do paměti
|
|
/// až při prvním přehrání (plaintext audio nikdy nejde na disk).
|
|
class AudioAttachmentContent extends ConsumerStatefulWidget {
|
|
const AudioAttachmentContent({
|
|
super.key,
|
|
required this.contactId,
|
|
required this.message,
|
|
required this.isMine,
|
|
});
|
|
|
|
final String contactId;
|
|
final Message message;
|
|
final bool isMine;
|
|
|
|
@override
|
|
ConsumerState<AudioAttachmentContent> createState() => _AudioState();
|
|
}
|
|
|
|
class _AudioState extends ConsumerState<AudioAttachmentContent> {
|
|
final _player = AudioPlayer();
|
|
final _subs = <StreamSubscription<dynamic>>[];
|
|
bool _loading = false;
|
|
bool _playing = false;
|
|
Duration _pos = Duration.zero;
|
|
Duration? _total;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
final dur = widget.message.attachment!.durationMs;
|
|
_total = dur != null ? Duration(milliseconds: dur) : null;
|
|
_subs.add(_player.onPositionChanged.listen((p) {
|
|
if (mounted) setState(() => _pos = p);
|
|
}));
|
|
_subs.add(_player.onDurationChanged.listen((d) {
|
|
if (mounted && d > Duration.zero) setState(() => _total = d);
|
|
}));
|
|
_subs.add(_player.onPlayerStateChanged.listen((s) {
|
|
if (mounted) setState(() => _playing = s == PlayerState.playing);
|
|
}));
|
|
_subs.add(_player.onPlayerComplete.listen((_) {
|
|
if (mounted) setState(() => _pos = Duration.zero);
|
|
}));
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
for (final s in _subs) {
|
|
s.cancel();
|
|
}
|
|
_player.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
String _fmt(Duration d) {
|
|
final m = d.inMinutes.remainder(60).toString();
|
|
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
|
|
return '$m:$s';
|
|
}
|
|
|
|
Future<void> _toggle() async {
|
|
final meta = widget.message.attachment!;
|
|
if (_playing) {
|
|
await _player.pause();
|
|
return;
|
|
}
|
|
if (_pos > Duration.zero && _pos < (_total ?? Duration.zero)) {
|
|
await _player.resume();
|
|
return;
|
|
}
|
|
setState(() => _loading = true);
|
|
final bytes = await ref
|
|
.read(messagingServiceProvider)
|
|
.loadDecryptedAttachment(widget.message);
|
|
if (!mounted) return;
|
|
setState(() => _loading = false);
|
|
if (bytes == null) return;
|
|
await _player.play(BytesSource(bytes, mimeType: meta.mime));
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final meta = widget.message.attachment!;
|
|
final ink = widget.isMine ? SepotColors.emberInk : SepotColors.textPrimary;
|
|
final accent = widget.isMine ? SepotColors.emberInk : SepotColors.ember;
|
|
|
|
if (meta.state != AttachmentTransferState.ready) {
|
|
return SizedBox(
|
|
width: 200,
|
|
child: Row(
|
|
children: [
|
|
const SizedBox(
|
|
width: 20,
|
|
height: 20,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Text(
|
|
meta.state == AttachmentTransferState.failed
|
|
? context.l10n.attachmentDownloadFailed
|
|
: context.l10n.attachmentDownloading,
|
|
style: TextStyle(color: ink, fontSize: 13),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
final total = _total ?? Duration.zero;
|
|
final progress = total.inMilliseconds == 0
|
|
? 0.0
|
|
: (_pos.inMilliseconds / total.inMilliseconds).clamp(0.0, 1.0);
|
|
|
|
return SizedBox(
|
|
width: 210,
|
|
child: Row(
|
|
children: [
|
|
GestureDetector(
|
|
onTap: _loading ? null : _toggle,
|
|
child: Container(
|
|
width: 40,
|
|
height: 40,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: accent.withValues(alpha: 0.18),
|
|
),
|
|
child: _loading
|
|
? Padding(
|
|
padding: const EdgeInsets.all(10),
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2, color: accent),
|
|
)
|
|
: Icon(_playing ? Icons.pause : Icons.play_arrow,
|
|
color: accent, size: 24),
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(4),
|
|
child: LinearProgressIndicator(
|
|
value: progress,
|
|
minHeight: 4,
|
|
backgroundColor: ink.withValues(alpha: 0.2),
|
|
valueColor: AlwaysStoppedAnimation(accent),
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Icon(Icons.mic, size: 13, color: ink.withValues(alpha: .6)),
|
|
Text(
|
|
_playing || _pos > Duration.zero
|
|
? _fmt(_pos)
|
|
: _fmt(total),
|
|
style: TextStyle(
|
|
fontSize: 11, color: ink.withValues(alpha: .8)),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Dokument: karta s ikonou, názvem a velikostí. Klepnutí (stav ready)
|
|
/// dešifruje do paměti a otevře systémový share sheet — plaintext se na
|
|
/// disk dostane jen do cache sdílení (spravuje OS), nikdy do galerie.
|
|
class DocumentAttachmentContent extends ConsumerStatefulWidget {
|
|
const DocumentAttachmentContent({
|
|
super.key,
|
|
required this.contactId,
|
|
required this.message,
|
|
});
|
|
|
|
final String contactId;
|
|
final Message message;
|
|
|
|
@override
|
|
ConsumerState<DocumentAttachmentContent> createState() =>
|
|
_DocumentAttachmentContentState();
|
|
}
|
|
|
|
class _DocumentAttachmentContentState
|
|
extends ConsumerState<DocumentAttachmentContent> {
|
|
bool _opening = false;
|
|
|
|
Future<void> _open() async {
|
|
final meta = widget.message.attachment!;
|
|
final l10n = context.l10n;
|
|
setState(() => _opening = true);
|
|
try {
|
|
final bytes = await ref
|
|
.read(messagingServiceProvider)
|
|
.loadDecryptedAttachment(widget.message);
|
|
if (bytes == null) return;
|
|
await Share.shareXFiles([
|
|
XFile.fromData(
|
|
bytes,
|
|
name: meta.name ?? l10n.attachmentSharedFileFallbackName,
|
|
mimeType: meta.mime,
|
|
),
|
|
]);
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content:
|
|
Text(l10n.attachmentOpenFailed(describeError(l10n, e)))),
|
|
);
|
|
}
|
|
} finally {
|
|
if (mounted) setState(() => _opening = false);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final meta = widget.message.attachment!;
|
|
|
|
return InkWell(
|
|
onTap: switch (meta.state) {
|
|
AttachmentTransferState.ready => _opening ? null : _open,
|
|
AttachmentTransferState.failed => () => ref
|
|
.read(conversationsProvider.notifier)
|
|
.retryAttachmentDownload(widget.contactId, widget.message),
|
|
_ => null,
|
|
},
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Container(
|
|
padding: const EdgeInsets.all(10),
|
|
decoration: BoxDecoration(
|
|
color: Colors.black.withValues(alpha: 0.15),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_leadingIcon(meta.state),
|
|
const SizedBox(width: 10),
|
|
Flexible(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
meta.name ?? context.l10n.commonFile,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w600,
|
|
color: SepotColors.textPrimary,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
meta.state == AttachmentTransferState.failed
|
|
? context.l10n.attachmentDownloadFailedTapToRetry
|
|
: formatBytes(context.l10n, meta.size),
|
|
style: const TextStyle(
|
|
fontSize: 12,
|
|
color: SepotColors.textMuted,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _leadingIcon(AttachmentTransferState state) {
|
|
const size = 38.0;
|
|
if (_opening ||
|
|
state == AttachmentTransferState.downloading ||
|
|
state == AttachmentTransferState.pending) {
|
|
return const SizedBox(
|
|
width: size,
|
|
height: size,
|
|
child: Padding(
|
|
padding: EdgeInsets.all(8),
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
color: SepotColors.ember,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
return Container(
|
|
width: size,
|
|
height: size,
|
|
decoration: BoxDecoration(
|
|
color: SepotColors.ember.withValues(alpha: 0.15),
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: Icon(
|
|
state == AttachmentTransferState.failed
|
|
? Icons.refresh_rounded
|
|
: Icons.description_outlined,
|
|
color: SepotColors.ember,
|
|
size: 20,
|
|
),
|
|
);
|
|
}
|
|
}
|