mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-08-06 17:03:27 +02:00
app: further harden refresh logic and prefer local first (#506)
* app: further harden refresh logic and prefer local first * address review comments * cover missing unit test vectors
This commit is contained in:
@@ -34,11 +34,18 @@ jobs:
|
||||
TAG_NAME: ${{ github.ref_name }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Configure Android local.properties
|
||||
run: |
|
||||
cat > android/local.properties <<'EOF'
|
||||
sdk.dir=${ANDROID_SDK_ROOT}
|
||||
flutter.sdk=${FLUTTER_ROOT}
|
||||
EOF
|
||||
- name: Set up Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: stable
|
||||
cache: true
|
||||
cache-key: ${{ runner.os }}-flutter-${{ hashFiles('app/pubspec.lock') }}
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
- name: Build release APK
|
||||
@@ -72,6 +79,7 @@ jobs:
|
||||
with:
|
||||
channel: stable
|
||||
cache: true
|
||||
cache-key: ${{ runner.os }}-flutter-${{ hashFiles('app/pubspec.lock') }}
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
- name: Build release IPA (no codesign)
|
||||
|
||||
+233
-78
@@ -17,6 +17,7 @@ import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
@@ -35,6 +36,34 @@ const String _gitDirtyEnv =
|
||||
String.fromEnvironment('GIT_DIRTY', defaultValue: '');
|
||||
const Duration _requestTimeout = Duration(seconds: 5);
|
||||
|
||||
void _logHttp(String message) {
|
||||
debugPrint('D/$message');
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> _decodeJsonList(String body) {
|
||||
return compute(_decodeJsonListSync, body);
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _decodeJsonListSync(String body) {
|
||||
final dynamic decoded = jsonDecode(body);
|
||||
if (decoded is! List) {
|
||||
throw const FormatException('Expected JSON array');
|
||||
}
|
||||
return decoded.whereType<Map<String, dynamic>>().toList();
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _decodeJsonMap(String body) {
|
||||
return compute(_decodeJsonMapSync, body);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _decodeJsonMapSync(String body) {
|
||||
final dynamic decoded = jsonDecode(body);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
throw const FormatException('Expected JSON object');
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
void main() {
|
||||
runApp(const PotatoMeshReaderApp());
|
||||
}
|
||||
@@ -54,6 +83,7 @@ class PotatoMeshReaderApp extends StatefulWidget {
|
||||
this.initialDomain = 'potatomesh.net',
|
||||
this.repository,
|
||||
this.bootstrapper,
|
||||
this.enableAutoRefresh = true,
|
||||
});
|
||||
|
||||
/// Fetch function injected to simplify testing and offline previews.
|
||||
@@ -73,6 +103,9 @@ class PotatoMeshReaderApp extends StatefulWidget {
|
||||
final Future<BootstrapResult> Function({ProgressCallback? onProgress})?
|
||||
bootstrapper;
|
||||
|
||||
/// Whether the chat view should periodically refresh messages.
|
||||
final bool enableAutoRefresh;
|
||||
|
||||
@override
|
||||
State<PotatoMeshReaderApp> createState() => _PotatoMeshReaderAppState();
|
||||
}
|
||||
@@ -83,6 +116,7 @@ class _PotatoMeshReaderAppState extends State<PotatoMeshReaderApp> {
|
||||
late final MeshRepository _repository;
|
||||
final GlobalKey<ScaffoldMessengerState> _messengerKey =
|
||||
GlobalKey<ScaffoldMessengerState>();
|
||||
bool _hasUserSelectedInstance = false;
|
||||
BootstrapProgress _progress =
|
||||
const BootstrapProgress(stage: 'loading instances');
|
||||
Future<BootstrapResult>? _bootstrapFuture;
|
||||
@@ -114,6 +148,8 @@ class _PotatoMeshReaderAppState extends State<PotatoMeshReaderApp> {
|
||||
setState(() {
|
||||
_bootstrapResult = result;
|
||||
_endpointDomain = result.selectedDomain;
|
||||
_hasUserSelectedInstance = _normalizeDomain(result.selectedDomain) !=
|
||||
_normalizeDomain(widget.initialDomain);
|
||||
_endpointVersion += 1;
|
||||
_lastError = null;
|
||||
});
|
||||
@@ -132,11 +168,47 @@ class _PotatoMeshReaderAppState extends State<PotatoMeshReaderApp> {
|
||||
});
|
||||
}
|
||||
|
||||
String _normalizeDomain(String domain) {
|
||||
var cleaned = domain.trim().toLowerCase();
|
||||
if (cleaned.startsWith('https://')) cleaned = cleaned.substring(8);
|
||||
if (cleaned.startsWith('http://')) cleaned = cleaned.substring(7);
|
||||
if (cleaned.endsWith('/')) {
|
||||
cleaned = cleaned.substring(0, cleaned.length - 1);
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
String? _instanceNameFor(String domain) {
|
||||
final normalized = _normalizeDomain(domain);
|
||||
final candidates = <MeshInstance>[
|
||||
..._repository.instances,
|
||||
if (_bootstrapResult != null) ..._bootstrapResult!.instances,
|
||||
];
|
||||
for (final instance in candidates) {
|
||||
if (_normalizeDomain(instance.domain) == normalized) {
|
||||
return instance.displayName;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<List<MeshInstance>> _loadInstances({bool refresh = false}) async {
|
||||
if (!refresh && _repository.instances.isNotEmpty) {
|
||||
return _repository.instances;
|
||||
}
|
||||
final instances = await widget.instanceFetcher();
|
||||
await _repository.updateInstances(instances);
|
||||
return instances;
|
||||
}
|
||||
|
||||
Future<void> _handleEndpointChanged(String newDomain) async {
|
||||
if (newDomain.isEmpty || newDomain == _endpointDomain) {
|
||||
return;
|
||||
}
|
||||
|
||||
final previousDomain = _endpointDomain;
|
||||
final previousSelectedDomain = _repository.selectedDomain;
|
||||
await _repository.rememberSelectedDomain(newDomain);
|
||||
final future = _repository
|
||||
.loadDomainData(
|
||||
domain: newDomain,
|
||||
@@ -154,6 +226,10 @@ class _PotatoMeshReaderAppState extends State<PotatoMeshReaderApp> {
|
||||
|
||||
setState(() {
|
||||
_bootstrapFuture = future;
|
||||
_endpointDomain = newDomain;
|
||||
_hasUserSelectedInstance = true;
|
||||
_endpointVersion += 1;
|
||||
_lastError = null;
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -162,14 +238,18 @@ class _PotatoMeshReaderAppState extends State<PotatoMeshReaderApp> {
|
||||
setState(() {
|
||||
_bootstrapResult = result;
|
||||
_endpointDomain = result.selectedDomain;
|
||||
_endpointVersion += 1;
|
||||
_hasUserSelectedInstance = true;
|
||||
_lastError = null;
|
||||
});
|
||||
} catch (error) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_lastError = error;
|
||||
_endpointDomain = previousDomain;
|
||||
_hasUserSelectedInstance = _normalizeDomain(previousDomain) !=
|
||||
_normalizeDomain(widget.initialDomain);
|
||||
});
|
||||
await _repository.rememberSelectedDomain(previousSelectedDomain);
|
||||
_messengerKey.currentState?.showSnackBar(
|
||||
SnackBar(content: Text('Failed to switch instance: $error')),
|
||||
);
|
||||
@@ -213,8 +293,7 @@ class _PotatoMeshReaderAppState extends State<PotatoMeshReaderApp> {
|
||||
future: _bootstrapFuture,
|
||||
builder: (context, snapshot) {
|
||||
final effectiveResult = snapshot.data ?? _bootstrapResult;
|
||||
if (snapshot.connectionState != ConnectionState.done ||
|
||||
effectiveResult == null) {
|
||||
if (effectiveResult == null) {
|
||||
return LoadingScreen(
|
||||
progress: _progress,
|
||||
error: _lastError ?? snapshot.error,
|
||||
@@ -224,13 +303,21 @@ class _PotatoMeshReaderAppState extends State<PotatoMeshReaderApp> {
|
||||
final domain = _repository.selectedDomain.isNotEmpty
|
||||
? _repository.selectedDomain
|
||||
: effectiveResult.selectedDomain;
|
||||
final instanceName = _hasUserSelectedInstance
|
||||
? _instanceNameFor(domain) ?? domain
|
||||
: null;
|
||||
final initialMessages = (effectiveResult.selectedDomain == domain)
|
||||
? effectiveResult.messages
|
||||
: const <MeshMessage>[];
|
||||
return MessagesScreen(
|
||||
key: ValueKey<String>(domain),
|
||||
fetcher: _fetchMessagesForCurrentDomain,
|
||||
resetToken: _endpointVersion,
|
||||
domain: domain,
|
||||
repository: _repository,
|
||||
initialMessages: effectiveResult.messages,
|
||||
instanceName: instanceName,
|
||||
enableAutoRefresh: widget.enableAutoRefresh,
|
||||
initialMessages: initialMessages,
|
||||
onOpenSettings: (context) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
@@ -239,12 +326,8 @@ class _PotatoMeshReaderAppState extends State<PotatoMeshReaderApp> {
|
||||
? _repository.selectedDomain
|
||||
: domain,
|
||||
onDomainChanged: _handleEndpointChanged,
|
||||
loadInstances: () async {
|
||||
if (_repository.instances.isNotEmpty) {
|
||||
return _repository.instances;
|
||||
}
|
||||
return widget.instanceFetcher();
|
||||
},
|
||||
loadInstances: ({bool refresh = false}) =>
|
||||
_loadInstances(refresh: refresh),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -469,6 +552,7 @@ class MeshRepository implements MeshNodeResolver {
|
||||
final Map<String, List<MeshNode>> _nodesByDomain = {};
|
||||
final Map<String, List<MeshMessage>> _messagesByDomain = {};
|
||||
final Map<String, bool> _messagesLoaded = {};
|
||||
final Map<String, Set<String>> _nodeFetchInFlight = {};
|
||||
List<MeshInstance> _instances = const [];
|
||||
String _selectedDomain = 'potatomesh.net';
|
||||
|
||||
@@ -482,15 +566,24 @@ class MeshRepository implements MeshNodeResolver {
|
||||
return _store!;
|
||||
}
|
||||
|
||||
/// Persist the selected domain choice without performing network calls.
|
||||
Future<void> rememberSelectedDomain(String domain) async {
|
||||
_selectedDomain = _domainKey(domain);
|
||||
final store = await _ensureStore();
|
||||
await store.saveSelectedDomain(_selectedDomain);
|
||||
}
|
||||
|
||||
String _domainKey(String domain) {
|
||||
var cleaned = domain.trim();
|
||||
if (cleaned.isEmpty) return 'potatomesh.net';
|
||||
cleaned = cleaned.toLowerCase();
|
||||
if (cleaned.startsWith('https://')) cleaned = cleaned.substring(8);
|
||||
if (cleaned.startsWith('http://')) cleaned = cleaned.substring(7);
|
||||
if (cleaned.endsWith('/')) {
|
||||
cleaned = cleaned.substring(0, cleaned.length - 1);
|
||||
}
|
||||
if (cleaned.isEmpty) return 'potatomesh.net';
|
||||
return cleaned.toLowerCase();
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/// Kicks off the full bootstrap flow including federation discovery, node
|
||||
@@ -704,6 +797,13 @@ class MeshRepository implements MeshNodeResolver {
|
||||
}
|
||||
}
|
||||
|
||||
/// Overwrites the cached instances and persists them to local storage.
|
||||
Future<void> updateInstances(List<MeshInstance> instances) async {
|
||||
_instances = instances;
|
||||
final store = await _ensureStore();
|
||||
await store.saveInstances(instances);
|
||||
}
|
||||
|
||||
@override
|
||||
MeshNode? findNode(String domain, String nodeId) {
|
||||
final key = _domainKey(domain);
|
||||
@@ -729,12 +829,12 @@ class MeshRepository implements MeshNodeResolver {
|
||||
Future<void> enqueueFromDomain(String domain) async {
|
||||
try {
|
||||
final uri = _buildInstancesUri(domain);
|
||||
_logHttp('GET $uri');
|
||||
final resp = await client.get(uri).timeout(_requestTimeout);
|
||||
_logHttp('HTTP ${resp.statusCode} $uri');
|
||||
if (resp.statusCode != 200) return;
|
||||
final dynamic decoded = jsonDecode(resp.body);
|
||||
if (decoded is! List) return;
|
||||
final decoded = await _decodeJsonList(resp.body);
|
||||
final parsed = decoded
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(MeshInstance.fromJson)
|
||||
.where((instance) => instance.domain.isNotEmpty)
|
||||
.toList();
|
||||
@@ -895,17 +995,16 @@ class MeshRepository implements MeshNodeResolver {
|
||||
return _nodesByDomain[key]!;
|
||||
}
|
||||
final uri = _buildNodesUri(domain, limit: limit);
|
||||
_logHttp('GET $uri');
|
||||
final resp = await client.get(uri).timeout(_requestTimeout);
|
||||
_logHttp('HTTP ${resp.statusCode} $uri');
|
||||
if (resp.statusCode != 200) {
|
||||
throw Exception('HTTP ${resp.statusCode}: ${resp.body}');
|
||||
}
|
||||
final dynamic decoded = jsonDecode(resp.body);
|
||||
if (decoded is! List) {
|
||||
throw Exception('Unexpected nodes response, expected JSON array');
|
||||
}
|
||||
final decoded = await _decodeJsonList(resp.body);
|
||||
final nodes = <MeshNode>[];
|
||||
var index = 0;
|
||||
for (final entry in decoded.whereType<Map<String, dynamic>>()) {
|
||||
for (final entry in decoded) {
|
||||
index += 1;
|
||||
final node = MeshNode.fromJson(entry);
|
||||
if (node.nodeId.isEmpty) continue;
|
||||
@@ -941,18 +1040,17 @@ class MeshRepository implements MeshNodeResolver {
|
||||
final limit = initialFetch ? 1000 : 100;
|
||||
|
||||
final uri = _buildMessagesUri(domain, limit: limit);
|
||||
_logHttp('GET $uri');
|
||||
final resp = await client.get(uri).timeout(_requestTimeout);
|
||||
_logHttp('HTTP ${resp.statusCode} $uri');
|
||||
if (resp.statusCode != 200) {
|
||||
throw Exception('HTTP ${resp.statusCode}: ${resp.body}');
|
||||
}
|
||||
final dynamic decoded = jsonDecode(resp.body);
|
||||
if (decoded is! List) {
|
||||
throw Exception('Unexpected response shape, expected JSON array');
|
||||
}
|
||||
final decoded = await _decodeJsonList(resp.body);
|
||||
|
||||
final messages = <MeshMessage>[];
|
||||
var index = 0;
|
||||
for (final entry in decoded.whereType<Map<String, dynamic>>()) {
|
||||
for (final entry in decoded) {
|
||||
index += 1;
|
||||
final message = MeshMessage.fromJson(entry);
|
||||
messages.add(message);
|
||||
@@ -1004,29 +1102,43 @@ class MeshRepository implements MeshNodeResolver {
|
||||
required List<MeshMessage> messages,
|
||||
required http.Client client,
|
||||
}) async {
|
||||
final store = await _ensureStore();
|
||||
final key = _domainKey(domain);
|
||||
var nodes = List<MeshNode>.from(_nodesByDomain[key] ?? const []);
|
||||
if (nodes.isEmpty) {
|
||||
final cached = store.loadNodes(domain);
|
||||
if (cached.isNotEmpty) {
|
||||
nodes = List<MeshNode>.from(cached);
|
||||
_nodesByDomain[key] = nodes;
|
||||
NodeShortNameCache.instance.prime(domain: domain, nodes: cached);
|
||||
}
|
||||
}
|
||||
final knownIds = nodes.map((n) => _normalizeNodeId(n.nodeId)).toSet();
|
||||
final inFlight = _nodeFetchInFlight.putIfAbsent(key, () => {});
|
||||
for (final message in messages) {
|
||||
final rawNodeId = message.lookupNodeId.trim();
|
||||
final nodeId = _normalizeNodeId(rawNodeId);
|
||||
if (nodeId.isEmpty || knownIds.contains(nodeId)) continue;
|
||||
if (inFlight.contains(nodeId)) continue;
|
||||
inFlight.add(nodeId);
|
||||
try {
|
||||
final uri = _buildNodeUri(domain, rawNodeId);
|
||||
final resp = await client.get(uri);
|
||||
_logHttp('GET $uri');
|
||||
final resp = await client.get(uri).timeout(_requestTimeout);
|
||||
_logHttp('HTTP ${resp.statusCode} $uri');
|
||||
if (resp.statusCode != 200) continue;
|
||||
final dynamic decoded = jsonDecode(resp.body);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
final node = MeshNode.fromJson(decoded);
|
||||
if (node.nodeId.isEmpty) continue;
|
||||
nodes = List<MeshNode>.from(nodes)..add(node);
|
||||
_nodesByDomain[key] = nodes;
|
||||
NodeShortNameCache.instance.prime(domain: domain, nodes: [node]);
|
||||
await _store?.saveNodes(domain, nodes);
|
||||
knownIds.add(_normalizeNodeId(node.nodeId));
|
||||
}
|
||||
final decoded = await _decodeJsonMap(resp.body);
|
||||
final node = MeshNode.fromJson(decoded);
|
||||
if (node.nodeId.isEmpty) continue;
|
||||
nodes = List<MeshNode>.from(nodes)..add(node);
|
||||
_nodesByDomain[key] = nodes;
|
||||
NodeShortNameCache.instance.prime(domain: domain, nodes: [node]);
|
||||
await _store?.saveNodes(domain, nodes);
|
||||
knownIds.add(_normalizeNodeId(node.nodeId));
|
||||
} catch (_) {
|
||||
// Swallow node lookup errors during refresh.
|
||||
} finally {
|
||||
inFlight.remove(nodeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1052,6 +1164,8 @@ class MessagesScreen extends StatefulWidget {
|
||||
required this.domain,
|
||||
this.repository,
|
||||
this.initialMessages = const [],
|
||||
this.instanceName,
|
||||
this.enableAutoRefresh = true,
|
||||
});
|
||||
|
||||
/// Fetch function used to load messages from the PotatoMesh API.
|
||||
@@ -1072,6 +1186,12 @@ class MessagesScreen extends StatefulWidget {
|
||||
/// Messages obtained during the bootstrap phase to avoid re-fetching.
|
||||
final List<MeshMessage> initialMessages;
|
||||
|
||||
/// Human-friendly name of the selected instance if the user picked one.
|
||||
final String? instanceName;
|
||||
|
||||
/// Whether periodic background refresh is enabled.
|
||||
final bool enableAutoRefresh;
|
||||
|
||||
@override
|
||||
State<MessagesScreen> createState() => _MessagesScreenState();
|
||||
}
|
||||
@@ -1104,7 +1224,8 @@ class _MessagesScreenState extends State<MessagesScreen>
|
||||
void didUpdateWidget(covariant MessagesScreen oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.fetcher != widget.fetcher ||
|
||||
oldWidget.resetToken != widget.resetToken) {
|
||||
oldWidget.resetToken != widget.resetToken ||
|
||||
oldWidget.enableAutoRefresh != widget.enableAutoRefresh) {
|
||||
_restartAutoRefresh();
|
||||
setState(() {
|
||||
_messages = List<MeshMessage>.from(widget.initialMessages);
|
||||
@@ -1184,6 +1305,7 @@ class _MessagesScreenState extends State<MessagesScreen>
|
||||
}
|
||||
|
||||
void _startAutoRefresh() {
|
||||
if (!widget.enableAutoRefresh) return;
|
||||
_refreshTimer?.cancel();
|
||||
if (!_isForeground) return;
|
||||
_refreshTimer =
|
||||
@@ -1228,6 +1350,10 @@ class _MessagesScreenState extends State<MessagesScreen>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final titleText =
|
||||
(widget.instanceName != null && widget.instanceName!.trim().isNotEmpty)
|
||||
? '🥔 ${widget.instanceName!.trim()}'
|
||||
: '🥔 PotatoMesh Reader';
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: Padding(
|
||||
@@ -1238,7 +1364,7 @@ class _MessagesScreenState extends State<MessagesScreen>
|
||||
semanticsLabel: 'PotatoMesh logo',
|
||||
),
|
||||
),
|
||||
title: const Text('🥔 PotatoMesh Reader'),
|
||||
title: Text(titleText),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Refresh',
|
||||
@@ -1328,6 +1454,8 @@ class ChatLine extends StatelessWidget {
|
||||
required this.domain,
|
||||
});
|
||||
|
||||
static final Map<String, double> _indentCache = {};
|
||||
|
||||
/// Message data to render.
|
||||
final MeshMessage message;
|
||||
final String domain;
|
||||
@@ -1389,11 +1517,17 @@ class ChatLine extends StatelessWidget {
|
||||
}
|
||||
|
||||
double _computeIndentPixels(TextStyle baseStyle, BuildContext context) {
|
||||
final key =
|
||||
'${baseStyle.fontFamily}-${baseStyle.fontSize}-${baseStyle.fontWeight}-${baseStyle.fontStyle}';
|
||||
final cached = _indentCache[key];
|
||||
if (cached != null) return cached;
|
||||
final painter = TextPainter(
|
||||
text: TextSpan(text: ' ', style: baseStyle),
|
||||
textDirection: Directionality.of(context),
|
||||
)..layout();
|
||||
return painter.size.width * 8;
|
||||
final width = painter.size.width * 8;
|
||||
_indentCache[key] = width;
|
||||
return width;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -1511,7 +1645,7 @@ class SettingsScreen extends StatefulWidget {
|
||||
super.key,
|
||||
required this.currentDomain,
|
||||
required this.onDomainChanged,
|
||||
this.loadInstances = fetchInstances,
|
||||
this.loadInstances = _defaultInstanceLoader,
|
||||
});
|
||||
|
||||
/// Currently selected endpoint domain.
|
||||
@@ -1521,7 +1655,12 @@ class SettingsScreen extends StatefulWidget {
|
||||
final ValueChanged<String> onDomainChanged;
|
||||
|
||||
/// Loader used to fetch federation instance metadata.
|
||||
final Future<List<MeshInstance>> Function() loadInstances;
|
||||
final Future<List<MeshInstance>> Function({bool refresh}) loadInstances;
|
||||
|
||||
static Future<List<MeshInstance>> _defaultInstanceLoader(
|
||||
{bool refresh = false}) {
|
||||
return fetchInstances();
|
||||
}
|
||||
|
||||
@override
|
||||
State<SettingsScreen> createState() => _SettingsScreenState();
|
||||
@@ -1557,14 +1696,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _fetchInstances() async {
|
||||
Future<void> _fetchInstances({bool refresh = false}) async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final fetched = await widget.loadInstances();
|
||||
final fetched = await widget.loadInstances(refresh: refresh);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_instances = fetched;
|
||||
@@ -1674,18 +1813,33 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
DropdownButtonFormField<String>(
|
||||
key: ValueKey<String>(_selectedDomain),
|
||||
initialValue: _selectedDomain.isNotEmpty
|
||||
? _selectedDomain
|
||||
: _defaultDomain,
|
||||
isExpanded: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Select endpoint',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: endpointItems,
|
||||
onChanged: _loading ? null : _onEndpointChanged,
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: DropdownButtonFormField<String>(
|
||||
key: ValueKey<String>(_selectedDomain),
|
||||
initialValue: _selectedDomain.isNotEmpty
|
||||
? _selectedDomain
|
||||
: _defaultDomain,
|
||||
isExpanded: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Select endpoint',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: endpointItems,
|
||||
onChanged: _loading ? null : _onEndpointChanged,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
tooltip: 'Refresh instances',
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _loading
|
||||
? null
|
||||
: () => _fetchInstances(refresh: true),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (_loading)
|
||||
@@ -1719,7 +1873,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
}
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.storage),
|
||||
title: const Text('PotatoMesh Info'),
|
||||
title: const Text('Instance'),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -2197,11 +2351,13 @@ Future<List<MeshMessage>> fetchMessages({
|
||||
int limit = 1000,
|
||||
}) async {
|
||||
final uri = _buildMessagesUri(domain, limit: limit);
|
||||
_logHttp('GET $uri');
|
||||
|
||||
final httpClient = client ?? http.Client();
|
||||
final shouldClose = client == null;
|
||||
|
||||
final resp = await httpClient.get(uri).timeout(_requestTimeout);
|
||||
_logHttp('HTTP ${resp.statusCode} $uri');
|
||||
if (shouldClose) {
|
||||
httpClient.close();
|
||||
}
|
||||
@@ -2209,15 +2365,8 @@ Future<List<MeshMessage>> fetchMessages({
|
||||
throw Exception('HTTP ${resp.statusCode}: ${resp.body}');
|
||||
}
|
||||
|
||||
final dynamic decoded = jsonDecode(resp.body);
|
||||
if (decoded is! List) {
|
||||
throw Exception('Unexpected response shape, expected JSON array');
|
||||
}
|
||||
|
||||
final msgs = decoded
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map((m) => MeshMessage.fromJson(m))
|
||||
.toList();
|
||||
final decoded = await _decodeJsonList(resp.body);
|
||||
final msgs = decoded.map(MeshMessage.fromJson).toList();
|
||||
|
||||
return sortMessagesByRxTime(msgs);
|
||||
}
|
||||
@@ -2232,6 +2381,7 @@ class NodeShortNameCache {
|
||||
MeshNodeResolver? _resolver;
|
||||
final Map<String, Future<String>> _cache = {};
|
||||
final Map<String, Map<String, String>> _primedShortNames = {};
|
||||
bool _allowRemoteLookups = true;
|
||||
|
||||
/// Registers a resolver that can supply locally cached node metadata.
|
||||
void registerResolver(MeshNodeResolver resolver) {
|
||||
@@ -2244,6 +2394,11 @@ class NodeShortNameCache {
|
||||
_primedShortNames.clear();
|
||||
}
|
||||
|
||||
/// Enables or disables remote lookups for short names.
|
||||
set allowRemoteLookups(bool enabled) {
|
||||
_allowRemoteLookups = enabled;
|
||||
}
|
||||
|
||||
/// Seeds the cache with a batch of node metadata to avoid network calls.
|
||||
void prime({required String domain, required Iterable<MeshNode> nodes}) {
|
||||
final key = domain.trim();
|
||||
@@ -2265,6 +2420,7 @@ class NodeShortNameCache {
|
||||
final trimmedId = nodeId.trim();
|
||||
final fallback = fallbackShortName(trimmedId);
|
||||
if (trimmedId.isEmpty) return Future.value(fallback);
|
||||
if (!_allowRemoteLookups) return Future.value(fallback);
|
||||
|
||||
final domainKey = domain.trim();
|
||||
final primed = _primedShortNames[domainKey];
|
||||
@@ -2308,18 +2464,18 @@ class NodeShortNameCache {
|
||||
final shouldClose = client == null;
|
||||
|
||||
try {
|
||||
_logHttp('GET $uri');
|
||||
final resp = await httpClient.get(uri).timeout(_requestTimeout);
|
||||
_logHttp('HTTP ${resp.statusCode} $uri');
|
||||
if (resp.statusCode != 200) return fallback;
|
||||
|
||||
final dynamic decoded = jsonDecode(resp.body);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
final raw = decoded['short_name'] ?? decoded['shortName'];
|
||||
if (raw != null) {
|
||||
final name = raw.toString().trim();
|
||||
if (name.isNotEmpty) {
|
||||
_storePrimed(domain, nodeId, name);
|
||||
return padToWidth(name);
|
||||
}
|
||||
final decoded = await _decodeJsonMap(resp.body);
|
||||
final raw = decoded['short_name'] ?? decoded['shortName'];
|
||||
if (raw != null) {
|
||||
final name = raw.toString().trim();
|
||||
if (name.isNotEmpty) {
|
||||
_storePrimed(domain, nodeId, name);
|
||||
return padToWidth(name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2422,13 +2578,12 @@ class InstanceVersionCache {
|
||||
final httpClient = client ?? http.Client();
|
||||
final shouldClose = client == null;
|
||||
try {
|
||||
_logHttp('GET $uri');
|
||||
final resp = await httpClient.get(uri).timeout(_requestTimeout);
|
||||
_logHttp('HTTP ${resp.statusCode} $uri');
|
||||
if (resp.statusCode != 200) return null;
|
||||
final dynamic decoded = jsonDecode(resp.body);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
return InstanceVersion.fromJson(decoded);
|
||||
}
|
||||
return null;
|
||||
final decoded = await _decodeJsonMap(resp.body);
|
||||
return InstanceVersion.fromJson(decoded);
|
||||
} catch (_) {
|
||||
return null;
|
||||
} finally {
|
||||
|
||||
+2
-2
@@ -268,10 +268,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: package_info_plus
|
||||
sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968"
|
||||
sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.3.1"
|
||||
version: "9.0.0"
|
||||
package_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
http: ^1.2.0
|
||||
package_info_plus: ^8.1.0
|
||||
package_info_plus: ^9.0.0
|
||||
flutter_svg: ^2.0.10+1
|
||||
url_launcher: ^6.3.1
|
||||
shared_preferences: ^2.3.2
|
||||
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
export GIT_TAG="$(git describe --tags --abbrev=0)"
|
||||
export GIT_COMMITS="$(git rev-list --count ${GIT_TAG}..HEAD)"
|
||||
export GIT_SHA="$(git rev-parse --short=9 HEAD)"
|
||||
export GIT_DIRTY="$(git diff --quiet --ignore-submodules HEAD || echo true || echo false)"
|
||||
flutter clean
|
||||
flutter pub get
|
||||
flutter build apk --release \
|
||||
--dart-define=GIT_TAG="${GIT_TAG}" \
|
||||
--dart-define=GIT_COMMITS="${GIT_COMMITS}" \
|
||||
--dart-define=GIT_SHA="${GIT_SHA}" \
|
||||
--dart-define=GIT_DIRTY="${GIT_DIRTY}"
|
||||
|
||||
if [ "$GIT_COMMITS" -eq 0 ]; then
|
||||
TAG_NAME="$GIT_TAG"
|
||||
else
|
||||
TAG_NAME="${GIT_TAG}+${GIT_COMMITS}.g${GIT_SHA}"
|
||||
fi
|
||||
|
||||
if [ "$GIT_DIRTY" = "true" ]; then
|
||||
TAG_NAME="${TAG_NAME}.dirty"
|
||||
fi
|
||||
|
||||
export APK_DIR="build/app/outputs/flutter-apk"
|
||||
mv -v "${APK_DIR}/app-release.apk" "${APK_DIR}/potatomesh-reader-android-${TAG_NAME}.apk"
|
||||
(cd "${APK_DIR}" && sha256sum "potatomesh-reader-android-${TAG_NAME}.apk" > "potatomesh-reader-android-${TAG_NAME}.apk.sha256sum")
|
||||
|
||||
@@ -327,4 +327,57 @@ void main() {
|
||||
expect(result.selectedDomain, 'cached.mesh');
|
||||
expect(result.messages.single.text, 'cached');
|
||||
});
|
||||
|
||||
test('loadMessages prefers cached nodes over remote lookups', () async {
|
||||
final savedNodes = jsonEncode([
|
||||
{'node_id': '!a', 'short_name': 'A', 'last_heard': 0}
|
||||
]);
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'mesh.nodes.potatomesh.net': savedNodes,
|
||||
});
|
||||
|
||||
var nodeDetailHits = 0;
|
||||
final client = MockClient((request) async {
|
||||
if (request.url.path == '/api/messages') {
|
||||
return http.Response(
|
||||
jsonEncode([
|
||||
{
|
||||
'id': 1,
|
||||
'rx_iso': '2024-01-01T00:00:00Z',
|
||||
'from_id': '!a',
|
||||
'to_id': '^',
|
||||
'channel': 1,
|
||||
'portnum': 'TEXT',
|
||||
'text': 'cached node'
|
||||
}
|
||||
]),
|
||||
200,
|
||||
);
|
||||
}
|
||||
if (request.url.path.startsWith('/api/nodes/')) {
|
||||
nodeDetailHits += 1;
|
||||
return http.Response(
|
||||
jsonEncode({'node_id': '!a', 'short_name': 'A', 'last_heard': 0}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.Response('[]', 200);
|
||||
});
|
||||
|
||||
final repository = MeshRepository(client: client);
|
||||
final messages = await repository.loadMessages(domain: 'potatomesh.net');
|
||||
|
||||
expect(messages.single.text, 'cached node');
|
||||
expect(nodeDetailHits, 0);
|
||||
});
|
||||
|
||||
test('rememberSelectedDomain persists normalized choice', () async {
|
||||
final repo = MeshRepository();
|
||||
await repo.rememberSelectedDomain('HTTP://Example.Mesh/');
|
||||
expect(repo.selectedDomain, 'example.mesh');
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final store = MeshLocalStore(prefs);
|
||||
expect(store.loadSelectedDomain(), 'example.mesh');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ void main() {
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
NodeShortNameCache.instance.allowRemoteLookups = false;
|
||||
NodeShortNameCache.instance.clear();
|
||||
});
|
||||
|
||||
@@ -69,6 +70,7 @@ void main() {
|
||||
await tester.pumpWidget(PotatoMeshReaderApp(
|
||||
fetcher: fakeFetch,
|
||||
bootstrapper: bootstrapper,
|
||||
enableAutoRefresh: false,
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
@@ -109,6 +111,7 @@ void main() {
|
||||
home: MessagesScreen(
|
||||
fetcher: fetcher,
|
||||
domain: 'potatomesh.net',
|
||||
enableAutoRefresh: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -148,6 +151,7 @@ void main() {
|
||||
home: MessagesScreen(
|
||||
fetcher: () async => [],
|
||||
domain: 'potatomesh.net',
|
||||
enableAutoRefresh: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -162,13 +166,14 @@ void main() {
|
||||
home: MessagesScreen(
|
||||
fetcher: () async => [],
|
||||
domain: 'potatomesh.net',
|
||||
enableAutoRefresh: false,
|
||||
onOpenSettings: (context) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => SettingsScreen(
|
||||
currentDomain: 'potatomesh.net',
|
||||
onDomainChanged: (_) {},
|
||||
loadInstances: () async => const [],
|
||||
loadInstances: ({bool refresh = false}) async => const [],
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -214,7 +219,7 @@ void main() {
|
||||
];
|
||||
}
|
||||
|
||||
Future<List<MeshInstance>> loader() async => const [
|
||||
Future<List<MeshInstance>> loader({bool refresh = false}) async => const [
|
||||
MeshInstance(name: 'Mesh Berlin', domain: 'berlin.mesh'),
|
||||
];
|
||||
|
||||
@@ -232,11 +237,15 @@ void main() {
|
||||
return http.Response('[]', 200);
|
||||
});
|
||||
|
||||
final repository = MeshRepository(client: mockClient);
|
||||
|
||||
Future<BootstrapResult> bootstrapper({ProgressCallback? onProgress}) async {
|
||||
onProgress?.call(const BootstrapProgress(stage: 'loading instances'));
|
||||
final initialMessages = await fetcher(domain: 'potatomesh.net');
|
||||
final instances = await loader();
|
||||
await repository.updateInstances(instances);
|
||||
return BootstrapResult(
|
||||
instances: const [],
|
||||
instances: instances,
|
||||
nodes: const [],
|
||||
messages: initialMessages,
|
||||
selectedDomain: 'potatomesh.net',
|
||||
@@ -248,7 +257,8 @@ void main() {
|
||||
fetcher: fetcher,
|
||||
instanceFetcher: ({http.Client? client}) => loader(),
|
||||
bootstrapper: bootstrapper,
|
||||
repository: MeshRepository(client: mockClient),
|
||||
repository: repository,
|
||||
enableAutoRefresh: false,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
@@ -263,10 +273,17 @@ void main() {
|
||||
await tester.tap(find.text('Mesh Berlin').last);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pageBack();
|
||||
await tester.pumpAndSettle(const Duration(seconds: 1));
|
||||
final messagesFinder = find.byType(MessagesScreen);
|
||||
for (var i = 0; i < 10 && messagesFinder.evaluate().isEmpty; i++) {
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
}
|
||||
|
||||
expect(messagesFinder, findsOneWidget);
|
||||
expect(repository.selectedDomain, 'berlin.mesh');
|
||||
expect(calls.contains('berlin.mesh'), isTrue);
|
||||
expect(find.text('berlin.mesh'), findsOneWidget);
|
||||
expect(find.text('potatomesh.net'), findsNothing);
|
||||
expect(find.text('🥔 Mesh Berlin'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('ChatLine renders placeholders and nick colour', (tester) async {
|
||||
|
||||
@@ -25,7 +25,7 @@ void main() {
|
||||
testWidgets('SettingsScreen lists instances and updates selection',
|
||||
(tester) async {
|
||||
final selections = <String>[];
|
||||
Future<List<MeshInstance>> loader() async => const [
|
||||
Future<List<MeshInstance>> loader({bool refresh = false}) async => const [
|
||||
MeshInstance(name: 'Mesh Dresden', domain: 'map.meshdresden.eu'),
|
||||
MeshInstance(name: 'Mesh Berlin', domain: 'berlin.mesh'),
|
||||
];
|
||||
@@ -53,7 +53,8 @@ void main() {
|
||||
});
|
||||
|
||||
testWidgets('SettingsScreen surfaces load errors', (tester) async {
|
||||
Future<List<MeshInstance>> loader() => Future.error(StateError('boom'));
|
||||
Future<List<MeshInstance>> loader({bool refresh = false}) =>
|
||||
Future.error(StateError('boom'));
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
@@ -69,4 +70,34 @@ void main() {
|
||||
|
||||
expect(find.textContaining('Failed to load instances'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('SettingsScreen refresh button refetches instances',
|
||||
(tester) async {
|
||||
final refreshCalls = <bool>[];
|
||||
Future<List<MeshInstance>> loader({bool refresh = false}) async {
|
||||
refreshCalls.add(refresh);
|
||||
return const [
|
||||
MeshInstance(name: 'Mesh Berlin', domain: 'berlin.mesh'),
|
||||
];
|
||||
}
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: SettingsScreen(
|
||||
currentDomain: 'potatomesh.net',
|
||||
onDomainChanged: (_) {},
|
||||
loadInstances: loader,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
expect(refreshCalls, [false]);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.refresh));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(refreshCalls, contains(true));
|
||||
expect(refreshCalls.length, greaterThanOrEqualTo(2));
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user