app: add persistance (#501)

* app: add persistance

* app: address review comments

* app: do not fail on http error or timeout

* app: only load instances if none are cached
This commit is contained in:
l5y
2025-11-23 21:17:56 +01:00
committed by GitHub
parent 753c0f171f
commit 2269d9b4ba
9 changed files with 1736 additions and 90 deletions
+1201 -77
View File
File diff suppressed because it is too large Load Diff
+104
View File
@@ -113,6 +113,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.4"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
flutter:
dependency: "direct main"
description: flutter
@@ -288,6 +296,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.0"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
url: "https://pub.dev"
source: hosted
version: "2.2.1"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
petitparser:
dependency: transitive
description:
@@ -296,6 +328,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.1"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
@@ -312,6 +352,62 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.3"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5"
url: "https://pub.dev"
source: hosted
version: "2.5.3"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: "46a46fd64659eff15f4638bbe19de43f9483f0e0bf024a9fb6b3582064bacc7b"
url: "https://pub.dev"
source: hosted
version: "2.4.17"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
url: "https://pub.dev"
source: hosted
version: "2.5.6"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.dev"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sky_engine:
dependency: transitive
description: flutter
@@ -501,6 +597,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.15.0"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
xml:
dependency: transitive
description:
+1
View File
@@ -13,6 +13,7 @@ dependencies:
package_info_plus: ^8.1.0
flutter_svg: ^2.0.10+1
url_launcher: ^6.3.1
shared_preferences: ^2.3.2
dev_dependencies:
flutter_test:
+6
View File
@@ -16,10 +16,16 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:potato_mesh_reader/main.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
NodeShortNameCache.instance.clear();
});
test('NodeShortNameCache fetches and memoizes short names', () async {
var calls = 0;
final client = MockClient((request) async {
+11 -5
View File
@@ -18,9 +18,17 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:potato_mesh_reader/main.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Unit tests for [MeshMessage] parsing and the sorting helper.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
NodeShortNameCache.instance.clear();
});
group('MeshMessage.fromJson', () {
test('parses fields and strips leading bang from sender', () {
final msg = MeshMessage.fromJson({
@@ -222,13 +230,11 @@ void main() {
instances.map((i) => i.domain), ['alpha.example', 'bravo.example']);
});
test('throws on failed fetch', () async {
test('returns empty list on failed fetch', () async {
final client = MockClient((request) async => http.Response('oops', 500));
expect(
() => fetchInstances(client: client),
throwsA(isA<Exception>()),
);
final instances = await fetchInstances(client: client);
expect(instances, isEmpty);
});
});
}
+330
View File
@@ -0,0 +1,330 @@
// Copyright © 2025-26 l5yth & contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import 'dart:convert';
import 'dart:math';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:potato_mesh_reader/main.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
NodeShortNameCache.instance.clear();
});
test('bootstrap filters private and inactive instances', () async {
final nowSeconds = DateTime.now().toUtc().millisecondsSinceEpoch ~/ 1000;
final client = MockClient((request) async {
if (request.url.path == '/api/instances' &&
request.url.host == 'potatomesh.net') {
return http.Response(
jsonEncode([
{'name': 'Public Mesh', 'domain': 'public.mesh'},
{
'name': 'Hidden Mesh',
'domain': 'private.mesh',
'isPrivate': true
},
{'name': 'Stale Mesh', 'domain': 'stale.mesh'},
]),
200,
);
}
if (request.url.path == '/api/instances' &&
request.url.host == 'public.mesh') {
return http.Response('[]', 200);
}
if (request.url.path == '/api/instances' &&
request.url.host == 'stale.mesh') {
return http.Response('[]', 200);
}
if (request.url.path == '/api/nodes' &&
request.url.host == 'public.mesh') {
final nodes = List.generate(12, (i) {
return {
'node_id': '!pub$i',
'short_name': 'P$i',
'last_heard': nowSeconds,
};
});
return http.Response(jsonEncode(nodes), 200);
}
if (request.url.path == '/api/nodes' &&
request.url.host == 'stale.mesh') {
final stale = nowSeconds - (60 * 60 * 48);
return http.Response(
jsonEncode([
{'node_id': '!old', 'short_name': 'OLD', 'last_heard': stale},
{'node_id': '!older', 'short_name': 'OLD2', 'last_heard': stale},
]),
200,
);
}
if (request.url.path == '/api/messages') {
return http.Response(
jsonEncode([
{
'id': 1,
'rx_iso': '2024-01-01T00:00:00Z',
'from_id': '!pub1',
'to_id': '^',
'channel': 1,
'portnum': 'TEXT',
'text': 'hello'
},
{
'id': 2,
'rx_iso': '2024-01-01T00:10:00Z',
'from_id': '!pub2',
'to_id': '^',
'channel': 1,
'portnum': 'TEXT',
'text': 'world'
},
]),
200,
);
}
if (request.url.path.startsWith('/api/nodes/')) {
return http.Response(
jsonEncode({
'node_id': '!fallback',
'short_name': 'FLBK',
'last_heard': nowSeconds
}),
200,
);
}
return http.Response('[]', 200);
});
final repository = MeshRepository(client: client, random: Random(1));
final result = await repository.bootstrap(initialDomain: 'public.mesh');
expect(result.instances.map((i) => i.domain), ['public.mesh']);
expect(result.selectedDomain, 'public.mesh');
expect(result.nodes.length, 12);
expect(result.messages.length, 2);
});
test('loadMessages performs incremental refresh after initial sync',
() async {
final nowSeconds = DateTime.now().toUtc().millisecondsSinceEpoch ~/ 1000;
final limits = <String>[];
final client = MockClient((request) async {
if (request.url.path == '/api/nodes') {
return http.Response(
jsonEncode([
{'node_id': '!a', 'short_name': 'A', 'last_heard': nowSeconds},
]),
200,
);
}
if (request.url.path.startsWith('/api/nodes/')) {
return http.Response(
jsonEncode(
{'node_id': '!a', 'short_name': 'A', 'last_heard': nowSeconds}),
200,
);
}
if (request.url.path == '/api/messages') {
limits.add(request.url.queryParameters['limit'] ?? '');
if (limits.length == 1) {
return http.Response(
jsonEncode([
{
'id': 1,
'rx_iso': '2024-01-01T00:00:00Z',
'from_id': '!a',
'to_id': '^',
'channel': 1,
'portnum': 'TEXT',
'text': 'first'
}
]),
200,
);
}
return http.Response(
jsonEncode([
{
'id': 2,
'rx_iso': '2024-01-01T00:10:00Z',
'from_id': '!a',
'to_id': '^',
'channel': 1,
'portnum': 'TEXT',
'text': 'new'
}
]),
200,
);
}
return http.Response('[]', 200);
});
final repository = MeshRepository(client: client);
final domainResult = await repository.loadDomainData(
domain: 'potatomesh.net',
forceFull: true,
);
expect(domainResult.messages.length, 1);
final refreshed = await repository.loadMessages(domain: 'potatomesh.net');
expect(limits.first, '1000');
expect(limits.last, '100');
expect(refreshed.length, 2);
expect(refreshed.last.text, 'new');
});
test('bootstrap falls back to next responsive instance when first fails',
() async {
final nowSeconds = DateTime.now().toUtc().millisecondsSinceEpoch ~/ 1000;
final client = MockClient((request) async {
if (request.url.host == 'potatomesh.net' &&
request.url.path == '/api/instances') {
return http.Response(
jsonEncode([
{'name': 'Broken', 'domain': 'broken.mesh'},
{'name': 'Healthy', 'domain': 'healthy.mesh'},
]),
200,
);
}
if (request.url.host == 'broken.mesh' &&
request.url.path == '/api/nodes') {
return http.Response('Not found', 404);
}
if (request.url.host == 'healthy.mesh' &&
request.url.path == '/api/nodes') {
final nodes = List.generate(10, (i) {
return {
'node_id': '!ok$i',
'short_name': 'OK$i',
'last_heard': nowSeconds,
};
});
return http.Response(jsonEncode(nodes), 200);
}
if (request.url.host == 'healthy.mesh' &&
request.url.path == '/api/messages') {
return http.Response(
jsonEncode([
{
'id': 10,
'rx_iso': '2024-01-01T00:00:00Z',
'from_id': '!ok1',
'to_id': '^',
'channel': 1,
'portnum': 'TEXT',
'text': 'hi'
}
]),
200,
);
}
if (request.url.path.startsWith('/api/nodes/')) {
return http.Response(
jsonEncode({
'node_id': '!ok1',
'short_name': 'OK1',
'last_heard': nowSeconds
}),
200,
);
}
return http.Response('[]', 200);
});
final repository = MeshRepository(client: client);
final result = await repository.bootstrap(initialDomain: 'broken.mesh');
expect(result.selectedDomain, 'healthy.mesh');
expect(result.messages.single.text, 'hi');
});
test('bootstrap skips instance discovery when cache is populated', () async {
final nowSeconds = DateTime.now().toUtc().millisecondsSinceEpoch ~/ 1000;
final cachedInstances = jsonEncode([
{
'id': '1',
'name': 'Cached Mesh',
'domain': 'cached.mesh',
'isPrivate': false,
'lastUpdateTime': nowSeconds,
}
]);
SharedPreferences.setMockInitialValues({
'mesh.instances': cachedInstances,
'mesh.selectedDomain': 'cached.mesh',
});
var instancesCalls = 0;
final client = MockClient((request) async {
if (request.url.path == '/api/instances') {
instancesCalls += 1;
return http.Response('[]', 200);
}
if (request.url.path == '/api/nodes') {
final nodes = List.generate(10, (i) {
return {
'node_id': '!cached$i',
'short_name': 'C$i',
'last_heard': nowSeconds,
};
});
return http.Response(jsonEncode(nodes), 200);
}
if (request.url.path == '/api/messages') {
return http.Response(
jsonEncode([
{
'id': 20,
'rx_iso': '2024-01-01T00:00:00Z',
'from_id': '!cached1',
'to_id': '^',
'channel': 1,
'portnum': 'TEXT',
'text': 'cached'
}
]),
200,
);
}
if (request.url.path.startsWith('/api/nodes/')) {
return http.Response(
jsonEncode({
'node_id': '!cached1',
'short_name': 'C1',
'last_heard': nowSeconds
}),
200,
);
}
return http.Response('[]', 200);
});
final repository = MeshRepository(client: client);
final result = await repository.bootstrap(initialDomain: 'cached.mesh');
expect(instancesCalls, 0, reason: 'should not refetch instance list');
expect(result.selectedDomain, 'cached.mesh');
expect(result.messages.single.text, 'cached');
});
}
+53 -4
View File
@@ -17,10 +17,19 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:potato_mesh_reader/main.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Widget-level tests that exercise UI states and rendering branches.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
NodeShortNameCache.instance.clear();
});
testWidgets('PotatoMeshReaderApp wires theming and home screen',
(tester) async {
final fetchCalls = <int>[];
@@ -47,12 +56,25 @@ void main() {
];
}
await tester.pumpWidget(PotatoMeshReaderApp(fetcher: fakeFetch));
Future<BootstrapResult> bootstrapper({ProgressCallback? onProgress}) async {
onProgress?.call(const BootstrapProgress(stage: 'loading instances'));
return BootstrapResult(
instances: const [],
nodes: const [],
messages: await fakeFetch(domain: 'potatomesh.net'),
selectedDomain: 'potatomesh.net',
);
}
await tester.pumpWidget(PotatoMeshReaderApp(
fetcher: fakeFetch,
bootstrapper: bootstrapper,
));
await tester.pumpAndSettle();
expect(find.textContaining('PotatoMesh Reader'), findsOneWidget);
expect(find.byType(MessagesScreen), findsOneWidget);
expect(fetchCalls.length, greaterThanOrEqualTo(2));
expect(fetchCalls.length, greaterThanOrEqualTo(1));
});
testWidgets('MessagesScreen shows loading, data, refresh, and empty states',
@@ -196,10 +218,37 @@ void main() {
MeshInstance(name: 'Mesh Berlin', domain: 'berlin.mesh'),
];
final mockClient = MockClient((request) async {
if (request.url.path.contains('/api/nodes')) {
return http.Response('[]', 200);
}
if (request.url.path.contains('/api/messages')) {
return http.Response('[]', 200);
}
if (request.url.path.contains('/api/instances')) {
return http.Response(
'[{"name":"Mesh Berlin","domain":"berlin.mesh"}]', 200);
}
return http.Response('[]', 200);
});
Future<BootstrapResult> bootstrapper({ProgressCallback? onProgress}) async {
onProgress?.call(const BootstrapProgress(stage: 'loading instances'));
final initialMessages = await fetcher(domain: 'potatomesh.net');
return BootstrapResult(
instances: const [],
nodes: const [],
messages: initialMessages,
selectedDomain: 'potatomesh.net',
);
}
await tester.pumpWidget(
PotatoMeshReaderApp(
fetcher: fetcher,
instanceFetcher: ({http.Client? client}) => loader(),
bootstrapper: bootstrapper,
repository: MeshRepository(client: mockClient),
),
);
await tester.pumpAndSettle();
@@ -214,9 +263,9 @@ void main() {
await tester.tap(find.text('Mesh Berlin').last);
await tester.pumpAndSettle();
await tester.pageBack();
await tester.pumpAndSettle();
await tester.pumpAndSettle(const Duration(seconds: 1));
expect(calls.last, 'berlin.mesh');
expect(calls.contains('berlin.mesh'), isTrue);
expect(find.text('berlin.mesh'), findsOneWidget);
});
+5
View File
@@ -15,8 +15,13 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:potato_mesh_reader/main.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
testWidgets('SettingsScreen lists instances and updates selection',
(tester) async {
final selections = <String>[];
+25 -4
View File
@@ -7,10 +7,16 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:potato_mesh_reader/main.dart';
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
NodeShortNameCache.instance.clear();
});
testWidgets('renders messages from fetcher and refreshes list',
(WidgetTester tester) async {
final sampleMessages = <MeshMessage>[
@@ -56,14 +62,29 @@ void main() {
return [sampleMessages[idx]];
}
await tester.pumpWidget(PotatoMeshReaderApp(fetcher: mockFetcher));
Future<BootstrapResult> bootstrapper({ProgressCallback? onProgress}) async {
onProgress?.call(const BootstrapProgress(stage: 'loading instances'));
return BootstrapResult(
instances: const [],
nodes: const [],
messages: sampleMessages,
selectedDomain: 'potatomesh.net',
);
}
await tester.pumpWidget(
PotatoMeshReaderApp(
fetcher: mockFetcher,
bootstrapper: bootstrapper,
),
);
await tester.pumpAndSettle();
expect(find.textContaining('PotatoMesh Reader'), findsOneWidget);
expect(find.textContaining('[--:--]'), findsWidgets);
expect(find.byType(ChatLine), findsOneWidget);
expect(find.textContaining('hello world'), findsNothing);
expect(find.textContaining('#TEST'), findsOneWidget);
expect(find.byType(ChatLine), findsNWidgets(2));
expect(find.textContaining('hello world'), findsOneWidget);
expect(find.textContaining('#TEST'), findsWidgets);
expect(find.textContaining('<!nodeB>'), findsOneWidget);
expect(find.textContaining('second message'), findsOneWidget);
});