diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 040affe..4c997db 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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) diff --git a/app/lib/main.dart b/app/lib/main.dart index 31d4bce..23b58b7 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -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>> _decodeJsonList(String body) { + return compute(_decodeJsonListSync, body); +} + +List> _decodeJsonListSync(String body) { + final dynamic decoded = jsonDecode(body); + if (decoded is! List) { + throw const FormatException('Expected JSON array'); + } + return decoded.whereType>().toList(); +} + +Future> _decodeJsonMap(String body) { + return compute(_decodeJsonMapSync, body); +} + +Map _decodeJsonMapSync(String body) { + final dynamic decoded = jsonDecode(body); + if (decoded is! Map) { + 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 Function({ProgressCallback? onProgress})? bootstrapper; + /// Whether the chat view should periodically refresh messages. + final bool enableAutoRefresh; + @override State createState() => _PotatoMeshReaderAppState(); } @@ -83,6 +116,7 @@ class _PotatoMeshReaderAppState extends State { late final MeshRepository _repository; final GlobalKey _messengerKey = GlobalKey(); + bool _hasUserSelectedInstance = false; BootstrapProgress _progress = const BootstrapProgress(stage: 'loading instances'); Future? _bootstrapFuture; @@ -114,6 +148,8 @@ class _PotatoMeshReaderAppState extends State { 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 { }); } + 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 = [ + ..._repository.instances, + if (_bootstrapResult != null) ..._bootstrapResult!.instances, + ]; + for (final instance in candidates) { + if (_normalizeDomain(instance.domain) == normalized) { + return instance.displayName; + } + } + return null; + } + + Future> _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 _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 { setState(() { _bootstrapFuture = future; + _endpointDomain = newDomain; + _hasUserSelectedInstance = true; + _endpointVersion += 1; + _lastError = null; }); try { @@ -162,14 +238,18 @@ class _PotatoMeshReaderAppState extends State { 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 { 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 { final domain = _repository.selectedDomain.isNotEmpty ? _repository.selectedDomain : effectiveResult.selectedDomain; + final instanceName = _hasUserSelectedInstance + ? _instanceNameFor(domain) ?? domain + : null; + final initialMessages = (effectiveResult.selectedDomain == domain) + ? effectiveResult.messages + : const []; return MessagesScreen( key: ValueKey(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 { ? _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> _nodesByDomain = {}; final Map> _messagesByDomain = {}; final Map _messagesLoaded = {}; + final Map> _nodeFetchInFlight = {}; List _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 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 updateInstances(List 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 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(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 = []; var index = 0; - for (final entry in decoded.whereType>()) { + 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 = []; var index = 0; - for (final entry in decoded.whereType>()) { + 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 messages, required http.Client client, }) async { + final store = await _ensureStore(); final key = _domainKey(domain); var nodes = List.from(_nodesByDomain[key] ?? const []); + if (nodes.isEmpty) { + final cached = store.loadNodes(domain); + if (cached.isNotEmpty) { + nodes = List.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) { - final node = MeshNode.fromJson(decoded); - if (node.nodeId.isEmpty) continue; - nodes = List.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.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 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 createState() => _MessagesScreenState(); } @@ -1104,7 +1224,8 @@ class _MessagesScreenState extends State 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.from(widget.initialMessages); @@ -1184,6 +1305,7 @@ class _MessagesScreenState extends State } void _startAutoRefresh() { + if (!widget.enableAutoRefresh) return; _refreshTimer?.cancel(); if (!_isForeground) return; _refreshTimer = @@ -1228,6 +1350,10 @@ class _MessagesScreenState extends State @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 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 _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 onDomainChanged; /// Loader used to fetch federation instance metadata. - final Future> Function() loadInstances; + final Future> Function({bool refresh}) loadInstances; + + static Future> _defaultInstanceLoader( + {bool refresh = false}) { + return fetchInstances(); + } @override State createState() => _SettingsScreenState(); @@ -1557,14 +1696,14 @@ class _SettingsScreenState extends State { } } - Future _fetchInstances() async { + Future _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 { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - DropdownButtonFormField( - key: ValueKey(_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( + key: ValueKey(_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 { } 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> 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> 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((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> _cache = {}; final Map> _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 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) { - 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) { - return InstanceVersion.fromJson(decoded); - } - return null; + final decoded = await _decodeJsonMap(resp.body); + return InstanceVersion.fromJson(decoded); } catch (_) { return null; } finally { diff --git a/app/pubspec.lock b/app/pubspec.lock index e66245a..b09c88f 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -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: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 106ff7c..3086390 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -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 diff --git a/app/release.sh b/app/release.sh new file mode 100755 index 0000000..7c1a568 --- /dev/null +++ b/app/release.sh @@ -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") + diff --git a/app/test/mesh_repository_test.dart b/app/test/mesh_repository_test.dart index 4329151..4884063 100644 --- a/app/test/mesh_repository_test.dart +++ b/app/test/mesh_repository_test.dart @@ -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'); + }); } diff --git a/app/test/messages_screen_test.dart b/app/test/messages_screen_test.dart index c611a36..42eea93 100644 --- a/app/test/messages_screen_test.dart +++ b/app/test/messages_screen_test.dart @@ -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> loader() async => const [ + Future> 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 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 { diff --git a/app/test/settings_screen_test.dart b/app/test/settings_screen_test.dart index ba45db9..b2a18cd 100644 --- a/app/test/settings_screen_test.dart +++ b/app/test/settings_screen_test.dart @@ -25,7 +25,7 @@ void main() { testWidgets('SettingsScreen lists instances and updates selection', (tester) async { final selections = []; - Future> loader() async => const [ + Future> 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> loader() => Future.error(StateError('boom')); + Future> 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 = []; + Future> 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)); + }); }