frindlymap/lib/modules/transport/src/mailbox_transport.dart

96 lines
3.2 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:friendly_map/modules/core/core.dart';
/// 외부 보관함 서버로 봉투를 주고받는다. 보관함은 잠긴 봉투를 잠시 맡아 두기만 한다.
///
/// 서버 쪽 약속 (server/mailbox_server.py):
/// POST /v1/send 봉투 JSON → {"ok":true}
/// GET /v1/fetch?to=<번호> → {"items":[봉투 JSON ...]} (헤더 X-Fm-Token)
/// POST /v1/ack {"to":..,"ids":[..]} → {"ok":true} (헤더 X-Fm-Token)
/// GET /v1/time → {"stamp": 1/100초}
class MailboxTransport implements Transport, TimeSource {
MailboxTransport({
required this.baseUrl,
required this.myPhone,
required this.token,
this.timeout = const Duration(seconds: 8),
});
final String baseUrl;
final String myPhone;
/// 이 기기가 보관함에서 내 우편함을 열 때 내는 비밀값 (처음 쓴 기기에 묶인다).
final String token;
final Duration timeout;
final StreamController<Envelope> _in = StreamController<Envelope>.broadcast(sync: true);
final HttpClient _http = HttpClient();
@override
Stream<Envelope> get incoming => _in.stream;
@override
Future<void> start() async {}
@override
Future<void> stop() async => _http.close(force: true);
Future<Map<String, Object?>?> _call(String method, String path,
{Object? body, Map<String, String>? query, bool auth = false}) async {
try {
final uri = Uri.parse('$baseUrl$path').replace(queryParameters: query);
final req = await _http.openUrl(method, uri).timeout(timeout);
req.headers.contentType = ContentType.json;
if (auth) req.headers.set('X-Fm-Token', token);
final bytes = utf8.encode(body == null ? '{}' : jsonEncode(body));
if (method == 'POST') {
req.contentLength = bytes.length; // 서버가 길이를 요구한다
req.add(bytes);
}
final res = await req.close().timeout(timeout);
final text = await utf8.decodeStream(res).timeout(timeout);
if (res.statusCode != 200) return null;
return (jsonDecode(text) as Map).cast<String, Object?>();
} on Object {
return null; // 연결 실패는 "못 보냄"으로 다룬다.
}
}
@override
Future<bool> send(Envelope envelope) async {
final r = await _call('POST', '/v1/send', body: envelope.toJson());
return r != null && r['ok'] == true;
}
@override
Future<int> fetchPending() async {
final r = await _call('GET', '/v1/fetch', query: {'to': myPhone}, auth: true);
if (r == null) return 0;
final items = (r['items'] as List? ?? const []).cast<Object?>();
final ids = <String>[];
for (final raw in items) {
try {
final e = Envelope.fromJson((raw as Map).cast<String, Object?>());
ids.add(e.id);
_in.add(e);
} catch (_) {
// 읽을 수 없는 봉투는 건너뛴다.
}
}
if (ids.isNotEmpty) {
await _call('POST', '/v1/ack', body: {'to': myPhone, 'ids': ids}, auth: true);
}
return ids.length;
}
@override
Future<int?> referenceStamp() async {
final r = await _call('GET', '/v1/time');
final v = r?['stamp'];
return v is int ? v : null;
}
}