Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
17 changes: 17 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,23 @@ The Flutter package is `app/`, not the repository root. Commands below run from

## Implementation

### Coding style

When working in the Papyrus workspace, read `../CODING_STYLE.md` for the
maintainer's reviewed examples. In standalone checkouts, follow these same rules:

- Use 120-character formatter width and `trailing_commas: preserve`. Use trailing commas to keep deliberate multiline layouts.
- Put blank lines around blocks and multiline statements when adjacent code exists. Keep consecutive single-line statements together, even when their purpose changes. Do not pad the start or end of a block.
- Use meaningful local and callback names such as `activity`, `document`, `error`, and comparator operands `left`/`right`. Avoid redundant qualifiers.
- Use braces for ordinary guards and conditional side effects. A compact consecutive table of immediate returns can remain inline when it is easier to scan.
- Replace nested ternaries with clear branches or a switch. Simple ternaries, getters, and expression-bodied methods remain appropriate.
- Extract meaningful constants and focused helpers for distracting details. Preserve scope, return values, null handling, types, and behavior.
- Remove comments that restate obvious code and decorative section banners. Retain explanations of non-obvious constraints and contracts.
- Keep related declarations together. Simple interface signatures need no blank lines between each member; multiline declarations and larger groups should be separated.
- Apply these rules to changed code without broad unrelated reformatting.

### Behavior and integration

