Tane dialled its four default relays at launch, before anyone had asked for anything — an F-Droid reviewer spotted it, and they were right. The seed book needs no network at all, so the app should not have one until the person joins the sharing side. - SocialSettings gains a three-state `sharingEnabled`. `null` means "never asked", which is what lets `migrateSharingEnabled` keep an existing install exactly as it was: anyone past the intro was on a build that connected at launch, so they keep messaging, device sync and offer alerts. A fresh install starts fully offline. - bootstrap only starts the shared connection when sharing is on. The inbox/sync/plantaré/alert listeners are untouched: they react to a session, and none arrives. - SharingSwitch is the single place that moves the stored choice, the live connection and the flag the UI listens to, so they cannot drift. - Agreeing to the community rules is the opt-in — one consent surface, reached from the market or from the drawer's invitation. - SocialConnection.start is now idempotent and gains stop(), so turning sharing off goes offline immediately instead of at the next launch. - The social drawer entries stay visible but padlocked while sharing is off; tapping one explains what wakes up and offers to join. Hiding them would have kept the tool a secret. "Coming soon" is gone for good — everything it labelled is built. Covered by tests for the migration in both directions, start/stop lifecycle, the gate turning sharing on, the invitation, and the drawer in all three states (no social layer / off / on).
250 lines
8.4 KiB
Dart
250 lines
8.4 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:commons_core/commons_core.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:nostr/nostr.dart';
|
|
import 'package:tane/services/social_connection.dart';
|
|
import 'package:tane/services/social_service.dart';
|
|
import 'package:tane/services/social_settings.dart';
|
|
|
|
import '../support/test_support.dart';
|
|
|
|
/// A no-op [NostrChannel] that only tracks whether it was closed — enough to
|
|
/// build a [SocialSession] and assert the connection's lifecycle.
|
|
class FakeChannel implements NostrChannel {
|
|
bool closed = false;
|
|
|
|
@override
|
|
String get privateKeyHex => '00' * 32;
|
|
@override
|
|
String get publicKeyHex => 'ab' * 32;
|
|
@override
|
|
Future<({bool accepted, String message})> publish(Event event) async =>
|
|
(accepted: true, message: '');
|
|
@override
|
|
Stream<Event> subscribe(Filter filter) => const Stream.empty();
|
|
@override
|
|
Future<List<Event>> reqOnce(Filter filter) async => const [];
|
|
@override
|
|
Future<void> close() async => closed = true;
|
|
}
|
|
|
|
void main() {
|
|
const seedHex =
|
|
'000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f';
|
|
|
|
late SocialService social;
|
|
late SocialSettings settings; // relayUrls() falls back to defaults (non-empty)
|
|
|
|
setUp(() async {
|
|
social = await SocialService.fromRootSeedHex(seedHex);
|
|
settings = SocialSettings(InMemorySecretStore());
|
|
});
|
|
|
|
SocialConnection make({
|
|
required List<FakeChannel> opened,
|
|
Stream<bool>? online,
|
|
bool Function()? fail,
|
|
List<Duration>? retrySchedule,
|
|
}) =>
|
|
SocialConnection(
|
|
social: social,
|
|
settings: settings,
|
|
online: online,
|
|
retrySchedule: retrySchedule,
|
|
open: (_) async {
|
|
if (fail?.call() ?? false) throw StateError('unreachable');
|
|
final ch = FakeChannel();
|
|
opened.add(ch);
|
|
return SocialSession(ch);
|
|
},
|
|
);
|
|
|
|
test('connects once and reuses the shared session', () async {
|
|
final opened = <FakeChannel>[];
|
|
final conn = make(opened: opened);
|
|
final a = await conn.session();
|
|
final b = await conn.session();
|
|
expect(a, isNotNull);
|
|
expect(identical(a, b), isTrue); // same shared instance
|
|
expect(opened, hasLength(1)); // only one connection opened
|
|
await conn.dispose();
|
|
});
|
|
|
|
test('concurrent callers share a single connect', () async {
|
|
final opened = <FakeChannel>[];
|
|
final conn = make(opened: opened);
|
|
final results = await Future.wait([conn.session(), conn.session()]);
|
|
expect(identical(results[0], results[1]), isTrue);
|
|
expect(opened, hasLength(1));
|
|
await conn.dispose();
|
|
});
|
|
|
|
test('drops when offline and reconnects when back online', () async {
|
|
final opened = <FakeChannel>[];
|
|
final online = StreamController<bool>.broadcast();
|
|
final conn = make(opened: opened, online: online.stream);
|
|
final emitted = <SocialSession?>[];
|
|
conn.sessions.listen(emitted.add);
|
|
|
|
conn.start(); // watch connectivity + initial connect
|
|
final first = await conn.session();
|
|
expect(first, isNotNull);
|
|
expect(opened, hasLength(1));
|
|
|
|
online.add(false); // network lost
|
|
await Future<void>.delayed(Duration.zero);
|
|
expect(conn.current, isNull);
|
|
expect(opened.first.closed, isTrue); // old session closed
|
|
expect(emitted.last, isNull); // announced the drop
|
|
|
|
online.add(true); // network back
|
|
await Future<void>.delayed(Duration.zero);
|
|
expect(conn.current, isNotNull);
|
|
expect(opened, hasLength(2)); // reconnected
|
|
expect(identical(emitted.last, conn.current), isTrue);
|
|
|
|
await conn.dispose();
|
|
await online.close();
|
|
});
|
|
|
|
test(
|
|
'started connection retries by itself after a failed first attempt, '
|
|
'without any connectivity event', () async {
|
|
// The fresh-install case: device online the whole time, but the very first
|
|
// connect fails (cold DNS/TLS). The connectivity stream never fires, so
|
|
// only the backoff retry can bring the market up without a manual Retry.
|
|
final opened = <FakeChannel>[];
|
|
var down = true;
|
|
final conn = make(
|
|
opened: opened,
|
|
online: const Stream.empty(), // connectivity never speaks
|
|
fail: () => down,
|
|
retrySchedule: const [Duration(milliseconds: 5)],
|
|
);
|
|
final emitted = <SocialSession?>[];
|
|
conn.sessions.listen(emitted.add);
|
|
|
|
conn.start();
|
|
await Future<void>.delayed(Duration.zero);
|
|
expect(conn.current, isNull); // first attempt failed
|
|
|
|
down = false; // network path recovers
|
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
|
expect(conn.current, isNotNull, reason: 'backoff retry reconnected');
|
|
expect(opened, hasLength(1));
|
|
expect(emitted.last, isNotNull, reason: 'recovery announced on sessions');
|
|
|
|
await conn.dispose();
|
|
});
|
|
|
|
test('a plain session() failure schedules no retry when never started',
|
|
() async {
|
|
// Widget tests build cubits against an un-started connection; a failed
|
|
// one-shot session() must not leave a pending retry timer behind.
|
|
final opened = <FakeChannel>[];
|
|
final conn = make(
|
|
opened: opened,
|
|
fail: () => true,
|
|
retrySchedule: const [Duration(milliseconds: 5)],
|
|
);
|
|
expect(await conn.session(), isNull);
|
|
await Future<void>.delayed(const Duration(milliseconds: 50));
|
|
expect(opened, isEmpty); // no background retry fired
|
|
await conn.dispose();
|
|
});
|
|
|
|
test('dispose cancels a pending backoff retry', () async {
|
|
final opened = <FakeChannel>[];
|
|
var down = true;
|
|
final conn = make(
|
|
opened: opened,
|
|
online: const Stream.empty(),
|
|
fail: () => down,
|
|
retrySchedule: const [Duration(milliseconds: 20)],
|
|
);
|
|
conn.start();
|
|
await Future<void>.delayed(Duration.zero); // first attempt fails
|
|
await conn.dispose(); // cancels the scheduled retry
|
|
down = false;
|
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
|
expect(opened, isEmpty, reason: 'no reconnect after dispose');
|
|
});
|
|
|
|
test('returns null when the relay is unreachable, and retries later',
|
|
() async {
|
|
final opened = <FakeChannel>[];
|
|
var down = true;
|
|
final conn = make(opened: opened, fail: () => down);
|
|
expect(await conn.session(), isNull); // unreachable now
|
|
down = false;
|
|
expect(await conn.session(), isNotNull); // succeeds on retry
|
|
await conn.dispose();
|
|
});
|
|
|
|
test('start is idempotent: a second call adds no second connect', () async {
|
|
// Joining sharing calls start() while bootstrap may already have, so a
|
|
// repeat must not stack another connectivity subscription or dial again.
|
|
final opened = <FakeChannel>[];
|
|
final online = StreamController<bool>.broadcast();
|
|
final conn = make(opened: opened, online: online.stream);
|
|
|
|
conn.start();
|
|
await conn.session();
|
|
conn.start();
|
|
await Future<void>.delayed(Duration.zero);
|
|
expect(opened, hasLength(1));
|
|
|
|
// One subscription, so one drop — not two competing reactions.
|
|
online.add(false);
|
|
await Future<void>.delayed(Duration.zero);
|
|
expect(conn.current, isNull);
|
|
expect(opened.first.closed, isTrue);
|
|
|
|
await conn.dispose();
|
|
await online.close();
|
|
});
|
|
|
|
test('stop goes offline now, not at the next launch', () async {
|
|
// Turning sharing off used to leave the live session open until restart.
|
|
final opened = <FakeChannel>[];
|
|
final online = StreamController<bool>.broadcast();
|
|
final conn = make(opened: opened, online: online.stream);
|
|
final emitted = <SocialSession?>[];
|
|
conn.sessions.listen(emitted.add);
|
|
|
|
conn.start();
|
|
expect(await conn.session(), isNotNull);
|
|
|
|
await conn.stop();
|
|
await Future<void>.delayed(Duration.zero); // let the drop be announced
|
|
expect(conn.current, isNull);
|
|
expect(opened.single.closed, isTrue);
|
|
expect(emitted.last, isNull);
|
|
|
|
// And it stays off: a connectivity event must not resurrect it.
|
|
online.add(true);
|
|
await Future<void>.delayed(Duration.zero);
|
|
expect(conn.current, isNull);
|
|
expect(opened, hasLength(1));
|
|
|
|
await conn.dispose();
|
|
await online.close();
|
|
});
|
|
|
|
test('stop leaves the connection usable: start brings it back', () async {
|
|
final opened = <FakeChannel>[];
|
|
final online = StreamController<bool>.broadcast();
|
|
final conn = make(opened: opened, online: online.stream);
|
|
conn.start();
|
|
await conn.session();
|
|
await conn.stop();
|
|
|
|
conn.start();
|
|
await Future<void>.delayed(Duration.zero);
|
|
expect(conn.current, isNotNull);
|
|
expect(opened, hasLength(2));
|
|
await conn.dispose();
|
|
await online.close();
|
|
});
|
|
}
|