Keep business logic in existing services, repositories and providers. Reuse shared
widgets and theme tokens. Preserve e-ink behavior, keyboard/focus handling, compact
and wide layouts. Use existing platform adapters and conditional imports; do not
Expand Down
1 change: 1 addition & 0 deletions app/analysis_options.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ linter:
lines_longer_than_80_chars: false
formatter:
page_width: 120
trailing_commas: preserve
analyzer:
exclude:
- build/**
Expand Down
11 changes: 9 additions & 2 deletions app/integration_test/powersync_books_integration_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,17 @@ class MemoryRefreshTokenStorage implements RefreshTokenStorage {

Future<void> waitForBook(PapyrusPowerSyncService service, String id, {bool present = true, String? title}) async {
final deadline = DateTime.now().add(const Duration(seconds: 20));

while (DateTime.now().isBefore(deadline)) {
final book = await service.getById(id);

if ((book != null) == present && (title == null || book?.title == title)) {
return;
}

await Future<void>.delayed(const Duration(milliseconds: 200));
}

fail('Book $id did not reach expected presence=$present');
}

Expand Down Expand Up @@ -64,15 +68,18 @@ void main() {
}

final firstAuth = repository('first');

final firstTokens = await firstAuth.register(
email: email,
password: password,
displayName: 'PowerSync Test',
clientType: 'desktop',
);

final secondAuth = repository('second');
await secondAuth.login(email: email, password: password, clientType: 'desktop');
final otherAuth = repository('other');

final otherTokens = await otherAuth.register(
email: 'other-$email',
password: password,
Expand All @@ -90,6 +97,7 @@ void main() {
second.activateAuthenticated(firstTokens.user.userId),
other.activateAuthenticated(otherTokens.user.userId),
]);

await Future.wait([
first.syncStates.firstWhere((state) => state.connected),
second.syncStates.firstWhere((state) => state.connected),
Expand All @@ -102,16 +110,15 @@ void main() {
author: 'Papyrus',
addedAt: DateTime.now().toUtc(),
);

await first.upsert(book);
await waitForBook(second, book.id, title: book.title);
expect(await other.getById(book.id), isNull);

await first.setOnline(false);
await first.upsert(book.copyWith(title: 'Offline edit'));
await first.setOnline(true);
await waitForBook(second, book.id, title: 'Offline edit');
expect((await second.getById(book.id))?.title, 'Offline edit');

await second.delete(book.id);
await waitForBook(first, book.id, present: false);
} finally {
Expand Down
33 changes: 26 additions & 7 deletions app/integration_test/reading_tracking_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,50 +8,69 @@ import 'support/tracking_app.dart';

void main() {
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();

testWidgets('real EPUB/PDF reader activity reaches Goals and survives closing', (tester) async {
final app = TrackingValidationApp();
await app.initialize();
await tester.pumpWidget(app.build());
await tester.pumpAndSettle();
expect(find.text('New goal'), findsOneWidget);

for (final format in ['epub', 'pdf']) {
await tester.tap(find.text('Open $format'));

for (var i = 0; i < 100; i++) {
await tester.pump(const Duration(milliseconds: 200));
if (find.byType(PapyrusReader).evaluate().isNotEmpty && find.byTooltip('Back').evaluate().isNotEmpty) break;

if (find.byType(PapyrusReader).evaluate().isNotEmpty && find.byTooltip('Back').evaluate().isNotEmpty) {
break;
}
}

expect(find.byType(PapyrusReader), findsOneWidget);

// Pump real frames while asynchronous parsing/layout reaches ready content.
// EPUB cover-only chapters have no text coverage, so turn to readable content.
for (var i = 0; i < 60; i++) {
await tester.pump(const Duration(seconds: 1));

if (format == 'epub' && i >= 3 && i <= 5 && find.byTooltip('Next').evaluate().isNotEmpty) {
await tester.tap(find.byTooltip('Next'));
}
if (app.store.readingActivities.any((a) => a.bookId == format && a.coverage.isNotEmpty)) break;

if (app.store.readingActivities.any((activity) => activity.bookId == format && activity.coverage.isNotEmpty)) {
break;
}
}

if (find.text('Not yet').evaluate().isNotEmpty) {
await tester.tap(find.text('Not yet'));
await tester.pump(const Duration(seconds: 3));
}

await tester.tap(find.byTooltip('Back').first);
await tester.pumpAndSettle();
expect(find.byType(GoalsPage), findsOneWidget);

final activity = app.store.effectiveReadingActivities
.where((a) => a.bookId == format && a.kind == 'reading')
.where((activity) => activity.bookId == format && activity.kind == 'reading')
.toList();

expect(activity, isNotEmpty);
expect(activity.fold<int>(0, (n, a) => n + a.seconds), greaterThanOrEqualTo(10));
expect(activity.expand((a) => a.coverage), isNotEmpty);
expect(activity.fold<int>(0, (total, activity) => total + activity.seconds), greaterThanOrEqualTo(10));
expect(activity.expand((activity) => activity.coverage), isNotEmpty);
}

final totals = app.goals.current;
expect(totals.firstWhere((p) => p.goal.type == GoalType.minutes).seconds, greaterThanOrEqualTo(20));
expect(totals.firstWhere((item) => item.goal.type == GoalType.minutes).seconds, greaterThanOrEqualTo(20));

binding.reportData = {
'activity_count': app.store.readingActivities.length,
'seconds': totals.first.seconds,
'estimated_pages': totals.first.pages,
'sources': app.store.readingActivities.map((a) => a.source).toSet().toList(),
'sources': app.store.readingActivities.map((activity) => activity.source).toSet().toList(),
};

await tester.pumpWidget(const SizedBox.shrink());
await app.close();
});
Expand Down
18 changes: 17 additions & 1 deletion app/integration_test/support/tracking_app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,15 @@ import 'package:powersync/powersync.dart' hide Column;
class NoNetworkConnector extends PowerSyncBackendConnector {
@override
Future<PowerSyncCredentials?> fetchCredentials() async => null;

@override
Future<void> uploadData(PowerSyncDatabase database) async {}
}

class FixtureMediaCache extends MediaCacheService {
FixtureMediaCache(this.bytes);
final Map<String, Uint8List> bytes;

@override
Future<Uint8List> ensureBookFileCached(
Book book, {
Expand All @@ -43,6 +45,7 @@ class FixtureMediaCache extends MediaCacheService {
class FixtureAuth extends ChangeNotifier implements AuthProvider {
@override
Future<Uint8List> downloadMedia(String assetId) async => Uint8List(0);

@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
Expand All @@ -55,21 +58,31 @@ class TrackingValidationApp {
late final GoRouter router;
final bytes = <String, Uint8List>{};
final fixtureAuth = FixtureAuth();

Future<void> initialize() async {
const origin = String.fromEnvironment('TRACKING_FIXTURE_ORIGIN', defaultValue: 'http://127.0.0.1:7311');

for (final format in ['epub', 'pdf']) {
final response = await http.get(Uri.parse('$origin/reader.$format'));
if (response.statusCode != 200) throw StateError('Fixture $format could not load');

if (response.statusCode != 200) {
throw StateError('Fixture $format could not load');
}

bytes[format] = response.bodyBytes;
}

final suffix = DateTime.now().microsecondsSinceEpoch;
final root = kIsWeb ? '' : (await getApplicationSupportDirectory()).path;

database = PapyrusPowerSyncService(
connectorFactory: NoNetworkConnector.new,
connectAuthenticated: false,
pathResolver: (_, _, _) async => '${root.isEmpty ? '' : '$root/'}goals-validation-$suffix.db',
);

await database.activateGuest();

for (final format in [BookFormat.epub, BookFormat.pdf]) {
await database.upsert(
Book(
Expand All @@ -84,6 +97,7 @@ class TrackingValidationApp {
),
);
}

store = DataStore(bookRepository: database);
await store.waitUntilLoaded();
goals = GoalsProvider(watchClock: false)..attach(store);
Expand All @@ -93,6 +107,7 @@ class TrackingValidationApp {
final prefs = await SharedPreferences.getInstance();
await ReadingDeviceIdentity.initialize(prefs);
preferences = PreferencesProvider(prefs);

router = GoRouter(
routes: [
GoRoute(
Expand Down Expand Up @@ -142,6 +157,7 @@ class TrackingValidationApp {
),
),
);

Future<void> close() async {
goals.dispose();
router.dispose();
Expand Down
34 changes: 25 additions & 9 deletions app/lib/acquisition/acquisition_api_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class AcquisitionApiClient {
config.endpoint('/acquisition/capabilities'),
headers: _headers(accessToken),
);

return AcquisitionCapabilities.fromJson(_decodeObject(response));
}

Expand Down Expand Up @@ -56,6 +57,7 @@ class AcquisitionApiClient {
'password': ?password,
}),
);

return AcquisitionEndpoint.fromJson(_decodeObject(response));
}

Expand Down Expand Up @@ -83,6 +85,7 @@ class AcquisitionApiClient {
'enabled': ?enabled,
}),
);

return AcquisitionEndpoint.fromJson(_decodeObject(response));
}

Expand All @@ -107,7 +110,9 @@ class AcquisitionApiClient {
'password': ?password,
}),
);

final result = _decodeObject(response);

if (result['ok'] != true) {
throw const AuthApiException(statusCode: 502, message: 'Connection test returned an invalid response');
}
Expand All @@ -118,7 +123,11 @@ class AcquisitionApiClient {
config.endpoint('/acquisition/endpoints/$endpointId'),
headers: _headers(accessToken),
);
if (response.statusCode >= 200 && response.statusCode < 300) return;

if (response.statusCode >= 200 && response.statusCode < 300) {
return;
}

_decodeObject(response);
}

Expand All @@ -132,6 +141,7 @@ class AcquisitionApiClient {
headers: _headers(accessToken),
body: jsonEncode({'query': query, 'endpoint_ids': ?endpointIds}),
);

return _decodeList(response).map(TorrentRelease.fromJson).toList();
}

Expand All @@ -148,6 +158,7 @@ class AcquisitionApiClient {
'release_tokens': releases.map((release) => release.releaseToken).toList(),
}),
);

return BatchSubmissionResponse.fromJson(_decodeObject(response));
}

Expand All @@ -172,13 +183,11 @@ class AcquisitionApiClient {
Future<AcquisitionJobPage> listJobs({required String accessToken, int limit = 50, int offset = 0}) async {
final uri = config.endpoint('/acquisition/jobs').replace(queryParameters: {'limit': '$limit', 'offset': '$offset'});
final response = await _httpClient.get(uri, headers: _headers(accessToken));

return AcquisitionJobPage.fromJson(_decodeObject(response));
}

Future<AcquisitionJob> getJob({required String accessToken, required String jobId}) async {
final response = await _httpClient.get(config.endpoint('/acquisition/jobs/$jobId'), headers: _headers(accessToken));

return AcquisitionJob.fromJson(_decodeObject(response));
}

Expand Down Expand Up @@ -247,6 +256,7 @@ class AcquisitionApiClient {
headers: _headers(accessToken),
body: jsonEncode({'command': command, 'ids': ids}),
);

return AcquisitionJob.fromJson(_decodeObject(response));
}

Expand All @@ -258,23 +268,29 @@ class AcquisitionApiClient {

Map<String, dynamic> _decodeObject(http.Response response) {
final decoded = response.body.isEmpty ? <String, dynamic>{} : jsonDecode(response.body) as Map<String, dynamic>;
if (response.statusCode >= 200 && response.statusCode < 300) return decoded;

if (response.statusCode >= 200 && response.statusCode < 300) {
return decoded;
}

final error = decoded['error'];
final detail = decoded['detail'];

throw AuthApiException(
statusCode: response.statusCode,
message: error is Map<String, dynamic>
? error['message'] as String? ?? 'Acquisition request failed'
: detail is String
? detail
: 'Acquisition request failed',
message: switch (error is Map<String, dynamic>) {
true => error['message'] as String? ?? 'Acquisition request failed',
false when detail is String => detail,
false => 'Acquisition request failed',
},
);
}

List<Map<String, dynamic>> _decodeList(http.Response response) {
if (response.statusCode < 200 || response.statusCode >= 300) {
_decodeObject(response);
}

return (jsonDecode(response.body) as List<dynamic>).cast<Map<String, dynamic>>();
}
}
Loading
Loading