diff --git a/CHANGELOG.md b/CHANGELOG.md index b1e3b333..7d4387e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,18 @@ and submission packet do not claim Play submission, approval, or rollout. ### App source +- Prepared app source candidate `0.3.0+9` with bounded on-device current-text + recovery using Drift. Exact text and metadata commit atomically; startup + offers offline preview, Restore and confirmed Discard, with separate local + checkpoint status and an explicit Save recovery copy action. Storage errors + preserve the previous copy and live text, with explicit volatile continuation. +- Added document-epoch and connection-session guards for Editor Save/Run, + Files Open/Upload and Blocks-to-Editor replacement. Restored drafts require + current-destination confirmation before board writes; a board swap after text + upload refuses Run and reports the partial outcome. Full project and Blocks + graph persistence remain carried. Candidate validation is recorded under + `docs/developments/2026-q4-reliable-workflow/c1-s2/`; no store release claimed. + - Added a full offline Privacy policy route from About, with the published policy, effective date and privacy contact. Reading it preserves the live board session, editor, Blocks and console state, including at large text @@ -90,8 +102,8 @@ and submission packet do not claim Play submission, approval, or rollout. results or new release are claimed. - Specified bounded Drift current-text recovery, local restore/discard, failure handling and document/session guards in ADR-0048 and the App - contracts. Implementation remains pending; sizing requires revising the - original S2 forecast. + contracts. The bounded successor is implemented in the 0.3.0 source candidate; + acceptance and broader persistence obligations follow the C1-S2 record. - Verified live Android open-testing wording and prepared correction of the older public TestFlight description. Installed identities are now recorded; Android code/assets match the retained build-8 AAB, while iPad build-6 source diff --git a/README.md b/README.md index 6edd1fb7..c5c48b08 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ fetches and validates the complete batch, shows the exact destination paths, and asks separately before overwriting existing files. Import never creates a remote folder hierarchy, opens an editor document, or runs downloaded code. -The current App source version is `0.2.0+8`; App and firmware versions are +The next App source candidate is `0.3.0+9`; App and firmware versions are independent. The published Android version is `0.2.0`, available through [Google Play open testing](https://play.google.com/store/apps/details?id=dev.pyble.pyble). iPad testing continues through TestFlight. The @@ -91,6 +91,22 @@ does not establish its installed build/source identity or mean those earlier artifacts contain later connection-lifecycle fixes. Both platforms share the same Flutter source and app test gates. The iOS/iPadOS project minimum is 15. +### Next app candidate: local draft recovery + +The `0.3.0+9` source candidate adds one on-device current-text recovery copy. +After restart, preview and explicitly Restore or Discard the last committed +copy before connecting. “Saved locally” means the matching transaction finished; +pending edits may not survive immediate termination. The separate **Save +recovery copy** action works without a board. Recovered text requires explicit +confirmation of the current board destination before Save, Run or Files Upload. + +This bounded feature covers text up to 1 MiB, including Python explicitly copied +from Blocks. Blocks workspace graphs, projects/history and exported backups +remain outside this increment. Uninstalling or clearing app data removes local +copies. See the [candidate execution record](docs/developments/2026-q4-reliable-workflow/c1-s2/00_Execution_Record.md) +for automated and device evidence; source implementation does not establish +store availability or release qualification. + ### Firmware The board-side agent is built with upstream MicroPython and exposes PBLE/1 as a diff --git a/app/DEPENDENCIES.md b/app/DEPENDENCIES.md index 5c616cef..eb189508 100644 --- a/app/DEPENDENCIES.md +++ b/app/DEPENDENCIES.md @@ -44,6 +44,12 @@ Toolchain pin: **Flutter 3.44.1 (stable)** / Dart SDK `^3.12.1` (see | `http` | runtime | `^1.6.0` | 1.6.0 | BSD-3-Clause | The bounded public GitHub REST client for the connected A-33 subset ([ADR-0040](../docs/decisions/0040-sha-pinned-connected-github-import.md)). The direct dependency is confined to `lib/github_import/`'s production composition point and `GithubRepositoryClient`; all feature consumers bind to the neutral `GithubApi` seam. The adapter accepts only canonical public repository input, sends no credential or user source, permits requests only to exact HTTPS host `api.github.com`, disables redirects, pins every browse to a full commit SHA, and bounds time and response bodies. Min 1.6.0 supplies the abortable request surface used by the cancellation contract on the pinned Dart/Flutter toolchain. | | `highlight` | runtime | `^0.7.0` | 0.7.0 | MIT | Python grammar data consumed directly by `lib/editor`'s rich adapter (A-20). It also appears in `flutter_code_editor`'s closure, but PyBLE's direct import makes the dependency and compatible minimum explicit under BLD-2. | | `webview_flutter` | runtime | `^4.14.1` | 4.14.1 | BSD-3-Clause | The offline platform-WebView host for the pinned Blockly runtime (A-31, ADR-0013). Min 4.14.1 is compatible with PyBLE's Android 24 / iOS 15 platform floors and supplies `loadFlutterAsset`, JavaScript channels, and navigation delegates. Its sole importer is `lib/blocks/`; generated source and board actions stay in Dart behind the neutral `Connection` seam. | +| `drift` | runtime | `^2.35.3` | 2.35.3 | MIT | ADR-0048 bounded current-text recovery. Typed atomic SQLite transactions and owned background native executor; no second persistence backend. | +| `sqlite3` | runtime | `3.5.0` (exact) | 3.5.0 | MIT | Native SQLite bindings/build hooks used by Drift; exact compatibility ceiling for Flutter 3.44.1's pinned `meta` 1.18.0. Later sqlite3 releases require newer hooks/meta; no dependency override is used. SQLite itself is public domain; default upstream SQLite build, no sqlite3mc/encryption fork. | +| `crypto` | runtime | `^3.0.8` | 3.0.8 | BSD-3-Clause | Exact UTF-8 SHA-256 identity checks for local recovery; accidental corruption detection, never board authority or authenticity. Promoted from transitive to explicit direct dependency. | +| `path_provider` | runtime | `^2.1.6` | 2.1.6 | BSD-3-Clause | Resolves the app-private persistent Application Support directory for the single Drift store. No temp/cache/external storage, source upload or remote sync. | +| `drift_dev` | dev | `^2.35.2` | 2.35.2 | MIT | Typed table generation and initial v1 schema export; build-time only. | +| `build_runner` | dev | `^2.15.1` | 2.15.1 | BSD-3-Clause | Reproducible Drift source generation. Resolved with the pinned SDK/meta without overrides; build-time only. | | `flutter_test` | dev (SDK) | Flutter SDK | 3.44.1 | BSD-3-Clause | Widget/unit/golden test harness; the substrate for the G1 suites (NFR-MAINT-4). | | `integration_test` | dev (SDK) | Flutter SDK | 3.44.1 | BSD-3-Clause | On-device Android/iPadOS parity coverage for the real Blockly platform WebView, local asset load, JavaScript bridge, generated Python, and file-backed actions (A-31). Ships with the Flutter SDK and is never part of a release build. | | `flutter_lints` | dev | `^6.0.0` | 6.0.0 | BSD-3-Clause | Recommended Flutter lint set, activated via `analysis_options.yaml`; the static-analysis floor for the app CI gate. Toolchain-default major for Flutter 3.44.1. | @@ -68,7 +74,6 @@ below the table. | `charcode` | 1.4.0 | BSD-3-Clause | | `clock` | 1.1.2 | Apache-2.0 | | `collection` | 1.19.1 | BSD-3-Clause | -| `crypto` | 3.0.7 | BSD-3-Clause | | `dbus` `‡` | 0.7.14 | MPL-2.0 | | `equatable` | 2.1.0 | MIT | | `fake_async` | 1.3.3 | Apache-2.0 | @@ -133,6 +138,64 @@ below the table. | `win32` | 6.3.0 | BSD-3-Clause | | `xml` | 6.6.1 | MIT | +### Recovery dependency additions (PYB-006.2) + +These additional locked transitive packages were checked against the exact package +`LICENSE` files on 31 October 2026. Build generators, analyzer and their helper +packages are development inputs; platform adapters compile only for their target. +The original transitive table plus this table covers the resolved graph. + +| Package | Resolved | SPDX | +|---|---|---| +| `_fe_analyzer_shared` | 100.0.0 | BSD-3-Clause | +| `analyzer` | 13.0.0 | BSD-3-Clause | +| `build` | 4.0.7 | BSD-3-Clause | +| `build_config` | 1.3.3 | BSD-3-Clause | +| `build_daemon` | 4.1.7 | BSD-3-Clause | +| `built_collection` | 5.1.2 | BSD-3-Clause | +| `built_value` | 8.13.0 | BSD-3-Clause | +| `checked_yaml` | 2.0.4 | BSD-3-Clause | +| `cli_util` | 0.6.0 | BSD-3-Clause | +| `code_assets` | 1.2.1 | BSD-3-Clause | +| `convert` | 3.1.2 | BSD-3-Clause | +| `dart_style` | 3.1.9 | BSD-3-Clause | +| `fixnum` | 1.1.1 | BSD-3-Clause | +| `glob` | 2.2.0 | BSD-3-Clause | +| `graphs` | 2.3.2 | BSD-3-Clause | +| `hooks` | 2.0.2 | BSD-3-Clause | +| `http_multi_server` | 3.2.3 | BSD-3-Clause | +| `io` | 1.1.0 | BSD-3-Clause | +| `jni` | 1.1.1 | BSD-3-Clause | +| `jni_flutter` | 1.0.4+1 | BSD-3-Clause | +| `jni_util` | 1.0.0 | BSD-3-Clause | +| `json_annotation` | 4.12.0 | BSD-3-Clause | +| `logging` | 1.3.0 | BSD-3-Clause | +| `mime` | 2.1.0 | BSD-3-Clause | +| `native_toolchain_c` | 0.19.2 | BSD-3-Clause | +| `objective_c` | 9.5.0 | BSD-3-Clause | +| `package_config` | 2.2.0 | BSD-3-Clause | +| `path_provider_android` | 2.3.1 | BSD-3-Clause | +| `path_provider_foundation` | 2.6.0 | BSD-3-Clause | +| `path_provider_linux` | 2.2.2 | BSD-3-Clause | +| `path_provider_platform_interface` | 2.1.3 | BSD-3-Clause | +| `path_provider_windows` | 2.3.0 | BSD-3-Clause | +| `pool` | 1.5.3 | BSD-3-Clause | +| `pub_semver` | 2.2.1 | BSD-3-Clause | +| `pubspec_parse` | 1.7.0 | BSD-3-Clause | +| `recase` | 4.1.0 | BSD-2-Clause | +| `record_use` | 0.6.0 | BSD-3-Clause | +| `shelf` | 1.4.3 | BSD-3-Clause | +| `shelf_web_socket` | 3.0.0 | BSD-3-Clause | +| `source_gen` | 4.2.4 | BSD-3-Clause | +| `sqlparser` | 0.45.1 | MIT | +| `stream_transform` | 2.1.2 | BSD-3-Clause | +| `watcher` | 1.2.1 | BSD-3-Clause | +| `web_socket` | 1.0.1 | BSD-3-Clause | +| `web_socket_channel` | 3.0.3 | BSD-3-Clause | +| `xdg_directories` | 1.1.0 | BSD-3-Clause | +| `yaml` | 3.1.4 | MIT | +| `yaml_edit` | 2.2.4 | BSD-3-Clause | + ### `†` Owner-ratified shipped carve-out — `autotrie` 2.0.0 (MPL-2.0) `flutter_code_editor` constructs its autocomplete index with `autotrie`, even @@ -205,6 +268,22 @@ rationale when added: | Package | Sprint / story | Purpose | |---|---|---| -| `drift`, `sqlite3` | S5 / A-24 | Local persistence. | | `fl_chart` | later | Plotting surface. | | `go_router` | — | Not planned for S1; navigation is index-based via a Riverpod `StateProvider` + `IndexedStack`. | + +## Recovery build and notice reproduction + +- Run `dart run build_runner build` from `app/`, then + `dart run drift_dev schema dump lib/data/draft_database.dart drift_schemas/`. + The first generated database source and schema are reviewed and committed; + `build.yaml` preserves the authored-source SPDX preamble on regeneration. +- Pub-package license texts remain in Flutter's mechanically generated + `NOTICES.Z` and in-app LicenseRegistry. The SQLite native amalgamation's + public-domain provenance is separately bundled in + `assets/licenses/SQLite-PUBLIC-DOMAIN.txt`; PyBLE adds no custom SQLite code. +- The default `sqlite3` build hook obtains its upstream binaries from the + package's release assets, with the package-pinned SHA-256 checks. No binaries, + compiler outputs or native signing inputs are committed here. +- The database is `pyble.sqlite` in Application Support. Its `-wal`/`-shm` + companions belong to the same store. Device/OS backup behavior is external + to PyBLE's local checkpoint promise; no backup/sync feature is added. diff --git a/app/assets/licenses/SQLite-PUBLIC-DOMAIN.txt b/app/assets/licenses/SQLite-PUBLIC-DOMAIN.txt new file mode 100644 index 00000000..43c00733 --- /dev/null +++ b/app/assets/licenses/SQLite-PUBLIC-DOMAIN.txt @@ -0,0 +1,11 @@ +SQLite native database engine + +SQLite is public-domain software, as documented by the SQLite project: +https://www.sqlite.org/copyright.html + +PyBLE uses the default SQLite engine supplied by the unmodified sqlite3 Dart +package's build hook. The Dart bindings are separately MIT licensed; their +license is included in Flutter's generated package notices. PyBLE does not +ship the optional sqlite3mc engine. + +Package provenance and locked versions: app/DEPENDENCIES.md and pubspec.lock. diff --git a/app/build.yaml b/app/build.yaml new file mode 100644 index 00000000..8e35773e --- /dev/null +++ b/app/build.yaml @@ -0,0 +1,11 @@ +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. + +targets: + $default: + builders: + source_gen:combining_builder: + options: + preamble: | + // SPDX-License-Identifier: MIT + // Part of PyBLE (https://pyble.dev) — see /LICENSE. diff --git a/app/drift_schemas/drift_schema_v1.json b/app/drift_schemas/drift_schema_v1.json new file mode 100644 index 00000000..de0a7632 --- /dev/null +++ b/app/drift_schemas/drift_schema_v1.json @@ -0,0 +1,239 @@ +{ + "_meta": { + "description": "This file contains a serialized version of schema entities for drift.", + "version": "1.3.0" + }, + "options": { + "store_date_time_values_as_text": false + }, + "entities": [ + { + "id": 0, + "references": [], + "type": "table", + "data": { + "name": "projects", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "name", + "getter_name": "name", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 1, + "references": [ + 0 + ], + "type": "table", + "data": { + "name": "project_files", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "project_id", + "getter_name": "projectId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "REFERENCES projects (id)", + "dialectAwareDefaultConstraints": { + "sqlite": "REFERENCES projects (id)" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + { + "foreign_key": { + "to": { + "table": "projects", + "column": "id" + }, + "initially_deferred": false, + "on_update": null, + "on_delete": null + } + } + ] + }, + { + "name": "path", + "getter_name": "path", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "content", + "getter_name": "content", + "moor_type": "blob", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "size", + "getter_name": "size", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "content_hash", + "getter_name": "contentHash", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 2, + "references": [], + "type": "table", + "data": { + "name": "settings", + "was_declared_in_moor": false, + "columns": [ + { + "name": "key", + "getter_name": "key", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "value", + "getter_name": "value", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "key" + ] + } + } + ], + "fixed_sql": [ + { + "name": "projects", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"projects\" (\"id\" TEXT NOT NULL, \"name\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" INTEGER NOT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "project_files", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"project_files\" (\"id\" TEXT NOT NULL, \"project_id\" TEXT NOT NULL REFERENCES projects (id), \"path\" TEXT NOT NULL, \"content\" BLOB NOT NULL, \"size\" INTEGER NOT NULL, \"content_hash\" TEXT NOT NULL, \"updated_at\" INTEGER NOT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "settings", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"settings\" (\"key\" TEXT NOT NULL, \"value\" TEXT NOT NULL, PRIMARY KEY (\"key\"));" + } + ] + } + ] +} \ No newline at end of file diff --git a/app/integration_test/draft_recovery_device_qualification.dart b/app/integration_test/draft_recovery_device_qualification.dart new file mode 100644 index 00000000..727c8178 --- /dev/null +++ b/app/integration_test/draft_recovery_device_qualification.dart @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: MIT +// Part of PyBLE (https://pyble.dev) — see /LICENSE. + +/// Two launches of an isolated qualification application exercise actual +/// platform storage. Run seed, terminate the process, then run verify with the +/// same application ID/data container. This uses a recording board seam and +/// does not qualify physical BLE or a store-distributed binary. +library; + +import 'dart:io'; +import 'dart:convert'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:pyble/app/providers.dart'; +import 'package:pyble/data/draft_repository.dart'; +import 'package:pyble/data/drift_draft_repository.dart'; +import 'package:pyble/editor/board_target_guard.dart'; +import 'package:pyble/editor/draft_recovery.dart'; +import 'package:pyble/editor/editor_document.dart'; +import 'package:pyble/editor/save_controller.dart'; +import 'package:pyble/pble/pble.dart'; +import '../test/support/recording_connection.dart'; + +const _source = '# recovery qualification — ทดสอบ\nprint(“exact text”)\n'; +const _phase = String.fromEnvironment( + 'PYBLE_RECOVERY_TEST_PHASE', + defaultValue: 'seed', +); + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + testWidgets('platform durable recovery ($_phase)', (tester) async { + final directory = await getApplicationSupportDirectory(); + final resultFile = File( + '${directory.path}/qualification-$_phase-result.json', + ); + if (await resultFile.exists()) await resultFile.delete(); + final file = File('${directory.path}/qualification-current-draft.sqlite'); + final repository = await DriftDraftRepository.open(file); + try { + final existing = await repository.load(); + if (_phase == 'seed') { + await repository.write( + DraftSnapshot( + name: 'qualification.py', + content: _source, + originPath: '/previous-board/qualification.py', + generation: existing.generation + 1, + checkpointTime: DateTime.now().toUtc(), + ), + expectedGeneration: existing.generation, + ); + expect((await repository.load()).snapshot!.content, _source); + } else { + expect(_phase, 'verify'); + expect( + existing.snapshot, + isNotNull, + reason: 'The seed must survive a separate app process launch.', + ); + final connection = RecordingConnection(initial: ConnState.ready); + final container = ProviderContainer( + overrides: [ + draftRepositoryProvider.overrideWithValue(repository), + connectionProvider.overrideWithValue(connection), + boardTargetConfirmationProvider.overrideWithValue( + (_) async => false, + ), + ], + ); + try { + final recovery = container.read(draftRecoveryProvider.notifier); + await recovery.start(); + expect( + container.read(draftRecoveryProvider).status, + DraftRecoveryStatus.decision, + ); + expect(recovery.restore(), isTrue); + expect(container.read(editorDocumentProvider).content, _source); + expect(container.read(editorDocumentProvider).boardPath, isNull); + expect(container.read(editorDocumentProvider).dirty, isTrue); + expect(connection.putFileCalls, isEmpty); + expect(connection.runFileCalls, isEmpty); + await expectLater( + container.read(saveControllerProvider).save(), + throwsA(isA()), + ); + expect(connection.putFileCalls, isEmpty); + } finally { + container.dispose(); + } + } + } finally { + await repository.close(); + } + // A profile-mode app may have no attached stdout/test driver. Emit a + // bounded test-only receipt only after every assertion and store close. + await resultFile.writeAsString( + jsonEncode({ + 'phase': _phase, + 'passed': true, + 'platform': Platform.operatingSystem, + 'completedAt': DateTime.now().toUtc().toIso8601String(), + }), + flush: true, + ); + }); +} diff --git a/app/lib/app/app.dart b/app/lib/app/app.dart index 38630e61..70cb4a55 100644 --- a/app/lib/app/app.dart +++ b/app/lib/app/app.dart @@ -15,10 +15,14 @@ library; import 'package:flutter/material.dart'; import 'package:pyble/localization/localization.dart'; +import 'package:pyble/editor/recovery_gate.dart'; import 'package:pyble/theme/theme.dart'; import 'shell.dart'; +/// Navigator used by explicit user confirmations, never by local recovery itself. +final GlobalKey appNavigatorKey = GlobalKey(); + /// The PyBLE root widget: a `MaterialApp` hosting the responsive shell. class PybleApp extends StatelessWidget { const PybleApp({super.key}); @@ -26,6 +30,7 @@ class PybleApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( + navigatorKey: appNavigatorKey, // The app wordmark is a brand proper noun, sourced from the ARB (rendered // verbatim) so the OS task-switcher title follows the localization path. onGenerateTitle: (BuildContext context) => @@ -40,7 +45,7 @@ class PybleApp extends StatelessWidget { highContrastDarkTheme: PybleTheme.highContrastDark, themeMode: ThemeMode.system, debugShowCheckedModeBanner: false, - home: const PybleShell(), + home: const RecoveryGate(child: PybleShell()), ); } } diff --git a/app/lib/app/app_info.dart b/app/lib/app/app_info.dart index 8d62021e..e4603cda 100644 --- a/app/lib/app/app_info.dart +++ b/app/lib/app/app_info.dart @@ -17,6 +17,6 @@ library; const String kAppName = 'PyBLE'; /// The client version sent in HELLO (ASCII semver). Kept in step with the -/// `version:` field in `app/pubspec.yaml` (currently `0.2.0+5`); the build- -/// metadata suffix (`+5`) is dropped so the wire value is a clean semver. -const String kAppVersion = '0.2.0'; +/// `version:` field in `app/pubspec.yaml` (currently `0.3.0+9`); the build- +/// metadata suffix (`+9`) is dropped so the wire value is a clean semver. +const String kAppVersion = '0.3.0'; diff --git a/app/lib/app/licenses.dart b/app/lib/app/licenses.dart index 3cbc302d..90b48224 100644 --- a/app/lib/app/licenses.dart +++ b/app/lib/app/licenses.dart @@ -28,4 +28,10 @@ void registerBundledLicenses() { ); yield LicenseEntryWithLineBreaks(['Blockly'], text); }); + LicenseRegistry.addLicense(() async* { + final String text = await rootBundle.loadString( + 'assets/licenses/SQLite-PUBLIC-DOMAIN.txt', + ); + yield LicenseEntryWithLineBreaks(['SQLite'], text); + }); } diff --git a/app/lib/app/pages/editor_page.dart b/app/lib/app/pages/editor_page.dart index c5f6a018..d9531d18 100644 --- a/app/lib/app/pages/editor_page.dart +++ b/app/lib/app/pages/editor_page.dart @@ -2,8 +2,8 @@ // Part of PyBLE (https://pyble.dev) — see /LICENSE. /// A-20 — the Editor surface host. Delegates to the live [EditorView] in -/// `lib/editor` (owner: app-editor-console-engineer), which edits the volatile -/// in-memory current document (ADR-0010). This page is a thin app-layer host: it +/// `lib/editor` (owner: app-editor-console-engineer), which edits the shared +/// current document with bounded local recovery (ADR-0048). This page is a thin app-layer host: it /// bears no display copy (all strings live in the feature view, sourced from /// `AppLocalizations`) and binds to no transport type (CON-8, D2). library; diff --git a/app/lib/app/shell.dart b/app/lib/app/shell.dart index 9aa70ba6..571c619c 100644 --- a/app/lib/app/shell.dart +++ b/app/lib/app/shell.dart @@ -19,6 +19,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:pyble/console/console.dart'; import 'package:pyble/editor/editor.dart'; +import 'package:pyble/editor/board_target_guard.dart'; +import 'package:pyble/editor/recovery_gate.dart'; import 'package:pyble/localization/localization.dart'; import 'package:pyble/pble/pble.dart'; import 'package:pyble/theme/theme.dart'; @@ -149,18 +151,53 @@ class PybleShell extends ConsumerWidget { /// Pin-reference pane, or console strip, none of which means anything without a /// board. The toolbar's connection action is an inert Connect slot here (the /// live scan/connect flow is the surface below); Run/Stop are disabled. -class _DisconnectedShell extends StatelessWidget { +class _DisconnectedShell extends ConsumerStatefulWidget { const _DisconnectedShell(); + @override + ConsumerState<_DisconnectedShell> createState() => _DisconnectedShellState(); +} + +class _DisconnectedShellState extends ConsumerState<_DisconnectedShell> { + bool _showRecovery = true; + @override Widget build(BuildContext context) { - return const Scaffold( + final DraftRecoveryState recovery = ref.watch(draftRecoveryProvider); + final bool recovered = ref.watch(editorRecoveredDraftProvider); + final EditorDocument document = ref.watch(editorDocumentProvider); + final bool previewAvailable = + recovery.enabled && + (recovered || + document.content.isNotEmpty || + document.name != kDefaultDocumentName || + document.boardPath != null); + final AppLocalizations l10n = AppLocalizations.of(context); + return Scaffold( body: SafeArea( child: Column( children: [ - _Toolbar(), - Divider(height: 1), - Expanded(child: ConnectPage()), + const _Toolbar(), + const Divider(height: 1), + if (previewAvailable) ...[ + TextButton.icon( + onPressed: () => setState(() => _showRecovery = !_showRecovery), + icon: Icon( + _showRecovery ? Icons.bluetooth : Icons.description_outlined, + ), + label: Text( + _showRecovery ? l10n.recoveryConnect : l10n.recoveryOpen, + ), + ), + const RecoveryStatusBar(), + ], + Expanded( + child: previewAvailable && _showRecovery + ? CurrentDraftPreview( + onContinue: () => setState(() => _showRecovery = false), + ) + : const ConnectPage(), + ), ], ), ), @@ -509,13 +546,15 @@ class _Toolbar extends ConsumerWidget { } on EBusy { // EBusy is never swallowed: tell the user a program is already running. messenger.showSnackBar(SnackBar(content: Text(l10n.runBusyMessage))); + } on BoardActionCancelled { + return; } catch (e) { // NOTHING else was surfaced before: every other failure (a typed ERange // for an over-long buffer, a link timeout, a torn-down connection) // became an unhandled async error, so pressing Run did LITERALLY nothing // visible. FR-RUN-3 requires the failure to be honest. messenger.showSnackBar( - SnackBar(content: Text(l10n.runFailed(_reasonOf(e)))), + SnackBar(content: Text(l10n.runFailed(_reasonOf(e, l10n)))), ); } } @@ -531,15 +570,19 @@ class _Toolbar extends ConsumerWidget { await action(); } catch (e) { messenger.showSnackBar( - SnackBar(content: Text(l10n.runFailed(_reasonOf(e)))), + SnackBar(content: Text(l10n.runFailed(_reasonOf(e, l10n)))), ); } } /// The technical reason token for [e]. A typed PBLE/1 §8 status name is a /// technical identifier rendered verbatim (FR-I18N-4). - static String _reasonOf(Object e) => - e is PbleException ? e.runtimeType.toString() : e.toString(); + static String _reasonOf(Object e, AppLocalizations l10n) => switch (e) { + ProgramSourceSessionChanged(:final sourcePath) => + l10n.runSourceSessionChanged(sourcePath), + PbleException() => e.runtimeType.toString(), + _ => e.toString(), + }; static void _openAbout(BuildContext context) { Navigator.of(context).push( diff --git a/app/lib/blocks/blocks_document.dart b/app/lib/blocks/blocks_document.dart index a94058db..ce33cb94 100644 --- a/app/lib/blocks/blocks_document.dart +++ b/app/lib/blocks/blocks_document.dart @@ -16,6 +16,7 @@ import 'package:flutter/foundation.dart' show immutable; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:pyble/editor/editor.dart'; +import 'package:pyble/editor/board_target_guard.dart'; import 'blocks_companion.dart'; import 'blocks_examples.dart'; @@ -1038,22 +1039,56 @@ class BlocksDocumentController extends Notifier { /// Copies a freshly acknowledged revision into an independent editor document. /// /// Callers must explicitly resolve a dirty-buffer conflict. - Future openInEditor({bool replaceDirty = false}) async { + Future openInEditor({ + bool replaceDirty = false, + int? expectedEditorEpoch, + }) async { + final int editorEpoch = + expectedEditorEpoch ?? ref.read(editorDocumentEpochProvider); + if (ref.read(editorDocumentEpochProvider) != editorEpoch) { + throw BlocksEditorConflict(ref.read(editorDocumentProvider).name); + } _beginAction(); try { final GeneratedProgram program = await _freshProgramForAction(); + final int? capturedHostId = _activeHostId; + if (ref.read(editorDocumentEpochProvider) != editorEpoch) { + throw BlocksEditorConflict(ref.read(editorDocumentProvider).name); + } final EditorDocument editor = ref.read(editorDocumentProvider); final String generatedName = state.targetPath.substring( state.targetPath.lastIndexOf('/') + 1, ); final bool sameGeneratedDocument = editor.name == generatedName && editor.content == program.source; - if (editor.dirty && !sameGeneratedDocument && !replaceDirty) { + final recoveryEnabled = + ref.read(draftRecoveryProvider).status != + DraftRecoveryStatus.disabled; + if (!recoveryEnabled && + editor.dirty && + !sameGeneratedDocument && + !replaceDirty) { throw BlocksEditorConflict(editor.name); } - ref - .read(editorDocumentProvider.notifier) - .openGenerated(name: generatedName, content: program.source); + if (recoveryEnabled && + !await confirmEditorReplacement(ref, expectedEpoch: editorEpoch)) { + throw const BoardActionCancelled(); + } + final adopted = await replaceEditorDocument( + ref, + EditorDocument( + name: generatedName, + content: program.source, + dirty: true, + ), + expectedEpoch: editorEpoch, + isStillValid: () => + capturedHostId == _activeHostId && + state.program?.revision == program.revision && + state.retainedWorkspaceRevision == program.revision && + state.workspaceError == null, + ); + if (!adopted) throw const BoardActionCancelled(); } finally { _endAction(); } diff --git a/app/lib/blocks/blocks_view.dart b/app/lib/blocks/blocks_view.dart index 09a98929..e289aeb3 100644 --- a/app/lib/blocks/blocks_view.dart +++ b/app/lib/blocks/blocks_view.dart @@ -12,6 +12,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:pyble/app/pages/surface_placeholder.dart'; import 'package:pyble/app/providers.dart'; import 'package:pyble/editor/editor.dart'; +import 'package:pyble/editor/board_target_guard.dart'; import 'package:pyble/localization/localization.dart'; import 'package:pyble/pble/pble.dart'; import 'package:pyble/theme/theme.dart'; @@ -464,9 +465,10 @@ class BlocksView extends ConsumerWidget { final BlocksDocumentController controller = ref.read( blocksDocumentProvider.notifier, ); + final int editorEpoch = ref.read(editorDocumentEpochProvider); try { try { - await controller.openInEditor(); + await controller.openInEditor(expectedEditorEpoch: editorEpoch); } on BlocksEditorConflict catch (conflict) { if (!context.mounted) return; final bool replace = @@ -491,10 +493,15 @@ class BlocksView extends ConsumerWidget { ) ?? false; if (!replace || !context.mounted) return; - await controller.openInEditor(replaceDirty: true); + await controller.openInEditor( + replaceDirty: true, + expectedEditorEpoch: editorEpoch, + ); } if (!context.mounted) return; ref.read(selectedSurfaceProvider.notifier).state = AppSurface.editor; + } on BoardActionCancelled { + return; } catch (error) { if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( diff --git a/app/lib/data/data.dart b/app/lib/data/data.dart index 275b5371..ee47201b 100644 --- a/app/lib/data/data.dart +++ b/app/lib/data/data.dart @@ -6,5 +6,9 @@ /// On-device content only: no accounts, no telemetry, no cloud, no /// managed-fleet/classroom tables (SEC, DAT-8). Binds to the seam, not the wire. /// -/// S1 skeleton placeholder barrel. Real content lands at story A-24. +/// The first implemented subset is one bounded current-text recovery slot +/// (ADR-0048 / PYB-006.2). Full projects, history and saved boards remain A-24. library; + +export 'draft_repository.dart'; +export 'draft_repository_factory.dart'; diff --git a/app/lib/data/draft_database.dart b/app/lib/data/draft_database.dart new file mode 100644 index 00000000..212dd88a --- /dev/null +++ b/app/lib/data/draft_database.dart @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT +// Part of PyBLE (https://pyble.dev) — see /LICENSE. + +import 'package:drift/drift.dart'; + +import 'draft_repository.dart'; + +part 'draft_database.g.dart'; + +/// The internal current-draft project is not a project-management UI. +class Projects extends Table { + TextColumn get id => text()(); + TextColumn get name => text()(); + IntColumn get createdAt => integer()(); + IntColumn get updatedAt => integer()(); + @override + Set> get primaryKey => >{id}; +} + +class ProjectFiles extends Table { + TextColumn get id => text()(); + TextColumn get projectId => text().references(Projects, #id)(); + TextColumn get path => text()(); + BlobColumn get content => blob()(); + IntColumn get size => integer()(); + TextColumn get contentHash => text()(); + IntColumn get updatedAt => integer()(); + @override + Set> get primaryKey => >{id}; +} + +/// The one SettingsStore, initially holding only versioned recovery metadata. +class Settings extends Table { + TextColumn get key => text()(); + TextColumn get value => text()(); + @override + Set> get primaryKey => >{key}; +} + +@DriftDatabase(tables: [Projects, ProjectFiles, Settings]) +class DraftDatabase extends _$DraftDatabase { + DraftDatabase(super.executor); + + @override + int get schemaVersion => 1; + + @override + MigrationStrategy get migration => MigrationStrategy( + onCreate: (Migrator m) async { + await m.createAll(); + await customStatement( + 'INSERT INTO settings (key, value) VALUES (?, ?)', + [ + 'current_text_draft', + '{"version":1,"generation":0,"empty":true}', + ], + ); + }, + // There is no released v0 disk draft and no supported downgrade. Future + // versions add explicit transactional forward migrations and fixtures. + onUpgrade: (Migrator m, int from, int to) async => + throw const DraftStorageException( + DraftStorageFailure.unsupportedVersion, + ), + ); +} diff --git a/app/lib/data/draft_database.g.dart b/app/lib/data/draft_database.g.dart new file mode 100644 index 00000000..805ab3a3 --- /dev/null +++ b/app/lib/data/draft_database.g.dart @@ -0,0 +1,1797 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// SPDX-License-Identifier: MIT +// Part of PyBLE (https://pyble.dev) — see /LICENSE. + +part of 'draft_database.dart'; + +// ignore_for_file: type=lint +class $ProjectsTable extends Projects with TableInfo<$ProjectsTable, Project> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $ProjectsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _nameMeta = const VerificationMeta('name'); + @override + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _updatedAtMeta = const VerificationMeta( + 'updatedAt', + ); + @override + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + @override + List get $columns => [id, name, createdAt, updatedAt]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'projects'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('name')) { + context.handle( + _nameMeta, + name.isAcceptableOrUnknown(data['name']!, _nameMeta), + ); + } else if (isInserting) { + context.missing(_nameMeta); + } + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } else if (isInserting) { + context.missing(_createdAtMeta); + } + if (data.containsKey('updated_at')) { + context.handle( + _updatedAtMeta, + updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta), + ); + } else if (isInserting) { + context.missing(_updatedAtMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + Project map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return Project( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}created_at'], + )!, + updatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}updated_at'], + )!, + ); + } + + @override + $ProjectsTable createAlias(String alias) { + return $ProjectsTable(attachedDatabase, alias); + } +} + +class Project extends DataClass implements Insertable { + final String id; + final String name; + final int createdAt; + final int updatedAt; + const Project({ + required this.id, + required this.name, + required this.createdAt, + required this.updatedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['name'] = Variable(name); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + return map; + } + + ProjectsCompanion toCompanion(bool nullToAbsent) { + return ProjectsCompanion( + id: Value(id), + name: Value(name), + createdAt: Value(createdAt), + updatedAt: Value(updatedAt), + ); + } + + factory Project.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return Project( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + }; + } + + Project copyWith({ + String? id, + String? name, + int? createdAt, + int? updatedAt, + }) => Project( + id: id ?? this.id, + name: name ?? this.name, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + Project copyWithCompanion(ProjectsCompanion data) { + return Project( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + ); + } + + @override + String toString() { + return (StringBuffer('Project(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, name, createdAt, updatedAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Project && + other.id == this.id && + other.name == this.name && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt); +} + +class ProjectsCompanion extends UpdateCompanion { + final Value id; + final Value name; + final Value createdAt; + final Value updatedAt; + final Value rowid; + const ProjectsCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + ProjectsCompanion.insert({ + required String id, + required String name, + required int createdAt, + required int updatedAt, + this.rowid = const Value.absent(), + }) : id = Value(id), + name = Value(name), + createdAt = Value(createdAt), + updatedAt = Value(updatedAt); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? createdAt, + Expression? updatedAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (rowid != null) 'rowid': rowid, + }); + } + + ProjectsCompanion copyWith({ + Value? id, + Value? name, + Value? createdAt, + Value? updatedAt, + Value? rowid, + }) { + return ProjectsCompanion( + id: id ?? this.id, + name: name ?? this.name, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ProjectsCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class $ProjectFilesTable extends ProjectFiles + with TableInfo<$ProjectFilesTable, ProjectFile> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $ProjectFilesTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _projectIdMeta = const VerificationMeta( + 'projectId', + ); + @override + late final GeneratedColumn projectId = GeneratedColumn( + 'project_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES projects (id)', + ), + ); + static const VerificationMeta _pathMeta = const VerificationMeta('path'); + @override + late final GeneratedColumn path = GeneratedColumn( + 'path', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _contentMeta = const VerificationMeta( + 'content', + ); + @override + late final GeneratedColumn content = GeneratedColumn( + 'content', + aliasedName, + false, + type: DriftSqlType.blob, + requiredDuringInsert: true, + ); + static const VerificationMeta _sizeMeta = const VerificationMeta('size'); + @override + late final GeneratedColumn size = GeneratedColumn( + 'size', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _contentHashMeta = const VerificationMeta( + 'contentHash', + ); + @override + late final GeneratedColumn contentHash = GeneratedColumn( + 'content_hash', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _updatedAtMeta = const VerificationMeta( + 'updatedAt', + ); + @override + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + @override + List get $columns => [ + id, + projectId, + path, + content, + size, + contentHash, + updatedAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'project_files'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('project_id')) { + context.handle( + _projectIdMeta, + projectId.isAcceptableOrUnknown(data['project_id']!, _projectIdMeta), + ); + } else if (isInserting) { + context.missing(_projectIdMeta); + } + if (data.containsKey('path')) { + context.handle( + _pathMeta, + path.isAcceptableOrUnknown(data['path']!, _pathMeta), + ); + } else if (isInserting) { + context.missing(_pathMeta); + } + if (data.containsKey('content')) { + context.handle( + _contentMeta, + content.isAcceptableOrUnknown(data['content']!, _contentMeta), + ); + } else if (isInserting) { + context.missing(_contentMeta); + } + if (data.containsKey('size')) { + context.handle( + _sizeMeta, + size.isAcceptableOrUnknown(data['size']!, _sizeMeta), + ); + } else if (isInserting) { + context.missing(_sizeMeta); + } + if (data.containsKey('content_hash')) { + context.handle( + _contentHashMeta, + contentHash.isAcceptableOrUnknown( + data['content_hash']!, + _contentHashMeta, + ), + ); + } else if (isInserting) { + context.missing(_contentHashMeta); + } + if (data.containsKey('updated_at')) { + context.handle( + _updatedAtMeta, + updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta), + ); + } else if (isInserting) { + context.missing(_updatedAtMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + ProjectFile map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ProjectFile( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + projectId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}project_id'], + )!, + path: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}path'], + )!, + content: attachedDatabase.typeMapping.read( + DriftSqlType.blob, + data['${effectivePrefix}content'], + )!, + size: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}size'], + )!, + contentHash: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}content_hash'], + )!, + updatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}updated_at'], + )!, + ); + } + + @override + $ProjectFilesTable createAlias(String alias) { + return $ProjectFilesTable(attachedDatabase, alias); + } +} + +class ProjectFile extends DataClass implements Insertable { + final String id; + final String projectId; + final String path; + final Uint8List content; + final int size; + final String contentHash; + final int updatedAt; + const ProjectFile({ + required this.id, + required this.projectId, + required this.path, + required this.content, + required this.size, + required this.contentHash, + required this.updatedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['project_id'] = Variable(projectId); + map['path'] = Variable(path); + map['content'] = Variable(content); + map['size'] = Variable(size); + map['content_hash'] = Variable(contentHash); + map['updated_at'] = Variable(updatedAt); + return map; + } + + ProjectFilesCompanion toCompanion(bool nullToAbsent) { + return ProjectFilesCompanion( + id: Value(id), + projectId: Value(projectId), + path: Value(path), + content: Value(content), + size: Value(size), + contentHash: Value(contentHash), + updatedAt: Value(updatedAt), + ); + } + + factory ProjectFile.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ProjectFile( + id: serializer.fromJson(json['id']), + projectId: serializer.fromJson(json['projectId']), + path: serializer.fromJson(json['path']), + content: serializer.fromJson(json['content']), + size: serializer.fromJson(json['size']), + contentHash: serializer.fromJson(json['contentHash']), + updatedAt: serializer.fromJson(json['updatedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'projectId': serializer.toJson(projectId), + 'path': serializer.toJson(path), + 'content': serializer.toJson(content), + 'size': serializer.toJson(size), + 'contentHash': serializer.toJson(contentHash), + 'updatedAt': serializer.toJson(updatedAt), + }; + } + + ProjectFile copyWith({ + String? id, + String? projectId, + String? path, + Uint8List? content, + int? size, + String? contentHash, + int? updatedAt, + }) => ProjectFile( + id: id ?? this.id, + projectId: projectId ?? this.projectId, + path: path ?? this.path, + content: content ?? this.content, + size: size ?? this.size, + contentHash: contentHash ?? this.contentHash, + updatedAt: updatedAt ?? this.updatedAt, + ); + ProjectFile copyWithCompanion(ProjectFilesCompanion data) { + return ProjectFile( + id: data.id.present ? data.id.value : this.id, + projectId: data.projectId.present ? data.projectId.value : this.projectId, + path: data.path.present ? data.path.value : this.path, + content: data.content.present ? data.content.value : this.content, + size: data.size.present ? data.size.value : this.size, + contentHash: data.contentHash.present + ? data.contentHash.value + : this.contentHash, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + ); + } + + @override + String toString() { + return (StringBuffer('ProjectFile(') + ..write('id: $id, ') + ..write('projectId: $projectId, ') + ..write('path: $path, ') + ..write('content: $content, ') + ..write('size: $size, ') + ..write('contentHash: $contentHash, ') + ..write('updatedAt: $updatedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + projectId, + path, + $driftBlobEquality.hash(content), + size, + contentHash, + updatedAt, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ProjectFile && + other.id == this.id && + other.projectId == this.projectId && + other.path == this.path && + $driftBlobEquality.equals(other.content, this.content) && + other.size == this.size && + other.contentHash == this.contentHash && + other.updatedAt == this.updatedAt); +} + +class ProjectFilesCompanion extends UpdateCompanion { + final Value id; + final Value projectId; + final Value path; + final Value content; + final Value size; + final Value contentHash; + final Value updatedAt; + final Value rowid; + const ProjectFilesCompanion({ + this.id = const Value.absent(), + this.projectId = const Value.absent(), + this.path = const Value.absent(), + this.content = const Value.absent(), + this.size = const Value.absent(), + this.contentHash = const Value.absent(), + this.updatedAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + ProjectFilesCompanion.insert({ + required String id, + required String projectId, + required String path, + required Uint8List content, + required int size, + required String contentHash, + required int updatedAt, + this.rowid = const Value.absent(), + }) : id = Value(id), + projectId = Value(projectId), + path = Value(path), + content = Value(content), + size = Value(size), + contentHash = Value(contentHash), + updatedAt = Value(updatedAt); + static Insertable custom({ + Expression? id, + Expression? projectId, + Expression? path, + Expression? content, + Expression? size, + Expression? contentHash, + Expression? updatedAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (projectId != null) 'project_id': projectId, + if (path != null) 'path': path, + if (content != null) 'content': content, + if (size != null) 'size': size, + if (contentHash != null) 'content_hash': contentHash, + if (updatedAt != null) 'updated_at': updatedAt, + if (rowid != null) 'rowid': rowid, + }); + } + + ProjectFilesCompanion copyWith({ + Value? id, + Value? projectId, + Value? path, + Value? content, + Value? size, + Value? contentHash, + Value? updatedAt, + Value? rowid, + }) { + return ProjectFilesCompanion( + id: id ?? this.id, + projectId: projectId ?? this.projectId, + path: path ?? this.path, + content: content ?? this.content, + size: size ?? this.size, + contentHash: contentHash ?? this.contentHash, + updatedAt: updatedAt ?? this.updatedAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (projectId.present) { + map['project_id'] = Variable(projectId.value); + } + if (path.present) { + map['path'] = Variable(path.value); + } + if (content.present) { + map['content'] = Variable(content.value); + } + if (size.present) { + map['size'] = Variable(size.value); + } + if (contentHash.present) { + map['content_hash'] = Variable(contentHash.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ProjectFilesCompanion(') + ..write('id: $id, ') + ..write('projectId: $projectId, ') + ..write('path: $path, ') + ..write('content: $content, ') + ..write('size: $size, ') + ..write('contentHash: $contentHash, ') + ..write('updatedAt: $updatedAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class $SettingsTable extends Settings with TableInfo<$SettingsTable, Setting> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $SettingsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _keyMeta = const VerificationMeta('key'); + @override + late final GeneratedColumn key = GeneratedColumn( + 'key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _valueMeta = const VerificationMeta('value'); + @override + late final GeneratedColumn value = GeneratedColumn( + 'value', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + @override + List get $columns => [key, value]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'settings'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('key')) { + context.handle( + _keyMeta, + key.isAcceptableOrUnknown(data['key']!, _keyMeta), + ); + } else if (isInserting) { + context.missing(_keyMeta); + } + if (data.containsKey('value')) { + context.handle( + _valueMeta, + value.isAcceptableOrUnknown(data['value']!, _valueMeta), + ); + } else if (isInserting) { + context.missing(_valueMeta); + } + return context; + } + + @override + Set get $primaryKey => {key}; + @override + Setting map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return Setting( + key: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}key'], + )!, + value: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}value'], + )!, + ); + } + + @override + $SettingsTable createAlias(String alias) { + return $SettingsTable(attachedDatabase, alias); + } +} + +class Setting extends DataClass implements Insertable { + final String key; + final String value; + const Setting({required this.key, required this.value}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['key'] = Variable(key); + map['value'] = Variable(value); + return map; + } + + SettingsCompanion toCompanion(bool nullToAbsent) { + return SettingsCompanion(key: Value(key), value: Value(value)); + } + + factory Setting.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return Setting( + key: serializer.fromJson(json['key']), + value: serializer.fromJson(json['value']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'key': serializer.toJson(key), + 'value': serializer.toJson(value), + }; + } + + Setting copyWith({String? key, String? value}) => + Setting(key: key ?? this.key, value: value ?? this.value); + Setting copyWithCompanion(SettingsCompanion data) { + return Setting( + key: data.key.present ? data.key.value : this.key, + value: data.value.present ? data.value.value : this.value, + ); + } + + @override + String toString() { + return (StringBuffer('Setting(') + ..write('key: $key, ') + ..write('value: $value') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(key, value); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Setting && other.key == this.key && other.value == this.value); +} + +class SettingsCompanion extends UpdateCompanion { + final Value key; + final Value value; + final Value rowid; + const SettingsCompanion({ + this.key = const Value.absent(), + this.value = const Value.absent(), + this.rowid = const Value.absent(), + }); + SettingsCompanion.insert({ + required String key, + required String value, + this.rowid = const Value.absent(), + }) : key = Value(key), + value = Value(value); + static Insertable custom({ + Expression? key, + Expression? value, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (key != null) 'key': key, + if (value != null) 'value': value, + if (rowid != null) 'rowid': rowid, + }); + } + + SettingsCompanion copyWith({ + Value? key, + Value? value, + Value? rowid, + }) { + return SettingsCompanion( + key: key ?? this.key, + value: value ?? this.value, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (key.present) { + map['key'] = Variable(key.value); + } + if (value.present) { + map['value'] = Variable(value.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('SettingsCompanion(') + ..write('key: $key, ') + ..write('value: $value, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +abstract class _$DraftDatabase extends GeneratedDatabase { + _$DraftDatabase(QueryExecutor e) : super(e); + $DraftDatabaseManager get managers => $DraftDatabaseManager(this); + late final $ProjectsTable projects = $ProjectsTable(this); + late final $ProjectFilesTable projectFiles = $ProjectFilesTable(this); + late final $SettingsTable settings = $SettingsTable(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + projects, + projectFiles, + settings, + ]; +} + +typedef $$ProjectsTableCreateCompanionBuilder = + ProjectsCompanion Function({ + required String id, + required String name, + required int createdAt, + required int updatedAt, + Value rowid, + }); +typedef $$ProjectsTableUpdateCompanionBuilder = + ProjectsCompanion Function({ + Value id, + Value name, + Value createdAt, + Value updatedAt, + Value rowid, + }); + +final class $$ProjectsTableReferences + extends BaseReferences<_$DraftDatabase, $ProjectsTable, Project> { + $$ProjectsTableReferences(super.$_db, super.$_table, super.$_typedResult); + + static MultiTypedResultKey<$ProjectFilesTable, List> + _projectFilesRefsTable(_$DraftDatabase db) => MultiTypedResultKey.fromTable( + db.projectFiles, + aliasName: 'projects__id__project_files__project_id', + ); + + $$ProjectFilesTableProcessedTableManager get projectFilesRefs { + final manager = $$ProjectFilesTableTableManager( + $_db, + $_db.projectFiles, + ).filter((f) => f.projectId.id.sqlEquals($_itemColumn('id')!)); + + final cache = $_typedResult.readTableOrNull(_projectFilesRefsTable($_db)); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } +} + +class $$ProjectsTableFilterComposer + extends Composer<_$DraftDatabase, $ProjectsTable> { + $$ProjectsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get name => $composableBuilder( + column: $table.name, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => ColumnFilters(column), + ); + + Expression projectFilesRefs( + Expression Function($$ProjectFilesTableFilterComposer f) f, + ) { + final $$ProjectFilesTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.projectFiles, + getReferencedColumn: (t) => t.projectId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$ProjectFilesTableFilterComposer( + $db: $db, + $table: $db.projectFiles, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } +} + +class $$ProjectsTableOrderingComposer + extends Composer<_$DraftDatabase, $ProjectsTable> { + $$ProjectsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get name => $composableBuilder( + column: $table.name, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$ProjectsTableAnnotationComposer + extends Composer<_$DraftDatabase, $ProjectsTable> { + $$ProjectsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get name => + $composableBuilder(column: $table.name, builder: (column) => column); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + GeneratedColumn get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => column); + + Expression projectFilesRefs( + Expression Function($$ProjectFilesTableAnnotationComposer a) f, + ) { + final $$ProjectFilesTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.projectFiles, + getReferencedColumn: (t) => t.projectId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$ProjectFilesTableAnnotationComposer( + $db: $db, + $table: $db.projectFiles, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } +} + +class $$ProjectsTableTableManager + extends + RootTableManager< + _$DraftDatabase, + $ProjectsTable, + Project, + $$ProjectsTableFilterComposer, + $$ProjectsTableOrderingComposer, + $$ProjectsTableAnnotationComposer, + $$ProjectsTableCreateCompanionBuilder, + $$ProjectsTableUpdateCompanionBuilder, + (Project, $$ProjectsTableReferences), + Project, + PrefetchHooks Function({bool projectFilesRefs}) + > { + $$ProjectsTableTableManager(_$DraftDatabase db, $ProjectsTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$ProjectsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$ProjectsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$ProjectsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value name = const Value.absent(), + Value createdAt = const Value.absent(), + Value updatedAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => ProjectsCompanion( + id: id, + name: name, + createdAt: createdAt, + updatedAt: updatedAt, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + required String name, + required int createdAt, + required int updatedAt, + Value rowid = const Value.absent(), + }) => ProjectsCompanion.insert( + id: id, + name: name, + createdAt: createdAt, + updatedAt: updatedAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map( + (e) => ( + e.readTable<$ProjectsTable, Project>(table), + $$ProjectsTableReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: ({projectFilesRefs = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [if (projectFilesRefs) db.projectFiles], + addJoins: null, + getPrefetchedDataCallback: (items) async { + return [ + if (projectFilesRefs) + await $_getPrefetchedDataByColumns< + Project, + $ProjectsTable, + ProjectFile, + $ProjectFilesTable + >( + currentTable: table, + referencedTable: $$ProjectsTableReferences + ._projectFilesRefsTable(db), + managerFromTypedResult: (p0) => $$ProjectsTableReferences( + db, + table, + p0, + ).projectFilesRefs, + currentColumn: 'id', + referencedColumn: 'project_id', + typedResults: items, + ), + ]; + }, + ); + }, + ), + ); +} + +typedef $$ProjectsTableProcessedTableManager = + ProcessedTableManager< + _$DraftDatabase, + $ProjectsTable, + Project, + $$ProjectsTableFilterComposer, + $$ProjectsTableOrderingComposer, + $$ProjectsTableAnnotationComposer, + $$ProjectsTableCreateCompanionBuilder, + $$ProjectsTableUpdateCompanionBuilder, + (Project, $$ProjectsTableReferences), + Project, + PrefetchHooks Function({bool projectFilesRefs}) + >; +typedef $$ProjectFilesTableCreateCompanionBuilder = + ProjectFilesCompanion Function({ + required String id, + required String projectId, + required String path, + required Uint8List content, + required int size, + required String contentHash, + required int updatedAt, + Value rowid, + }); +typedef $$ProjectFilesTableUpdateCompanionBuilder = + ProjectFilesCompanion Function({ + Value id, + Value projectId, + Value path, + Value content, + Value size, + Value contentHash, + Value updatedAt, + Value rowid, + }); + +final class $$ProjectFilesTableReferences + extends BaseReferences<_$DraftDatabase, $ProjectFilesTable, ProjectFile> { + $$ProjectFilesTableReferences(super.$_db, super.$_table, super.$_typedResult); + + static $ProjectsTable _projectIdTable(_$DraftDatabase db) => + db.projects.createAlias('project_files__project_id__projects__id'); + + $$ProjectsTableProcessedTableManager get projectId { + final $_column = $_itemColumn('project_id')!; + + final manager = $$ProjectsTableTableManager( + $_db, + $_db.projects, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_projectIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } +} + +class $$ProjectFilesTableFilterComposer + extends Composer<_$DraftDatabase, $ProjectFilesTable> { + $$ProjectFilesTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get path => $composableBuilder( + column: $table.path, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get content => $composableBuilder( + column: $table.content, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get size => $composableBuilder( + column: $table.size, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get contentHash => $composableBuilder( + column: $table.contentHash, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => ColumnFilters(column), + ); + + $$ProjectsTableFilterComposer get projectId { + final $$ProjectsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.projectId, + referencedTable: $db.projects, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$ProjectsTableFilterComposer( + $db: $db, + $table: $db.projects, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$ProjectFilesTableOrderingComposer + extends Composer<_$DraftDatabase, $ProjectFilesTable> { + $$ProjectFilesTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get path => $composableBuilder( + column: $table.path, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get content => $composableBuilder( + column: $table.content, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get size => $composableBuilder( + column: $table.size, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get contentHash => $composableBuilder( + column: $table.contentHash, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => ColumnOrderings(column), + ); + + $$ProjectsTableOrderingComposer get projectId { + final $$ProjectsTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.projectId, + referencedTable: $db.projects, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$ProjectsTableOrderingComposer( + $db: $db, + $table: $db.projects, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$ProjectFilesTableAnnotationComposer + extends Composer<_$DraftDatabase, $ProjectFilesTable> { + $$ProjectFilesTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get path => + $composableBuilder(column: $table.path, builder: (column) => column); + + GeneratedColumn get content => + $composableBuilder(column: $table.content, builder: (column) => column); + + GeneratedColumn get size => + $composableBuilder(column: $table.size, builder: (column) => column); + + GeneratedColumn get contentHash => $composableBuilder( + column: $table.contentHash, + builder: (column) => column, + ); + + GeneratedColumn get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => column); + + $$ProjectsTableAnnotationComposer get projectId { + final $$ProjectsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.projectId, + referencedTable: $db.projects, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$ProjectsTableAnnotationComposer( + $db: $db, + $table: $db.projects, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$ProjectFilesTableTableManager + extends + RootTableManager< + _$DraftDatabase, + $ProjectFilesTable, + ProjectFile, + $$ProjectFilesTableFilterComposer, + $$ProjectFilesTableOrderingComposer, + $$ProjectFilesTableAnnotationComposer, + $$ProjectFilesTableCreateCompanionBuilder, + $$ProjectFilesTableUpdateCompanionBuilder, + (ProjectFile, $$ProjectFilesTableReferences), + ProjectFile, + PrefetchHooks Function({bool projectId}) + > { + $$ProjectFilesTableTableManager(_$DraftDatabase db, $ProjectFilesTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$ProjectFilesTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$ProjectFilesTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$ProjectFilesTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value projectId = const Value.absent(), + Value path = const Value.absent(), + Value content = const Value.absent(), + Value size = const Value.absent(), + Value contentHash = const Value.absent(), + Value updatedAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => ProjectFilesCompanion( + id: id, + projectId: projectId, + path: path, + content: content, + size: size, + contentHash: contentHash, + updatedAt: updatedAt, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + required String projectId, + required String path, + required Uint8List content, + required int size, + required String contentHash, + required int updatedAt, + Value rowid = const Value.absent(), + }) => ProjectFilesCompanion.insert( + id: id, + projectId: projectId, + path: path, + content: content, + size: size, + contentHash: contentHash, + updatedAt: updatedAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map( + (e) => ( + e.readTable<$ProjectFilesTable, ProjectFile>(table), + $$ProjectFilesTableReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: ({projectId = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [], + addJoins: + < + T extends TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (projectId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.projectId, + referencedTable: $$ProjectFilesTableReferences + ._projectIdTable(db), + referencedColumn: $$ProjectFilesTableReferences + ._projectIdTable(db) + .id, + ) + as T; + } + + return state; + }, + getPrefetchedDataCallback: (items) async { + return []; + }, + ); + }, + ), + ); +} + +typedef $$ProjectFilesTableProcessedTableManager = + ProcessedTableManager< + _$DraftDatabase, + $ProjectFilesTable, + ProjectFile, + $$ProjectFilesTableFilterComposer, + $$ProjectFilesTableOrderingComposer, + $$ProjectFilesTableAnnotationComposer, + $$ProjectFilesTableCreateCompanionBuilder, + $$ProjectFilesTableUpdateCompanionBuilder, + (ProjectFile, $$ProjectFilesTableReferences), + ProjectFile, + PrefetchHooks Function({bool projectId}) + >; +typedef $$SettingsTableCreateCompanionBuilder = + SettingsCompanion Function({ + required String key, + required String value, + Value rowid, + }); +typedef $$SettingsTableUpdateCompanionBuilder = + SettingsCompanion Function({ + Value key, + Value value, + Value rowid, + }); + +class $$SettingsTableFilterComposer + extends Composer<_$DraftDatabase, $SettingsTable> { + $$SettingsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get key => $composableBuilder( + column: $table.key, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get value => $composableBuilder( + column: $table.value, + builder: (column) => ColumnFilters(column), + ); +} + +class $$SettingsTableOrderingComposer + extends Composer<_$DraftDatabase, $SettingsTable> { + $$SettingsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get key => $composableBuilder( + column: $table.key, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get value => $composableBuilder( + column: $table.value, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$SettingsTableAnnotationComposer + extends Composer<_$DraftDatabase, $SettingsTable> { + $$SettingsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get key => + $composableBuilder(column: $table.key, builder: (column) => column); + + GeneratedColumn get value => + $composableBuilder(column: $table.value, builder: (column) => column); +} + +class $$SettingsTableTableManager + extends + RootTableManager< + _$DraftDatabase, + $SettingsTable, + Setting, + $$SettingsTableFilterComposer, + $$SettingsTableOrderingComposer, + $$SettingsTableAnnotationComposer, + $$SettingsTableCreateCompanionBuilder, + $$SettingsTableUpdateCompanionBuilder, + (Setting, BaseReferences<_$DraftDatabase, $SettingsTable, Setting>), + Setting, + PrefetchHooks Function() + > { + $$SettingsTableTableManager(_$DraftDatabase db, $SettingsTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$SettingsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$SettingsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$SettingsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value key = const Value.absent(), + Value value = const Value.absent(), + Value rowid = const Value.absent(), + }) => SettingsCompanion(key: key, value: value, rowid: rowid), + createCompanionCallback: + ({ + required String key, + required String value, + Value rowid = const Value.absent(), + }) => SettingsCompanion.insert( + key: key, + value: value, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map( + (e) => ( + e.readTable<$SettingsTable, Setting>(table), + BaseReferences<_$DraftDatabase, $SettingsTable, Setting>( + db, + table, + e, + ), + ), + ) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$SettingsTableProcessedTableManager = + ProcessedTableManager< + _$DraftDatabase, + $SettingsTable, + Setting, + $$SettingsTableFilterComposer, + $$SettingsTableOrderingComposer, + $$SettingsTableAnnotationComposer, + $$SettingsTableCreateCompanionBuilder, + $$SettingsTableUpdateCompanionBuilder, + (Setting, BaseReferences<_$DraftDatabase, $SettingsTable, Setting>), + Setting, + PrefetchHooks Function() + >; + +class $DraftDatabaseManager { + final _$DraftDatabase _db; + $DraftDatabaseManager(this._db); + $$ProjectsTableTableManager get projects => + $$ProjectsTableTableManager(_db, _db.projects); + $$ProjectFilesTableTableManager get projectFiles => + $$ProjectFilesTableTableManager(_db, _db.projectFiles); + $$SettingsTableTableManager get settings => + $$SettingsTableTableManager(_db, _db.settings); +} diff --git a/app/lib/data/draft_repository.dart b/app/lib/data/draft_repository.dart new file mode 100644 index 00000000..407863e4 --- /dev/null +++ b/app/lib/data/draft_repository.dart @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MIT +// Part of PyBLE (https://pyble.dev) — see /LICENSE. + +/// Neutral local current-text recovery seam (ADR-0048). +library; + +/// One complete, immutable local checkpoint. Origin is historical context, +/// never authority to write to a board. +class DraftSnapshot { + const DraftSnapshot({ + required this.name, + required this.content, + this.originPath, + required this.generation, + required this.checkpointTime, + }); + + final String name; + final String content; + final String? originPath; + final int generation; + final DateTime checkpointTime; +} + +/// A committed slot, including a high-water generation for an empty tombstone. +class DraftRead { + const DraftRead({required this.generation, this.snapshot}); + final int generation; + final DraftSnapshot? snapshot; +} + +/// Bounded reasons suitable for localized UI; never includes user source/path. +enum DraftStorageFailure { + unavailable, + corrupt, + unsupportedVersion, + invalidMetadata, + invalidText, + tooLarge, + conflict, +} + +class DraftStorageException implements Exception { + const DraftStorageException(this.reason); + final DraftStorageFailure reason; + + @override + String toString() => 'DraftStorageException(${reason.name})'; +} + +/// Sole serialized writer for one local slot. A successful Future means that +/// its matching transaction committed, not that a board was contacted. +abstract interface class DraftRepository { + Future load(); + Future write(DraftSnapshot snapshot, {required int expectedGeneration}); + Future clear({required int expectedGeneration}); + Future close(); +} diff --git a/app/lib/data/draft_repository_factory.dart b/app/lib/data/draft_repository_factory.dart new file mode 100644 index 00000000..7dbf2749 --- /dev/null +++ b/app/lib/data/draft_repository_factory.dart @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +// Part of PyBLE (https://pyble.dev) — see /LICENSE. + +import 'dart:io'; + +import 'package:path_provider/path_provider.dart'; + +import 'draft_repository.dart'; +import 'drift_draft_repository.dart'; + +/// App-private Application Support, never temporary/cache/external storage. +/// Directory discovery is a platform-channel operation; SQLite and its source +/// validation/hash work run off the UI isolate. The caller handles failure +/// through the same preserved-store startup choices as a later I/O error. +Future openDraftRepository() async { + try { + final Directory directory = await getApplicationSupportDirectory(); + await directory.create(recursive: true); + return await DriftDraftRepository.open( + File('${directory.path}/pyble.sqlite'), + ); + } on DraftStorageException { + rethrow; + } catch (_) { + throw const DraftStorageException(DraftStorageFailure.unavailable); + } +} diff --git a/app/lib/data/drift_draft_repository.dart b/app/lib/data/drift_draft_repository.dart new file mode 100644 index 00000000..3095c196 --- /dev/null +++ b/app/lib/data/drift_draft_repository.dart @@ -0,0 +1,422 @@ +// SPDX-License-Identifier: MIT +// Part of PyBLE (https://pyble.dev) — see /LICENSE. + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:isolate'; + +import 'package:crypto/crypto.dart'; +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:drift/isolate.dart' show DriftRemoteException; + +import 'draft_database.dart'; +import 'draft_repository.dart'; + +const int maxDraftBytes = 1024 * 1024; +const String _slotKey = 'current_text_draft'; +const String _projectId = 'pyble.internal.current_text'; +const String _fileId = 'pyble.internal.current_text.file'; + +/// Drift-backed, app-private slot. Every database statement runs on the owned +/// native background executor; large UTF-8/hash work runs in another isolate. +/// No method deletes, repairs, truncates or recreates an existing store. +class DriftDraftRepository implements DraftRepository { + DriftDraftRepository._(this._database); + + /// Test seam for real SQLite fault injection (read-only/full/abort). Runtime + /// composition always uses [open]'s owned background executor and pragmas. + DriftDraftRepository.forTesting(QueryExecutor executor) + : _database = DraftDatabase(executor); + + final DraftDatabase _database; + Future _tail = Future.value(); + bool _closed = false; + bool _accepting = true; + Future? _closeFuture; + + /// Opens one file-backed connection. The existing-file check is deliberately + /// outside SQLite's schema handling: a version-zero existing database is + /// unreadable state, never permission to recreate user storage. + static Future open(File file) async { + final bool existed = await file.exists(); + final DraftDatabase database = DraftDatabase( + NativeDatabase.createInBackground( + file, + setup: (db) { + final int version = + db.select('PRAGMA user_version').single.values.single as int; + if (version > 1) { + throw const DraftStorageException( + DraftStorageFailure.unsupportedVersion, + ); + } + if (existed && version == 0) { + throw const DraftStorageException(DraftStorageFailure.corrupt); + } + db.execute('PRAGMA busy_timeout = 5000'); + db.execute('PRAGMA foreign_keys = ON'); + final Object? mode = db + .select('PRAGMA journal_mode = WAL') + .single + .values + .single; + db.execute('PRAGMA synchronous = FULL'); + final Object? sync = db + .select('PRAGMA synchronous') + .single + .values + .single; + if (mode != 'wal' || sync != 2) { + throw const DraftStorageException(DraftStorageFailure.unavailable); + } + }, + ), + ); + final DriftDraftRepository repository = DriftDraftRepository._(database); + try { + await repository.load(); + return repository; + } catch (_) { + await database.close(); + rethrow; + } + } + + Future _serialized(Future Function() operation) { + if (!_accepting) { + return Future.error( + const DraftStorageException(DraftStorageFailure.unavailable), + ); + } + final Completer result = Completer(); + _tail = _tail.then((_) async { + try { + if (_closed) { + throw const DraftStorageException(DraftStorageFailure.unavailable); + } + result.complete(await operation()); + } catch (error, stack) { + result.completeError(_safeException(error), stack); + } + }); + return result.future; + } + + @override + Future load() => _serialized(() => _database.transaction(_read)); + + Future _read() async { + // Bound untrusted SQLite values on the database isolate before sending + // strings to the UI isolate or decoding their JSON. This also keeps a + // corrupt-but-readable database from monopolizing startup's timer. + final QueryRow settingsBound = await _database + .customSelect( + 'SELECT COUNT(*) AS row_count, COALESCE(MAX(LENGTH(CAST(value AS BLOB))), 0) AS max_bytes FROM settings WHERE key = ?', + variables: >[const Variable(_slotKey)], + ) + .getSingle(); + if (settingsBound.read('row_count') != 1 || + settingsBound.read('max_bytes') > 4096) { + throw const DraftStorageException(DraftStorageFailure.corrupt); + } + final List settings = await (_database.select( + _database.settings, + )..where((Settings t) => t.key.equals(_slotKey))).get(); + if (settings.length != 1) { + throw const DraftStorageException(DraftStorageFailure.corrupt); + } + final Map metadata; + try { + metadata = (jsonDecode(settings.single.value) as Map) + .cast(); + } catch (_) { + throw const DraftStorageException(DraftStorageFailure.corrupt); + } + if (metadata['version'] is! int || (metadata['version'] as int) < 1) { + throw const DraftStorageException(DraftStorageFailure.corrupt); + } + if (metadata['version'] != 1) { + throw const DraftStorageException(DraftStorageFailure.unsupportedVersion); + } + final Object? generation = metadata['generation']; + if (generation is! int || + generation < 0 || + generation > 0x1fffffffffffff || + metadata['empty'] is! bool) { + throw const DraftStorageException(DraftStorageFailure.corrupt); + } + final QueryRow bound = await _database + .customSelect( + 'SELECT COUNT(*) AS row_count, COALESCE(MAX(LENGTH(CAST(content AS BLOB))), 0) AS max_bytes, ' + 'COALESCE(MAX(MAX(LENGTH(CAST(id AS BLOB)), LENGTH(CAST(project_id AS BLOB)), ' + 'LENGTH(CAST(path AS BLOB)), LENGTH(CAST(content_hash AS BLOB)))), 0) AS metadata_bytes, ' + "COALESCE(MIN(TYPEOF(size) = 'integer' AND TYPEOF(updated_at) = 'integer'), 1) AS valid_numbers FROM project_files", + ) + .getSingle(); + if (bound.read('row_count') > 1 || + bound.read('max_bytes') > maxDraftBytes || + bound.read('metadata_bytes') > 128 || + bound.read('valid_numbers') != 1) { + throw const DraftStorageException(DraftStorageFailure.corrupt); + } + final QueryRow projectsBound = await _database + .customSelect( + 'SELECT COUNT(*) AS row_count, COALESCE(MAX(MAX(LENGTH(CAST(id AS BLOB)), ' + 'LENGTH(CAST(name AS BLOB)))), 0) AS max_bytes, ' + "COALESCE(MIN(TYPEOF(created_at) = 'integer' AND TYPEOF(updated_at) = 'integer'), 1) AS valid_numbers FROM projects", + ) + .getSingle(); + if (projectsBound.read('row_count') > 1 || + projectsBound.read('max_bytes') > 128 || + projectsBound.read('valid_numbers') != 1) { + throw const DraftStorageException(DraftStorageFailure.corrupt); + } + final List files = await _database + .select(_database.projectFiles) + .get(); + final List projects = await _database + .select(_database.projects) + .get(); + if (metadata['empty'] == true) { + if (files.isNotEmpty || projects.isNotEmpty) { + throw const DraftStorageException(DraftStorageFailure.corrupt); + } + return DraftRead(generation: generation); + } + if (files.length != 1 || projects.length != 1) { + throw const DraftStorageException(DraftStorageFailure.corrupt); + } + final ProjectFile file = files.single; + final Project project = projects.single; + if (file.id != _fileId || + file.projectId != _projectId || + project.id != _projectId || + metadata['documentId'] != _fileId || + metadata['name'] != file.path || + metadata['size'] != file.size || + metadata['hash'] != file.contentHash || + metadata['time'] != file.updatedAt || + metadata['time'] is! int) { + throw const DraftStorageException(DraftStorageFailure.corrupt); + } + // Isolate the potentially megabyte-sized UTF-8 conversion, validation and + // SHA-256. No source is passed to loggers or platform channels. + final Uint8List bytes = file.content; + final String name = file.path; + final String hash = file.contentHash; + final Object? origin = metadata['originPath']; + final int time = file.updatedAt; + return _decodeInBackground( + bytes, + name, + hash, + origin, + time, + generation, + file.size, + ); + } + + @override + Future write( + DraftSnapshot snapshot, { + required int expectedGeneration, + }) => _serialized(() async { + _checkNext(snapshot.generation, expectedGeneration); + final _Prepared prepared = await _prepareInBackground(snapshot); + await _database.transaction(() async { + final DraftRead current = await _read(); + if (current.generation != expectedGeneration) { + throw const DraftStorageException(DraftStorageFailure.conflict); + } + final int time = snapshot.checkpointTime.toUtc().millisecondsSinceEpoch; + await _database + .into(_database.projects) + .insertOnConflictUpdate( + ProjectsCompanion.insert( + id: _projectId, + name: _projectId, + createdAt: time, + updatedAt: time, + ), + ); + await _database + .into(_database.projectFiles) + .insertOnConflictUpdate( + ProjectFilesCompanion.insert( + id: _fileId, + projectId: _projectId, + path: snapshot.name, + content: prepared.bytes, + size: prepared.bytes.length, + contentHash: prepared.hash, + updatedAt: time, + ), + ); + await _storeMetadata({ + 'version': 1, + 'generation': snapshot.generation, + 'empty': false, + 'documentId': _fileId, + 'name': snapshot.name, + 'size': prepared.bytes.length, + 'hash': prepared.hash, + 'time': time, + 'originPath': snapshot.originPath, + }); + }); + }); + + @override + Future clear({required int expectedGeneration}) => _serialized(() async { + final int next = expectedGeneration + 1; + _checkNext(next, expectedGeneration); + return _database.transaction(() async { + if ((await _read()).generation != expectedGeneration) { + throw const DraftStorageException(DraftStorageFailure.conflict); + } + await _database.delete(_database.projectFiles).go(); + await _database.delete(_database.projects).go(); + await _storeMetadata({ + 'version': 1, + 'generation': next, + 'empty': true, + }); + return next; + }); + }); + + Future _storeMetadata(Map value) => _database + .into(_database.settings) + .insertOnConflictUpdate( + SettingsCompanion.insert(key: _slotKey, value: jsonEncode(value)), + ) + .then((_) {}); + + static void _checkNext(int next, int expected) { + if (expected < 0 || next <= expected || next > 0x1fffffffffffff) { + throw const DraftStorageException(DraftStorageFailure.conflict); + } + } + + @override + Future close() => _closeFuture ??= _close(); + + Future _close() async { + _accepting = false; + await _tail; + if (_closed) return; + _closed = true; + await _database.close(); + } +} + +class _Prepared { + const _Prepared(this.bytes, this.hash); + final Uint8List bytes; + final String hash; +} + +_Prepared _prepare(DraftSnapshot snapshot) { + _validateName(snapshot.name); + final String? origin = snapshot.originPath; + if (origin != null) { + if (!origin.startsWith('/') || + origin == '/' || + origin.contains('\\') || + origin + .split('/') + .skip(1) + .any((String s) => s.isEmpty || s == '.' || s == '..') || + _hasControls(origin) || + !_wellFormed(origin) || + utf8.encode(origin).length > 128) { + throw const DraftStorageException(DraftStorageFailure.invalidMetadata); + } + } + if (!_wellFormed(snapshot.content)) { + throw const DraftStorageException(DraftStorageFailure.invalidText); + } + final Uint8List bytes = utf8.encode(snapshot.content); + if (bytes.length > maxDraftBytes) { + throw const DraftStorageException(DraftStorageFailure.tooLarge); + } + return _Prepared(bytes, sha256.convert(bytes).toString()); +} + +void _validateName(String name) { + if (name.isEmpty || + name == '.' || + name == '..' || + name.contains('/') || + name.contains('\\') || + _hasControls(name) || + !_wellFormed(name) || + utf8.encode(name).length > 128) { + throw const DraftStorageException(DraftStorageFailure.invalidMetadata); + } +} + +bool _hasControls(String value) => + value.runes.any((int c) => c < 0x20 || (c >= 0x7f && c <= 0x9f)); + +bool _wellFormed(String value) { + for (int i = 0; i < value.length; i++) { + final int unit = value.codeUnitAt(i); + if (unit >= 0xd800 && unit <= 0xdbff) { + if (++i >= value.length) return false; + final int next = value.codeUnitAt(i); + if (next < 0xdc00 || next > 0xdfff) return false; + } else if (unit >= 0xdc00 && unit <= 0xdfff) { + return false; + } + } + return true; +} + +Future<_Prepared> _prepareInBackground(DraftSnapshot snapshot) => + Isolate.run(() => _prepare(snapshot)); + +Future _decodeInBackground( + Uint8List bytes, + String name, + String hash, + Object? origin, + int time, + int generation, + int expectedSize, +) => Isolate.run(() { + try { + if (origin != null && origin is! String) { + throw const DraftStorageException(DraftStorageFailure.corrupt); + } + final String content = utf8.decode(bytes, allowMalformed: false); + final DraftSnapshot snapshot = DraftSnapshot( + name: name, + content: content, + originPath: origin as String?, + generation: generation, + checkpointTime: DateTime.fromMillisecondsSinceEpoch(time, isUtc: true), + ); + final _Prepared prepared = _prepare(snapshot); + if (bytes.length != expectedSize || + prepared.hash != hash || + prepared.bytes.length != bytes.length) { + throw const DraftStorageException(DraftStorageFailure.corrupt); + } + return DraftRead(generation: generation, snapshot: snapshot); + } catch (_) { + throw const DraftStorageException(DraftStorageFailure.corrupt); + } +}); + +DraftStorageException _safeException(Object error) { + if (error is DriftRemoteException) return _safeException(error.remoteCause); + if (error is DraftStorageException) return error; + if (error is SqliteException && {11, 26}.contains(error.resultCode)) { + return const DraftStorageException(DraftStorageFailure.corrupt); + } + return const DraftStorageException(DraftStorageFailure.unavailable); +} diff --git a/app/lib/editor/board_target_guard.dart b/app/lib/editor/board_target_guard.dart new file mode 100644 index 00000000..d505df15 --- /dev/null +++ b/app/lib/editor/board_target_guard.dart @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: MIT +// Part of PyBLE (https://pyble.dev) — see /LICENSE. + +/// Local recovery consent and replacement seams. No transport implementation. +library; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:pyble/app/providers.dart'; +import 'package:pyble/pble/pble.dart'; +import 'draft_recovery.dart'; +import 'editor_document.dart'; + +class BoardTargetRequest { + const BoardTargetRequest({ + required this.path, + required this.run, + required this.docName, + }); + final String path; + final bool run; + final String docName; +} + +/// Production injects ARB-backed dialogs through the root navigator. Missing +/// confirmation fails closed; controller tests can provide a recording choice. +final boardTargetConfirmationProvider = + Provider Function(BoardTargetRequest)?>((ref) => null); +final editorReplacementConfirmationProvider = + Provider Function()?>((ref) => null); + +final editorVolatileReplacementConfirmationProvider = + Provider Function()?>((ref) => null); + +class BoardActionCancelled implements Exception { + const BoardActionCancelled(); +} + +/// A recovered path is historical context, never permission to overwrite a +/// board. Every explicit action is confirmed against its current destination. +Future confirmBoardTarget( + Ref ref, { + required String path, + required bool run, +}) async { + if (!ref.read(editorRecoveredDraftProvider)) return true; + final int epoch = ref.read(editorDocumentEpochProvider); + final connection = ref.read(connectionProvider); + final stamp = connectionSessionStampOf(connection); + final confirm = ref.read(boardTargetConfirmationProvider); + if (confirm == null) return false; + final accepted = await confirm( + BoardTargetRequest( + path: path, + run: run, + docName: ref.read(editorDocumentProvider).name, + ), + ); + return accepted && + ref.read(editorDocumentEpochProvider) == epoch && + identical(ref.read(connectionProvider), connection) && + identical(connectionSessionStampOf(connection), stamp); +} + +Future confirmEditorReplacement( + Ref ref, { + required int expectedEpoch, +}) async { + if (ref.read(editorDocumentEpochProvider) != expectedEpoch) return false; + final recovery = ref.read(draftRecoveryProvider); + if (recovery.status == DraftRecoveryStatus.disabled) return true; + final document = ref.read(editorDocumentProvider); + final meaningful = + document.content.isNotEmpty || + document.name != kDefaultDocumentName || + document.boardPath != null; + final needsChoice = + recovery.status == DraftRecoveryStatus.decision || + (meaningful && + (document.dirty || recovery.status != DraftRecoveryStatus.saved)); + if (!needsChoice) return true; + final confirm = ref.read(editorReplacementConfirmationProvider); + if (confirm == null || !await confirm()) return false; + return ref.read(editorDocumentEpochProvider) == expectedEpoch; +} + +/// Preserve the live document on failure; volatile replacement needs its own +/// explicit decision bound to the same document and board session. +Future replaceEditorDocument( + Ref ref, + EditorDocument replacement, { + required int expectedEpoch, + bool Function()? isStillValid, +}) async { + bool valid() => + ref.read(editorDocumentEpochProvider) == expectedEpoch && + (isStillValid?.call() ?? true); + if (!valid()) return false; + final controller = ref.read(draftRecoveryProvider.notifier); + final replaced = await controller.replaceDocument( + replacement, + expectedEpoch: expectedEpoch, + allowVolatile: + ref.read(draftRecoveryProvider).status == DraftRecoveryStatus.volatile, + isStillValid: isStillValid, + ); + if (replaced || + !valid() || + ref.read(draftRecoveryProvider).status != DraftRecoveryStatus.failed) { + return replaced; + } + final confirm = ref.read(editorVolatileReplacementConfirmationProvider); + if (confirm == null || !await confirm() || !valid()) return false; + controller.continueWithoutRecovery(); + return controller.replaceDocument( + replacement, + expectedEpoch: expectedEpoch, + allowVolatile: true, + isStillValid: isStillValid, + ); +} diff --git a/app/lib/editor/draft_recovery.dart b/app/lib/editor/draft_recovery.dart new file mode 100644 index 00000000..e2065552 --- /dev/null +++ b/app/lib/editor/draft_recovery.dart @@ -0,0 +1,598 @@ +// SPDX-License-Identifier: MIT +// Part of PyBLE (https://pyble.dev) — see /LICENSE. + +/// One local current-text recovery slot. Disk state and board dirty state are +/// independent; only a successful transaction acknowledges local protection. +library; + +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:pyble/data/draft_repository.dart'; + +import 'editor_document.dart'; + +/// Production supplies a factory; ordinary provider harnesses remain disk-free. +final draftRepositoryFactoryProvider = + Provider Function()?>((ref) => null); + +/// Injectable already-open repository for focused storage/controller harnesses. +final draftRepositoryProvider = Provider((ref) => null); + +enum DraftRecoveryStatus { + disabled, + loading, + decision, + pending, + saving, + saved, + failed, + volatile, +} + +class DraftRecoveryState { + const DraftRecoveryState({ + required this.status, + this.candidate, + this.failure, + this.timedOut = false, + this.hasResolvedStartup = false, + this.busy = false, + this.checkpointTime, + }); + + final DraftRecoveryStatus status; + final DraftSnapshot? candidate; + final DraftStorageFailure? failure; + final bool timedOut; + final bool hasResolvedStartup; + final bool busy; + final DateTime? checkpointTime; + + bool get enabled => status != DraftRecoveryStatus.disabled; + bool get blocksStartup => + enabled && + (status == DraftRecoveryStatus.loading || + status == DraftRecoveryStatus.decision || + (!hasResolvedStartup && status == DraftRecoveryStatus.failed)); +} + +final draftRecoveryProvider = + NotifierProvider( + DraftRecoveryController.new, + ); + +class _Checkpoint { + _Checkpoint(this.document, this.epoch); + final EditorDocument document; + final int epoch; + final Completer completion = Completer(); +} + +class DraftRecoveryController extends Notifier { + DraftRepository? _repository; + Future? _opening; + Future? _loading; + Future? _startup; + Future? _active; + Completer? _replacementDone; + _Checkpoint? _queued; + Timer? _idle; + Timer? _maximum; + Timer? _startupTimer; + DateTime? _lastCheckpointTime; + int _operation = 0; + int _generation = 0; + int _decisionEpoch = 0; + int _lastCommittedEpoch = -1; + bool _started = false; + bool _writable = false; + bool _suppress = false; + bool _replacement = false; + bool _disposed = false; + + @override + DraftRecoveryState build() { + _repository = ref.read(draftRepositoryProvider); + final bool ownsRepository = _repository == null; + final bool enabled = + _repository != null || ref.read(draftRepositoryFactoryProvider) != null; + ref.listen(editorDocumentEpochProvider, (previous, next) { + if (previous != next) _changed(); + }); + ref.onDispose(() { + _disposed = true; + _cancelTimers(); + _startupTimer?.cancel(); + _queued?.completion.complete(false); + _queued = null; + // The application owns an injected repository. Only a factory-created + // connection is ours, and it closes after already-running work settles. + if (ownsRepository) { + final Future settled = Future.wait([ + ?_active, + ?_startup, + ?_replacementDone?.future, + ]).then((_) {}); + unawaited( + settled + .then((_) async { + await _repository?.close(); + }) + .catchError((Object _) {}), + ); + } + }); + return DraftRecoveryState( + status: enabled + ? DraftRecoveryStatus.loading + : DraftRecoveryStatus.disabled, + hasResolvedStartup: !enabled, + ); + } + + Future _open() async { + if (_repository != null) return _repository!; + final factory = ref.read(draftRepositoryFactoryProvider); + if (factory == null) { + throw const DraftStorageException(DraftStorageFailure.unavailable); + } + final Future opening = _opening ??= factory(); + try { + return _repository = await opening; + } catch (_) { + if (identical(_opening, opening)) _opening = null; + rethrow; + } + } + + Future start() { + if (!state.enabled) return Future.value(); + if (_started) return _startup ?? Future.value(); + _started = true; + return _startup = _read(); + } + + Future retry() { + if (!state.enabled) return Future.value(); + // A failed checkpoint retries the current text. Startup/volatile retry + // reads and previews the preserved slot before any replacement decision. + if (state.hasResolvedStartup && + state.status != DraftRecoveryStatus.volatile && + state.candidate == null && + _writable) { + return saveRecoveryCopy().then((_) {}); + } + _started = true; + return _startup = _read(); + } + + Future _read() async { + final int operation = ++_operation; + final int epoch = ref.read(editorDocumentEpochProvider); + _cancelTimers(); + _cancelQueued(); + _writable = false; + state = DraftRecoveryState( + checkpointTime: _lastCheckpointTime, + status: DraftRecoveryStatus.loading, + ); + _startupTimer?.cancel(); + _startupTimer = Timer(const Duration(seconds: 10), () { + if (_valid(operation)) { + state = DraftRecoveryState( + checkpointTime: _lastCheckpointTime, + status: DraftRecoveryStatus.failed, + timedOut: true, + failure: DraftStorageFailure.unavailable, + ); + } + }); + // Reuse a pending open/read rather than opening the database underneath it. + final Future loading = _loading ??= _loadAfterWriter(); + try { + final DraftRead result = await loading; + if (!_valid(operation)) return; + _generation = result.generation; + _lastCheckpointTime = result.snapshot?.checkpointTime; + _decisionEpoch = ref.read(editorDocumentEpochProvider); + if (result.snapshot != null) { + state = DraftRecoveryState( + checkpointTime: _lastCheckpointTime, + status: DraftRecoveryStatus.decision, + candidate: result.snapshot, + ); + } else { + _writable = true; + final bool changed = + epoch != _decisionEpoch || + ref.read(editorDocumentProvider) != const EditorDocument.untitled(); + _lastCommittedEpoch = changed ? -1 : _decisionEpoch; + state = DraftRecoveryState( + checkpointTime: _lastCheckpointTime, + status: changed + ? DraftRecoveryStatus.pending + : DraftRecoveryStatus.saved, + hasResolvedStartup: true, + ); + if (changed) _changed(); + } + } catch (error) { + if (_valid(operation)) { + state = DraftRecoveryState( + checkpointTime: _lastCheckpointTime, + status: DraftRecoveryStatus.failed, + failure: _failure(error), + ); + } + } finally { + if (identical(_loading, loading)) _loading = null; + if (_valid(operation)) _startupTimer?.cancel(); + } + } + + Future _loadAfterWriter() async { + await _replacementDone?.future; + await _active; + return (await _open()).load(); + } + + bool _valid(int operation) => !_disposed && operation == _operation; + + void continueWithoutRecovery() { + if (!state.enabled) return; + ++_operation; + _startupTimer?.cancel(); + _cancelTimers(); + _cancelQueued(); + _writable = false; + state = DraftRecoveryState( + checkpointTime: _lastCheckpointTime, + status: DraftRecoveryStatus.volatile, + candidate: state.candidate, + failure: state.failure, + hasResolvedStartup: true, + ); + } + + bool restore() { + final DraftSnapshot? snapshot = state.candidate; + if (snapshot == null || + state.status != DraftRecoveryStatus.decision || + state.busy || + ref.read(editorDocumentEpochProvider) != _decisionEpoch) { + return false; + } + _suppress = true; + try { + ref + .read(editorDocumentProvider.notifier) + .restoreDraft(name: snapshot.name, content: snapshot.content); + } finally { + _suppress = false; + } + _writable = true; + _lastCommittedEpoch = ref.read(editorDocumentEpochProvider); + state = DraftRecoveryState( + checkpointTime: _lastCheckpointTime, + status: DraftRecoveryStatus.saved, + hasResolvedStartup: true, + ); + return true; + } + + Future discard() async { + if (state.candidate == null || + state.busy || + state.status != DraftRecoveryStatus.decision || + ref.read(editorDocumentEpochProvider) != _decisionEpoch) { + return false; + } + final DraftSnapshot candidate = state.candidate!; + final int operation = _operation; + final int epoch = _decisionEpoch; + bool cleared = false; + _replacement = true; + _replacementDone = Completer(); + _cancelTimers(); + _cancelQueued(); + state = DraftRecoveryState( + checkpointTime: _lastCheckpointTime, + status: DraftRecoveryStatus.decision, + candidate: candidate, + busy: true, + ); + try { + await _active; + if (!_valid(operation) || + ref.read(editorDocumentEpochProvider) != epoch) { + return false; + } + _generation = await (await _open()).clear( + expectedGeneration: _generation, + ); + cleared = true; + _lastCheckpointTime = null; + if (!_valid(operation) || + ref.read(editorDocumentEpochProvider) != epoch) { + return false; + } + _adopt(const EditorDocument.untitled()); + _writable = true; + _lastCommittedEpoch = ref.read(editorDocumentEpochProvider); + state = DraftRecoveryState( + checkpointTime: _lastCheckpointTime, + status: DraftRecoveryStatus.saved, + hasResolvedStartup: true, + ); + return true; + } catch (error) { + if (_valid(operation)) { + state = DraftRecoveryState( + checkpointTime: _lastCheckpointTime, + status: DraftRecoveryStatus.decision, + candidate: candidate, + failure: _failure(error), + ); + } + _stopOnUnsafeFailure(error); + return false; + } finally { + _replacement = false; + _replacementDone?.complete(); + _replacementDone = null; + if (_valid(operation) && + state.status == DraftRecoveryStatus.decision && + state.busy) { + if (cleared) { + // Deletion was confirmed for the old copy; preserve any newer live + // edits and checkpoint them instead of installing a fresh buffer. + _writable = true; + state = DraftRecoveryState( + checkpointTime: _lastCheckpointTime, + status: DraftRecoveryStatus.pending, + hasResolvedStartup: true, + ); + _changed(); + } else { + _decisionEpoch = ref.read(editorDocumentEpochProvider); + state = DraftRecoveryState( + checkpointTime: _lastCheckpointTime, + status: DraftRecoveryStatus.decision, + candidate: candidate, + ); + } + } + } + } + + void _changed() { + if (_disposed || + _suppress || + !state.enabled || + !_writable || + state.blocksStartup || + _replacement) { + return; + } + state = DraftRecoveryState( + checkpointTime: _lastCheckpointTime, + status: DraftRecoveryStatus.pending, + hasResolvedStartup: true, + ); + _idle?.cancel(); + _idle = Timer(const Duration(milliseconds: 500), _checkpointCurrent); + _maximum ??= Timer(const Duration(seconds: 2), _checkpointCurrent); + } + + void _checkpointCurrent() { + _cancelTimers(); + if (!_disposed && _writable && !state.blocksStartup && !_replacement) { + unawaited( + _enqueue( + ref.read(editorDocumentProvider), + ref.read(editorDocumentEpochProvider), + ), + ); + } + } + + /// Wait for this captured revision, independently of later edits. + Future saveRecoveryCopy() { + if (!state.enabled || !_writable || state.blocksStartup || _replacement) { + return Future.value(false); + } + _cancelTimers(); + final int epoch = ref.read(editorDocumentEpochProvider); + if (epoch == _lastCommittedEpoch) return Future.value(true); + return _enqueue(ref.read(editorDocumentProvider), epoch); + } + + /// Lifecycle pause/inactive requests the same checkpoint as the explicit UI. + Future flush() => saveRecoveryCopy(); + + Future _enqueue(EditorDocument document, int epoch) { + if (_queued?.epoch == epoch) return _queued!.completion.future; + _cancelQueued(); + final checkpoint = _Checkpoint(document, epoch); + _queued = checkpoint; + _active ??= Future.microtask(_drain); + return checkpoint.completion.future; + } + + Future _drain() async { + try { + while (!_disposed && _queued != null && !_replacement) { + final _Checkpoint checkpoint = _queued!; + _queued = null; + if (checkpoint.epoch == ref.read(editorDocumentEpochProvider)) { + state = DraftRecoveryState( + checkpointTime: _lastCheckpointTime, + status: DraftRecoveryStatus.saving, + hasResolvedStartup: true, + ); + } + try { + await _commit(checkpoint.document); + _lastCommittedEpoch = checkpoint.epoch; + if (!_disposed && + _writable && + !state.blocksStartup && + !_replacement) { + final bool current = + ref.read(editorDocumentEpochProvider) == checkpoint.epoch; + state = DraftRecoveryState( + checkpointTime: _lastCheckpointTime, + status: current + ? DraftRecoveryStatus.saved + : DraftRecoveryStatus.pending, + hasResolvedStartup: true, + ); + } + checkpoint.completion.complete(true); + } catch (error) { + if (!_disposed && + _writable && + !state.blocksStartup && + !_replacement) { + state = DraftRecoveryState( + checkpointTime: _lastCheckpointTime, + status: DraftRecoveryStatus.failed, + failure: _failure(error), + hasResolvedStartup: true, + ); + } + _stopOnUnsafeFailure(error); + checkpoint.completion.complete(false); + } + } + } finally { + _active = null; + } + } + + Future _commit(EditorDocument document) async { + final DraftRepository repository = await _open(); + if (document == const EditorDocument.untitled()) { + _generation = await repository.clear(expectedGeneration: _generation); + _lastCheckpointTime = null; + } else { + final int next = _generation + 1; + final DateTime checkpointTime = DateTime.now().toUtc(); + await repository.write( + DraftSnapshot( + name: document.name, + content: document.content, + originPath: document.boardPath, + generation: next, + checkpointTime: checkpointTime, + ), + expectedGeneration: _generation, + ); + _generation = next; + _lastCheckpointTime = checkpointTime; + } + } + + /// Adopt a user-confirmed replacement only after its local checkpoint. The + /// caller owns confirmation and supplies the epoch captured before any await. + Future replaceDocument( + EditorDocument replacement, { + required int expectedEpoch, + bool allowVolatile = false, + bool Function()? isStillValid, + }) async { + final int operation = _operation; + bool valid() => + _valid(operation) && + ref.read(editorDocumentEpochProvider) == expectedEpoch && + (isStillValid?.call() ?? true); + if (!valid() || _replacement || state.blocksStartup) return false; + if (!state.enabled || (!_writable && allowVolatile)) { + _adopt(replacement); + return true; + } + if (!_writable) return false; + _replacement = true; + _replacementDone = Completer(); + _cancelTimers(); + _cancelQueued(); + try { + await _active; + if (!valid()) return false; + await _commit(replacement); + if (!valid()) { + // The live newer revision remains authoritative and must not be + // replaced by an old completion, even if a checkpoint reached disk. + return false; + } + _adopt(replacement); + _lastCommittedEpoch = ref.read(editorDocumentEpochProvider); + state = DraftRecoveryState( + checkpointTime: _lastCheckpointTime, + status: DraftRecoveryStatus.saved, + hasResolvedStartup: true, + ); + return true; + } catch (error) { + if (!_disposed) { + state = DraftRecoveryState( + checkpointTime: _lastCheckpointTime, + status: DraftRecoveryStatus.failed, + failure: _failure(error), + hasResolvedStartup: true, + ); + } + _stopOnUnsafeFailure(error); + return false; + } finally { + _replacement = false; + _replacementDone?.complete(); + _replacementDone = null; + if (!_disposed && + _writable && + ref.read(editorDocumentEpochProvider) != _lastCommittedEpoch && + state.status != DraftRecoveryStatus.failed) { + _changed(); + } + } + } + + void _adopt(EditorDocument document) { + _suppress = true; + try { + ref.read(editorDocumentProvider.notifier).adoptDocument(document); + } finally { + _suppress = false; + } + } + + void _cancelQueued() { + _queued?.completion.complete(false); + _queued = null; + } + + void _cancelTimers() { + _idle?.cancel(); + _maximum?.cancel(); + _idle = null; + _maximum = null; + } + + void _stopOnUnsafeFailure(Object error) { + final reason = _failure(error); + if (reason == DraftStorageFailure.conflict || + reason == DraftStorageFailure.corrupt || + reason == DraftStorageFailure.unsupportedVersion) { + // The high-water mark/content can no longer be trusted. Retry must read + // and offer an explicit choice rather than reusing a stale generation. + _writable = false; + _cancelTimers(); + _cancelQueued(); + } + } + + DraftStorageFailure _failure(Object error) => error is DraftStorageException + ? error.reason + : DraftStorageFailure.unavailable; +} diff --git a/app/lib/editor/editor.dart b/app/lib/editor/editor.dart index a7d95cc3..ea5cd4c5 100644 --- a/app/lib/editor/editor.dart +++ b/app/lib/editor/editor.dart @@ -4,7 +4,7 @@ /// PyBLE `lib/editor/` — the working-loop editor + run-control (A-20 / A-16, /// ADR-0010, TDD §11.6a/b, specs.md FR-EDIT §4.6 / FR-RUN §4.8). /// -/// Exposes the volatile in-memory current document ([editorDocumentProvider]), +/// Exposes the current document ([editorDocumentProvider]), /// the derived run-action availability ([runAvailabilityProvider]) and imperative /// run controller ([runControllerProvider]), and the live editable surface /// ([EditorView]). Every board action crosses the abstract `Connection` seam @@ -13,10 +13,11 @@ /// ADR-0012 puts the rich and plain editing implementations behind the exported /// EditorSurface seam. The rich default supplies Python highlighting and line /// numbers; session display settings resize code and gutter together. Deferred -/// (recorded, not dropped): Drift persistence (A-24), multi-file tabs, +/// (recorded, not dropped): full project persistence (A-24), multi-file tabs, /// app-owned find/replace, and the remaining external-keyboard shortcuts. library; +export 'draft_recovery.dart'; export 'editor_display_settings.dart'; export 'editor_document.dart'; export 'editor_surface.dart'; diff --git a/app/lib/editor/editor_document.dart b/app/lib/editor/editor_document.dart index 8d9e317b..393b63a2 100644 --- a/app/lib/editor/editor_document.dart +++ b/app/lib/editor/editor_document.dart @@ -1,14 +1,9 @@ // SPDX-License-Identifier: MIT // Part of PyBLE (https://pyble.dev) — see /LICENSE. -/// A-20 — the working-loop current document (ADR-0010, TDD §11.6a, specs.md -/// FR-EDIT §4.6). A single, volatile in-memory buffer the editor edits, -/// run-control runs, and the files explorer opens-into / uploads-from. -/// -/// It survives navigation (a keep-alive `NotifierProvider`) but NOT a process -/// restart: persistence to Drift is the A-24 hook, reached through -/// [EditorDocumentController.openFromBoard] / [EditorDocumentController.markSaved] -/// — a RECORDED deviation from FR-EDIT-7 (carried, not dropped). +/// The working-loop current document. The recovery coordinator observes every +/// mutation epoch and checkpoints the bounded current-text slot independently +/// from board upload state (ADR-0048). Broader project persistence remains A-24. /// /// Binds to no transport type and imports no `lib/ble` (CON-8, D2): the document /// is a pure value; the seam verbs live in `run_controller.dart` / @@ -79,6 +74,7 @@ class EditorDocumentController extends Notifier { /// Resets to a fresh, empty untitled buffer (dirty=false, boardPath=null). void newDocument() { + ref.read(editorRecoveredDraftProvider.notifier).state = false; state = const EditorDocument.untitled(); _bumpEpoch(); } @@ -99,6 +95,7 @@ class EditorDocumentController extends Notifier { /// Adopts a file downloaded from the board: the display name becomes the leaf /// of [path], [boardPath] becomes [path], and the buffer is clean. void openFromBoard({required String path, required String content}) { + ref.read(editorRecoveredDraftProvider.notifier).state = false; state = EditorDocument( name: _leaf(path), content: content, @@ -115,6 +112,7 @@ class EditorDocumentController extends Notifier { /// not a board path, and [boardPath] stays null until the normal editor Save /// succeeds. void openGenerated({required String name, required String content}) { + ref.read(editorRecoveredDraftProvider.notifier).state = false; state = EditorDocument( name: _leaf(name), content: content, @@ -136,6 +134,20 @@ class EditorDocumentController extends Notifier { _bumpEpoch(); } + /// Coordinator-only adoption after a guarded, confirmed replacement. + void adoptDocument(EditorDocument document) { + ref.read(editorRecoveredDraftProvider.notifier).state = false; + state = document; + _bumpEpoch(); + } + + /// Restore exact local text without importing historical board authority. + void restoreDraft({required String name, required String content}) { + ref.read(editorRecoveredDraftProvider.notifier).state = true; + state = EditorDocument(name: name, content: content, dirty: true); + _bumpEpoch(); + } + void _bumpEpoch() { ref.read(editorDocumentEpochProvider.notifier).state += 1; } @@ -174,3 +186,10 @@ editorDocumentProvider = final StateProvider editorDocumentEpochProvider = StateProvider( (Ref ref) => 0, ); + +/// A recovered document always needs explicit current-session target consent. +/// This marker survives text edits and successful uploads; session/path grants +/// are owned by the board-action seam and are never persisted. +final StateProvider editorRecoveredDraftProvider = StateProvider( + (Ref ref) => false, +); diff --git a/app/lib/editor/editor_view.dart b/app/lib/editor/editor_view.dart index 145a3e86..b25d1a30 100644 --- a/app/lib/editor/editor_view.dart +++ b/app/lib/editor/editor_view.dart @@ -22,6 +22,10 @@ import 'package:pyble/pble/pble.dart'; import 'package:pyble/theme/theme.dart'; import 'editor_document.dart'; +import 'board_target_guard.dart'; +import 'draft_recovery.dart'; +import 'recovery_gate.dart'; +import 'recovery_panel.dart'; import 'editor_display_settings.dart'; import 'editor_surface.dart'; import 'editor_surface_factory.dart'; @@ -103,6 +107,7 @@ class _EditorViewState extends ConsumerState { // present, and it fixes on tap — the buffer is never rewritten behind // the user's back. const _SmartPunctuationBanner(), + const RecoveryStatusBar(), // The editable code well — a recessed monospace surface (§1.4). Expanded( child: surfaceBuilder( @@ -267,6 +272,8 @@ class _EditorHeader extends ConsumerWidget { try { final String path = await ref.read(saveControllerProvider).save(); messenger.showSnackBar(SnackBar(content: Text(l10n.editorSaved(path)))); + } on BoardActionCancelled { + return; } on PbleException catch (e) { // The typed §8 status name (EBusy / ENoSpc / ECrc …) is a PBLE/1 // technical identifier: rendered verbatim, never localized (FR-I18N-4). @@ -291,27 +298,83 @@ class _EditorHeader extends ConsumerWidget { /// no way to get it back (the document is volatile by design). Future _newDocument(BuildContext context, WidgetRef ref) async { final AppLocalizations l10n = AppLocalizations.of(context); - if (ref.read(editorDocumentProvider).dirty) { - final bool? discard = await showDialog( - context: context, - builder: (BuildContext ctx) => AlertDialog( - title: Text(l10n.editorDiscardTitle), - content: Text(l10n.editorDiscardDetail), - actions: [ - TextButton( - onPressed: () => Navigator.of(ctx).pop(false), - child: Text(l10n.commonCancel), - ), - TextButton( - onPressed: () => Navigator.of(ctx).pop(true), - child: Text(l10n.editorDiscardConfirm), - ), - ], - ), - ); - if (discard != true) return; + final int epoch = ref.read(editorDocumentEpochProvider); + final EditorDocument document = ref.read(editorDocumentProvider); + final DraftRecoveryState recovery = ref.read(draftRecoveryProvider); + final bool meaningful = + document.content.isNotEmpty || + document.name != kDefaultDocumentName || + document.boardPath != null; + final bool needsChoice = + document.dirty || + (recovery.enabled && + meaningful && + recovery.status != DraftRecoveryStatus.saved); + if (needsChoice) { + if (recovery.enabled) { + if (!await confirmRecoveryReplacement(context)) return; + } else { + final bool? discard = await showDialog( + context: context, + builder: (BuildContext ctx) => AlertDialog( + scrollable: true, + title: Text(l10n.editorDiscardTitle), + content: Text(l10n.editorDiscardDetail), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: Text(l10n.commonCancel), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: Text(l10n.editorDiscardConfirm), + ), + ], + ), + ); + if (discard != true) return; + } + } + if (!context.mounted) return; + if (ref.read(editorDocumentEpochProvider) != epoch) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.recoveryChanged))); + return; + } + if (!recovery.enabled) { + ref.read(editorDocumentProvider.notifier).newDocument(); + return; + } + final bool replaced = await ref + .read(draftRecoveryProvider.notifier) + .replaceDocument( + const EditorDocument.untitled(), + expectedEpoch: epoch, + allowVolatile: + ref.read(draftRecoveryProvider).status == + DraftRecoveryStatus.volatile, + ); + if (!replaced && context.mounted) { + if (ref.read(editorDocumentEpochProvider) != epoch) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.recoveryChanged))); + return; + } + if (!await confirmVolatileReplacement(context)) return; + if (!context.mounted || ref.read(editorDocumentEpochProvider) != epoch) { + return; + } + ref.read(draftRecoveryProvider.notifier).continueWithoutRecovery(); + await ref + .read(draftRecoveryProvider.notifier) + .replaceDocument( + const EditorDocument.untitled(), + expectedEpoch: epoch, + allowVolatile: true, + ); } - ref.read(editorDocumentProvider.notifier).newDocument(); } } diff --git a/app/lib/editor/program_actions.dart b/app/lib/editor/program_actions.dart index d2b0691a..eeceba25 100644 --- a/app/lib/editor/program_actions.dart +++ b/app/lib/editor/program_actions.dart @@ -32,6 +32,13 @@ class ProgramBundlePathTooLong implements Exception { String toString() => 'ProgramBundlePathTooLong($path)'; } +/// The text upload may have completed on its original board, but Run was +/// refused after the facade changed attachment. Keep that partial result. +class ProgramSourceSessionChanged implements Exception { + const ProgramSourceSessionChanged({required this.sourcePath}); + final String sourcePath; +} + /// The source upload completed, but its sidecar commit record did not. /// /// PBLE/1 has no multi-file transaction. Callers must therefore report that @@ -133,8 +140,16 @@ class ProgramActions { required String source, ProgressCb? onProgress, }) async { + final Connection connection = _ref.read(connectionProvider); + final Object stamp = connectionSessionStampOf(connection); await saveSource(path: path, source: source, onProgress: onProgress); - await _ref.read(connectionProvider).runFile(path); + if (!identical(_ref.read(connectionProvider), connection) || + !identical(connectionSessionStampOf(connection), stamp)) { + // The upload may have completed on the previous attachment. Do not send + // the second verb to a replacement board. + throw ProgramSourceSessionChanged(sourcePath: path); + } + await connection.runFile(path); } /// Writes a Python file and then its raw JSON companion commit record. diff --git a/app/lib/editor/recovery_gate.dart b/app/lib/editor/recovery_gate.dart new file mode 100644 index 00000000..f7521f4a --- /dev/null +++ b/app/lib/editor/recovery_gate.dart @@ -0,0 +1,352 @@ +// SPDX-License-Identifier: MIT +// Part of PyBLE (https://pyble.dev) — see /LICENSE. + +/// Startup and lifecycle adapter for the local recovery coordinator. +library; + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:pyble/data/draft_repository.dart'; +import 'package:pyble/localization/localization.dart'; + +import 'draft_recovery.dart'; +import 'editor_document.dart'; +import 'recovery_panel.dart'; + +/// Keeps connection workflows unmounted until the local startup decision is +/// resolved. The callback is scheduled after mounting, outside provider build. +class RecoveryGate extends ConsumerStatefulWidget { + const RecoveryGate({super.key, required this.child}); + final Widget child; + + @override + ConsumerState createState() => _RecoveryGateState(); +} + +class _RecoveryGateState extends ConsumerState + with WidgetsBindingObserver { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + scheduleMicrotask(() { + if (mounted) unawaited(ref.read(draftRecoveryProvider.notifier).start()); + }); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.inactive || + state == AppLifecycleState.paused) { + final DraftRecoveryState recovery = ref.read(draftRecoveryProvider); + if (recovery.status == DraftRecoveryStatus.pending || + recovery.status == DraftRecoveryStatus.saving) { + unawaited(ref.read(draftRecoveryProvider.notifier).saveRecoveryCopy()); + } + } + } + + @override + Widget build(BuildContext context) { + final DraftRecoveryState recovery = ref.watch(draftRecoveryProvider); + final AppLocalizations l10n = AppLocalizations.of(context); + if (!recovery.enabled) return widget.child; + final DraftRecoveryController controller = ref.read( + draftRecoveryProvider.notifier, + ); + if (recovery.blocksStartup) { + final DraftSnapshot? candidate = recovery.candidate; + final EditorDocument current = ref.watch(editorDocumentProvider); + final bool currentMeaningful = + current.content.isNotEmpty || + current.name != kDefaultDocumentName || + current.boardPath != null; + if (candidate != null && recovery.status != DraftRecoveryStatus.loading) { + return Scaffold( + body: PopScope( + canPop: false, + child: RecoveryPanel( + currentContent: currentMeaningful ? current.content : null, + currentName: currentMeaningful ? current.name : null, + onKeepCurrent: currentMeaningful + ? controller.continueWithoutRecovery + : null, + name: candidate.name, + content: candidate.content, + checkpointTime: candidate.checkpointTime, + onRestore: () async { + controller.restore(); + }, + onDiscard: () async { + await controller.discard(); + }, + busy: recovery.busy, + failure: recovery.failure == null + ? null + : recoveryFailureMessage(l10n, recovery.failure!), + ), + ), + ); + } + return Scaffold( + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Semantics( + header: true, + child: Text( + l10n.recoveryTitle, + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + const SizedBox(height: 16), + if (recovery.status == DraftRecoveryStatus.loading && + !recovery.timedOut) ...[ + LinearProgressIndicator(semanticsLabel: l10n.recoveryLoading), + const SizedBox(height: 16), + Text(l10n.recoveryLoading), + ] else ...[ + Text( + recovery.timedOut + ? l10n.recoveryTimeout + : l10n.recoveryUnavailable, + ), + if (recovery.failure != null) ...[ + const SizedBox(height: 12), + Text(recoveryFailureMessage(l10n, recovery.failure!)), + ], + const SizedBox(height: 16), + Wrap( + spacing: 12, + runSpacing: 8, + children: [ + FilledButton( + key: kRecoveryRetryKey, + onPressed: () => controller.retry(), + child: Text(l10n.recoveryRetry), + ), + OutlinedButton( + key: kRecoveryContinueKey, + onPressed: controller.continueWithoutRecovery, + child: Text(l10n.recoveryContinue), + ), + ], + ), + ], + ], + ), + ), + ), + ); + } + if (recovery.status == DraftRecoveryStatus.volatile) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SafeArea( + bottom: false, + child: Material( + color: Theme.of(context).colorScheme.errorContainer, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + child: Wrap( + spacing: 12, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text(l10n.recoveryVolatileShort), + TextButton( + key: kRecoveryRetryKey, + onPressed: () => controller.retry(), + child: Text(l10n.recoveryRetry), + ), + IconButton( + tooltip: l10n.recoveryVolatile, + icon: const Icon(Icons.info_outline), + onPressed: () => showDialog( + context: context, + builder: (BuildContext context) => AlertDialog( + scrollable: true, + title: Text(l10n.recoveryTitle), + content: Text(l10n.recoveryVolatile), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.recoveryClose), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + Expanded(child: widget.child), + ], + ); + } + return widget.child; + } +} + +/// Distinct from the board-dirty indicator and board Save feedback. +class RecoveryStatusBar extends ConsumerWidget { + const RecoveryStatusBar({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final DraftRecoveryState recovery = ref.watch(draftRecoveryProvider); + if (!recovery.enabled) return const SizedBox.shrink(); + final AppLocalizations l10n = AppLocalizations.of(context); + final String label = switch (recovery.status) { + DraftRecoveryStatus.saved => l10n.recoverySaved, + DraftRecoveryStatus.saving => l10n.recoverySaving, + DraftRecoveryStatus.pending => l10n.recoveryPending, + DraftRecoveryStatus.volatile => l10n.recoveryVolatileShort, + DraftRecoveryStatus.loading => l10n.recoveryLoading, + DraftRecoveryStatus.decision => l10n.recoveryTitle, + DraftRecoveryStatus.failed => + recovery.failure == null + ? l10n.recoverySaveFailed + : recoveryFailureMessage(l10n, recovery.failure!), + DraftRecoveryStatus.disabled => '', + }; + final bool canSave = + !recovery.blocksStartup && + recovery.status != DraftRecoveryStatus.volatile && + recovery.status != DraftRecoveryStatus.saving; + return Material( + color: Theme.of(context).colorScheme.surfaceContainerLow, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: Wrap( + spacing: 12, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (recovery.status != DraftRecoveryStatus.volatile) + Semantics(liveRegion: true, child: Text(label)), + if (recovery.status == DraftRecoveryStatus.failed) ...[ + TextButton( + key: kRecoveryRetryKey, + onPressed: () => + ref.read(draftRecoveryProvider.notifier).retry(), + child: Text(l10n.recoveryRetry), + ), + TextButton( + key: kRecoveryContinueKey, + onPressed: () => ref + .read(draftRecoveryProvider.notifier) + .continueWithoutRecovery(), + child: Text(l10n.recoveryContinue), + ), + ], + if (recovery.status != DraftRecoveryStatus.volatile && + recovery.status != DraftRecoveryStatus.failed) + TextButton.icon( + key: kRecoverySaveKey, + onPressed: canSave + ? () async { + final ScaffoldMessengerState messenger = + ScaffoldMessenger.of(context); + final bool saved = await ref + .read(draftRecoveryProvider.notifier) + .saveRecoveryCopy(); + if (!messenger.mounted) return; + messenger.showSnackBar( + SnackBar( + content: Text( + saved + ? l10n.recoverySavedFeedback + : l10n.recoverySaveFailed, + ), + ), + ); + } + : null, + icon: const Icon(Icons.save_alt), + label: Text(l10n.recoverySave), + ), + TextButton( + onPressed: () => showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (BuildContext context) => const FractionallySizedBox( + heightFactor: 0.85, + child: CurrentDraftPreview(), + ), + ), + child: Text(l10n.recoveryOpen), + ), + ], + ), + ), + ); + } +} + +String recoveryFailureMessage( + AppLocalizations l10n, + DraftStorageFailure failure, +) => switch (failure) { + DraftStorageFailure.unavailable => l10n.recoveryErrorStorage, + DraftStorageFailure.corrupt => l10n.recoveryErrorCorrupt, + DraftStorageFailure.unsupportedVersion => l10n.recoveryErrorUnsupported, + DraftStorageFailure.invalidMetadata || + DraftStorageFailure.invalidText => l10n.recoveryErrorInvalid, + DraftStorageFailure.tooLarge => l10n.recoveryErrorTooLarge, + DraftStorageFailure.conflict => l10n.recoveryErrorConflict, +}; + +/// Offline access to the restored text after the startup choice. Reading this +/// panel never authorizes a board action or mutates the document. +class CurrentDraftPreview extends ConsumerWidget { + const CurrentDraftPreview({super.key, this.onContinue}); + final VoidCallback? onContinue; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final EditorDocument document = ref.watch(editorDocumentProvider); + final DraftRecoveryState recovery = ref.watch(draftRecoveryProvider); + return RecoveryPanel( + name: document.name, + content: document.content, + checkpointTime: recovery.status == DraftRecoveryStatus.saved + ? recovery.checkpointTime + : null, + restored: true, + onRestore: () async {}, + onDiscard: () async {}, + onContinue: onContinue, + failure: recovery.status == DraftRecoveryStatus.volatile + ? AppLocalizations.of(context).recoveryVolatile + : recovery.status == DraftRecoveryStatus.pending + ? AppLocalizations.of(context).recoveryPending + : recovery.status == DraftRecoveryStatus.saving + ? AppLocalizations.of(context).recoverySaving + : recovery.failure == null + ? null + : recoveryFailureMessage( + AppLocalizations.of(context), + recovery.failure!, + ), + ); + } +} diff --git a/app/lib/editor/recovery_panel.dart b/app/lib/editor/recovery_panel.dart new file mode 100644 index 00000000..fb6c8d15 --- /dev/null +++ b/app/lib/editor/recovery_panel.dart @@ -0,0 +1,320 @@ +// SPDX-License-Identifier: MIT +// Part of PyBLE (https://pyble.dev) — see /LICENSE. + +/// Local-only recovery preview and explicit confirmations. These widgets have +/// no transport, repository or network dependency: callbacks own the decision. +library; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:pyble/localization/localization.dart'; + +const Key kRecoveryRestoreKey = ValueKey('recoveryRestore'); +const Key kRecoveryDiscardKey = ValueKey('recoveryDiscard'); +const Key kRecoveryConfirmDiscardKey = ValueKey( + 'recoveryConfirmDiscard', +); +const Key kRecoveryCopyKey = ValueKey('recoveryCopy'); +const Key kRecoveryRetryKey = ValueKey('recoveryRetry'); +const Key kRecoveryContinueKey = ValueKey('recoveryContinue'); +const Key kRecoverySaveKey = ValueKey('recoverySave'); +const Key kRecoveryKeepCurrentKey = ValueKey('recoveryKeepCurrent'); + +/// A scrollable, selectable source preview. Merely mounting or dismissing it +/// never restores, discards, copies, connects to a board, or runs code. +class RecoveryPanel extends StatelessWidget { + const RecoveryPanel({ + super.key, + required this.name, + required this.content, + required this.checkpointTime, + required this.onRestore, + required this.onDiscard, + this.restored = false, + this.busy = false, + this.failure, + this.onContinue, + this.currentContent, + this.currentName, + this.onKeepCurrent, + }); + + final String name; + final String content; + final DateTime? checkpointTime; + final Future Function() onRestore; + final Future Function() onDiscard; + final bool restored; + final bool busy; + final String? failure; + final VoidCallback? onContinue; + final String? currentContent; + final String? currentName; + final VoidCallback? onKeepCurrent; + + @override + Widget build(BuildContext context) { + final AppLocalizations l10n = AppLocalizations.of(context); + final ThemeData theme = Theme.of(context); + final MaterialLocalizations material = MaterialLocalizations.of(context); + final DateTime? localTime = checkpointTime?.toLocal(); + final String? when = localTime == null + ? null + : '${material.formatMediumDate(localTime)} ' + '${material.formatTimeOfDay(TimeOfDay.fromDateTime(localTime))}'; + return SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Semantics( + header: true, + child: Text( + restored ? l10n.recoveryRestoredTitle : l10n.recoveryTitle, + style: theme.textTheme.headlineSmall, + ), + ), + const SizedBox(height: 12), + Text(l10n.recoveryLocalOnly), + if (currentContent != null) ...[ + const SizedBox(height: 12), + Text( + l10n.recoveryCurrentText, + style: theme.textTheme.titleMedium, + ), + if (currentName != null) SelectableText(currentName!), + Container( + constraints: const BoxConstraints(maxHeight: 160), + padding: const EdgeInsets.all(12), + color: theme.colorScheme.surfaceContainerLow, + child: SingleChildScrollView( + child: SelectableText(currentContent!), + ), + ), + Align( + alignment: AlignmentDirectional.centerStart, + child: TextButton.icon( + onPressed: busy + ? null + : () async { + final ScaffoldMessengerState? messenger = + ScaffoldMessenger.maybeOf(context); + await Clipboard.setData( + ClipboardData(text: currentContent!), + ); + if (messenger?.mounted ?? false) { + messenger!.showSnackBar( + SnackBar(content: Text(l10n.recoveryCopied)), + ); + } + }, + icon: const Icon(Icons.copy_outlined), + label: Text(l10n.recoveryCopyCurrent), + ), + ), + Text(l10n.recoveryStoredText, style: theme.textTheme.titleMedium), + ], + const SizedBox(height: 12), + SelectableText(name, style: theme.textTheme.titleMedium), + if (when != null) Text(l10n.recoveryCheckpointTime(when)), + const SizedBox(height: 12), + Container( + constraints: const BoxConstraints(maxHeight: 240), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerLow, + border: Border.all(color: theme.colorScheme.outlineVariant), + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.all(12), + child: SingleChildScrollView( + child: SelectableText( + content, + style: theme.textTheme.bodyMedium?.copyWith( + fontFamily: 'monospace', + ), + ), + ), + ), + const SizedBox(height: 12), + Text(l10n.recoveryScope), + if (failure != null) ...[ + const SizedBox(height: 12), + Semantics(liveRegion: true, child: Text(failure!)), + ], + const SizedBox(height: 16), + Wrap( + spacing: 12, + runSpacing: 8, + children: [ + if (!restored) + FilledButton.icon( + key: kRecoveryRestoreKey, + onPressed: busy ? null : onRestore, + icon: const Icon(Icons.restore), + label: Text(l10n.recoveryRestore), + ), + OutlinedButton.icon( + key: kRecoveryCopyKey, + onPressed: busy + ? null + : () async { + final ScaffoldMessengerState? messenger = + ScaffoldMessenger.maybeOf(context); + await Clipboard.setData(ClipboardData(text: content)); + if (messenger?.mounted ?? false) { + messenger!.showSnackBar( + SnackBar(content: Text(l10n.recoveryCopied)), + ); + } + }, + icon: const Icon(Icons.copy_outlined), + label: Text(l10n.recoveryCopy), + ), + if (!restored) + TextButton( + key: kRecoveryDiscardKey, + onPressed: busy + ? null + : () async { + if (await _confirmDiscard(context)) { + await onDiscard(); + } + }, + child: Text(l10n.recoveryDiscard), + ), + if (onKeepCurrent != null) + OutlinedButton( + key: kRecoveryKeepCurrentKey, + onPressed: busy ? null : onKeepCurrent, + child: Text(l10n.recoveryKeepCurrent), + ), + if (onContinue != null) + FilledButton( + onPressed: busy ? null : onContinue, + child: Text(l10n.recoveryConnect), + ), + ], + ), + if (busy) ...[ + const SizedBox(height: 12), + LinearProgressIndicator(semanticsLabel: l10n.recoverySaving), + ], + ], + ), + ), + ); + } + + Future _confirmDiscard(BuildContext context) async { + final AppLocalizations l10n = AppLocalizations.of(context); + return await showDialog( + context: context, + builder: (BuildContext ctx) => AlertDialog( + scrollable: true, + title: Text(l10n.recoveryDiscardTitle), + content: Text(l10n.recoveryDiscardDetail), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: Text(l10n.commonCancel), + ), + TextButton( + key: kRecoveryConfirmDiscardKey, + onPressed: () => Navigator.of(ctx).pop(true), + child: Text(l10n.recoveryDiscard), + ), + ], + ), + ) ?? + false; + } +} + +/// Confirmation for a current-session board action on recovered text. The +/// caller must recheck document, target and attachment after this future. +Future confirmRecoveryBoardTarget( + BuildContext context, { + required String path, + required bool run, +}) async { + final AppLocalizations l10n = AppLocalizations.of(context); + return await showDialog( + context: context, + builder: (BuildContext ctx) => AlertDialog( + scrollable: true, + title: Text(l10n.recoveryBoardTargetTitle), + content: Text( + run + ? l10n.recoveryBoardTargetRunDetail(path) + : l10n.recoveryBoardTargetSaveDetail(path), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: Text(l10n.commonCancel), + ), + FilledButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: Text( + run + ? l10n.recoveryBoardTargetRun + : l10n.recoveryBoardTargetSave, + ), + ), + ], + ), + ) ?? + false; +} + +/// Consent to replace the single current draft. Epoch/session fencing belongs +/// to the caller because an editor can change while a dialog is open. +Future confirmRecoveryReplacement(BuildContext context) async { + final AppLocalizations l10n = AppLocalizations.of(context); + return await showDialog( + context: context, + builder: (BuildContext ctx) => AlertDialog( + scrollable: true, + title: Text(l10n.recoveryReplaceTitle), + content: Text(l10n.recoveryReplaceDetail), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: Text(l10n.commonCancel), + ), + FilledButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: Text(l10n.recoveryReplace), + ), + ], + ), + ) ?? + false; +} + +/// A separate choice after a failed durable replacement. The current buffer +/// remains untouched until the caller applies this explicitly temporary edit. +Future confirmVolatileReplacement(BuildContext context) async { + final AppLocalizations l10n = AppLocalizations.of(context); + return await showDialog( + context: context, + builder: (BuildContext ctx) => AlertDialog( + scrollable: true, + title: Text(l10n.recoverySaveFailed), + content: Text(l10n.recoveryVolatileReplaceDetail), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: Text(l10n.commonCancel), + ), + FilledButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: Text(l10n.recoveryContinue), + ), + ], + ), + ) ?? + false; +} diff --git a/app/lib/editor/run_controller.dart b/app/lib/editor/run_controller.dart index 2dd139d7..a0a98614 100644 --- a/app/lib/editor/run_controller.dart +++ b/app/lib/editor/run_controller.dart @@ -24,6 +24,7 @@ import 'package:pyble/app/providers.dart'; import 'package:pyble/pble/pble.dart'; import 'editor_document.dart'; +import 'board_target_guard.dart'; import 'program_actions.dart'; import 'smart_punctuation.dart'; @@ -117,10 +118,21 @@ class RunController { } final EditorDocument uploadedDocument = _ref.read(editorDocumentProvider); final String path = boardPathForDocument(uploadedDocument); + final int epoch = _ref.read(editorDocumentEpochProvider); + final Connection connection = _connection; + final Object stamp = connectionSessionStampOf(connection); + if (!await confirmBoardTarget(_ref, path: path, run: true) || + _ref.read(editorDocumentEpochProvider) != epoch || + !identical(_ref.read(connectionProvider), connection) || + !identical(connectionSessionStampOf(connection), stamp)) { + throw const BoardActionCancelled(); + } await _ref .read(programActionsProvider) .runSourceFile(path: path, source: cleaned); - if (_ref.read(editorDocumentProvider) == uploadedDocument) { + if (_ref.read(editorDocumentEpochProvider) == epoch && + identical(_connection, connection) && + identical(connectionSessionStampOf(connection), stamp)) { _ref.read(editorDocumentProvider.notifier).markSaved(boardPath: path); } } diff --git a/app/lib/editor/save_controller.dart b/app/lib/editor/save_controller.dart index 13361cfd..fe743318 100644 --- a/app/lib/editor/save_controller.dart +++ b/app/lib/editor/save_controller.dart @@ -25,8 +25,10 @@ library; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:pyble/pble/pble.dart'; +import 'package:pyble/app/providers.dart'; import 'editor_document.dart'; +import 'board_target_guard.dart'; import 'program_actions.dart'; import 'smart_punctuation.dart'; @@ -73,6 +75,15 @@ class SaveController { doc = _ref.read(editorDocumentProvider); } + final int epoch = _ref.read(editorDocumentEpochProvider); + final Connection connection = _ref.read(connectionProvider); + final Object stamp = connectionSessionStampOf(connection); + if (!await confirmBoardTarget(_ref, path: path, run: false) || + _ref.read(editorDocumentEpochProvider) != epoch || + !identical(_ref.read(connectionProvider), connection) || + !identical(connectionSessionStampOf(connection), stamp)) { + throw const BoardActionCancelled(); + } await _ref .read(programActionsProvider) .saveSource(path: path, source: cleaned, onProgress: onProgress); @@ -80,7 +91,9 @@ class SaveController { // Only after CRC-verified success, and only if this is still the document // whose bytes were uploaded: clear dirty and adopt the path. A user may edit // or open another document while a BLE transfer is in flight. - if (_ref.read(editorDocumentProvider) == doc) { + if (_ref.read(editorDocumentEpochProvider) == epoch && + identical(_ref.read(connectionProvider), connection) && + identical(connectionSessionStampOf(connection), stamp)) { _ref.read(editorDocumentProvider.notifier).markSaved(boardPath: path); } return path; diff --git a/app/lib/files/file_explorer_controller.dart b/app/lib/files/file_explorer_controller.dart index 2773fd5b..9b10e267 100644 --- a/app/lib/files/file_explorer_controller.dart +++ b/app/lib/files/file_explorer_controller.dart @@ -25,6 +25,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:pyble/app/providers.dart'; import 'package:pyble/editor/editor.dart'; +import 'package:pyble/editor/board_target_guard.dart'; import 'package:pyble/pble/pble.dart'; /// The neutral, localizable classification of a file-operation failure @@ -493,23 +494,44 @@ class FileExplorerController extends Notifier { Future openInEditor(String fileName) async { final String path = _join(state.cwd, fileName); final int epoch = ++_openEpoch; - final EditorDocument before = ref.read(editorDocumentProvider); + final int documentEpoch = ref.read(editorDocumentEpochProvider); + final Connection connection = _conn; + final Object stamp = connectionSessionStampOf(connection); try { - final Uint8List bytes = await _conn.getFile( + final Uint8List bytes = await connection.getFile( path, onProgress: (TransferProgress progress) { if (epoch == _openEpoch) _onProgress(progress); }, ); - if (epoch != _openEpoch || ref.read(editorDocumentProvider) != before) { + if (_disposed || + epoch != _openEpoch || + ref.read(editorDocumentEpochProvider) != documentEpoch || + !_isCapturedSessionCurrent(connection: connection, stamp: stamp)) { return; } state = state.copyWith(clearProgress: true); final String content = utf8.decode(bytes, allowMalformed: true); - ref - .read(editorDocumentProvider.notifier) - .openFromBoard(path: path, content: content); - ref.read(selectedSurfaceProvider.notifier).state = AppSurface.editor; + if (!await confirmEditorReplacement(ref, expectedEpoch: documentEpoch)) { + return; + } + final adopted = await replaceEditorDocument( + ref, + EditorDocument( + name: fileName, + content: content, + dirty: false, + boardPath: path, + ), + expectedEpoch: documentEpoch, + isStillValid: () => + !_disposed && + epoch == _openEpoch && + _isCapturedSessionCurrent(connection: connection, stamp: stamp), + ); + if (adopted) { + ref.read(selectedSurfaceProvider.notifier).state = AppSurface.editor; + } } on PbleException catch (e) { if (epoch == _openEpoch) _fail(e, path); } @@ -562,14 +584,25 @@ class FileExplorerController extends Notifier { Future uploadCurrentBuffer() async { final EditorDocument doc = ref.read(editorDocumentProvider); final String path = _join(state.cwd, doc.name); + final int epoch = ref.read(editorDocumentEpochProvider); + final Connection connection = _conn; + final Object stamp = connectionSessionStampOf(connection); + if (!await confirmBoardTarget(ref, path: path, run: false) || + _disposed || + _join(state.cwd, doc.name) != path || + ref.read(editorDocumentEpochProvider) != epoch || + !_isCapturedSessionCurrent(connection: connection, stamp: stamp)) { + return; + } try { - await _conn.putFile( + await connection.putFile( path, Uint8List.fromList(utf8.encode(doc.content)), onProgress: _onProgress, ); state = state.copyWith(clearProgress: true); - if (ref.read(editorDocumentProvider) == doc) { + if (ref.read(editorDocumentEpochProvider) == epoch && + _isCapturedSessionCurrent(connection: connection, stamp: stamp)) { ref.read(editorDocumentProvider.notifier).markSaved(boardPath: path); } await refresh(); diff --git a/app/lib/localization/arb/app_en.arb b/app/lib/localization/arb/app_en.arb index 0d33a388..49e0ce4a 100644 --- a/app/lib/localization/arb/app_en.arb +++ b/app/lib/localization/arb/app_en.arb @@ -2382,5 +2382,206 @@ "githubImportNoAutomaticRun": "Downloaded files are not opened or run automatically.", "@githubImportNoAutomaticRun": { "description": "Safety note on review and terminal import states." - } + }, + "recoveryTitle": "Recover your current text draft", + "@recoveryTitle": { + "description": "Bounded current-text recovery: recoveryTitle." + }, + "recoveryRestoredTitle": "Your current text draft", + "@recoveryRestoredTitle": { + "description": "Bounded current-text recovery: recoveryRestoredTitle." + }, + "recoveryLocalOnly": "This copy stays on this device. Restoring does not connect, upload or run code. Choose and confirm a board target again before writing.", + "@recoveryLocalOnly": { + "description": "Bounded current-text recovery: recoveryLocalOnly." + }, + "recoveryCheckpointTime": "Recovery checkpoint: {time}", + "@recoveryCheckpointTime": { + "description": "Bounded current-text recovery: recoveryCheckpointTime.", + "placeholders": { + "time": { + "type": "String", + "example": "Oct 31, 2026 12:00" + } + } + }, + "recoveryScope": "One current text draft only. Blocks workspaces, other files and projects are not included. Pending edits may be lost if the app stops before saving locally.", + "@recoveryScope": { + "description": "Bounded current-text recovery: recoveryScope." + }, + "recoveryRestore": "Restore", + "@recoveryRestore": { + "description": "Bounded current-text recovery: recoveryRestore." + }, + "recoveryDiscard": "Discard recovery copy", + "@recoveryDiscard": { + "description": "Bounded current-text recovery: recoveryDiscard." + }, + "recoveryCopy": "Copy text", + "@recoveryCopy": { + "description": "Bounded current-text recovery: recoveryCopy." + }, + "recoveryCopied": "Text copied.", + "@recoveryCopied": { + "description": "Bounded current-text recovery: recoveryCopied." + }, + "recoveryConnect": "Connect a board", + "@recoveryConnect": { + "description": "Bounded current-text recovery: recoveryConnect." + }, + "recoveryDiscardTitle": "Discard this recovery copy?", + "@recoveryDiscardTitle": { + "description": "Bounded current-text recovery: recoveryDiscardTitle." + }, + "recoveryDiscardDetail": "This deletes the single local recovery copy and starts an empty draft. It cannot be undone. No board file is changed.", + "@recoveryDiscardDetail": { + "description": "Bounded current-text recovery: recoveryDiscardDetail." + }, + "recoveryBoardTargetTitle": "Confirm the current board target", + "@recoveryBoardTargetTitle": { + "description": "Bounded current-text recovery: recoveryBoardTargetTitle." + }, + "recoveryBoardTargetRunDetail": "Upload this recovered text to {path} on the currently connected board, replacing any file there, then run it? The previous board path does not authorize this action.", + "@recoveryBoardTargetRunDetail": { + "description": "Bounded current-text recovery: recoveryBoardTargetRunDetail.", + "placeholders": { + "path": { + "type": "String", + "example": "/main.py" + } + } + }, + "recoveryBoardTargetSaveDetail": "Upload this recovered text to {path} on the currently connected board, replacing any file there? This action does not run code. The previous board path does not authorize this action.", + "@recoveryBoardTargetSaveDetail": { + "description": "Bounded current-text recovery: recoveryBoardTargetSaveDetail.", + "placeholders": { + "path": { + "type": "String", + "example": "/main.py" + } + } + }, + "recoveryBoardTargetRun": "Upload and run", + "@recoveryBoardTargetRun": { + "description": "Bounded current-text recovery: recoveryBoardTargetRun." + }, + "recoveryBoardTargetSave": "Upload to board", + "@recoveryBoardTargetSave": { + "description": "Bounded current-text recovery: recoveryBoardTargetSave." + }, + "recoveryReplaceTitle": "Replace the current draft?", + "@recoveryReplaceTitle": { + "description": "Bounded current-text recovery: recoveryReplaceTitle." + }, + "recoveryReplaceDetail": "This replaces the current text and its single local recovery copy. There is no draft history. Copy any text you want to keep before continuing.", + "@recoveryReplaceDetail": { + "description": "Bounded current-text recovery: recoveryReplaceDetail." + }, + "recoveryReplace": "Replace draft", + "@recoveryReplace": { + "description": "Bounded current-text recovery: recoveryReplace." + }, + "recoveryLoading": "Opening local recovery storage…", + "@recoveryLoading": { + "description": "Bounded current-text recovery: recoveryLoading." + }, + "recoveryTimeout": "Local recovery storage has not responded. Retry, or continue with a temporary draft. Your existing storage will be preserved.", + "@recoveryTimeout": { + "description": "Bounded current-text recovery: recoveryTimeout." + }, + "recoveryUnavailable": "Local recovery is unavailable. Your existing storage has been preserved.", + "@recoveryUnavailable": { + "description": "Bounded current-text recovery: recoveryUnavailable." + }, + "recoveryErrorStorage": "The recovery copy could not be read or written. Check device storage and try again.", + "@recoveryErrorStorage": { + "description": "Bounded current-text recovery: recoveryErrorStorage." + }, + "recoveryErrorCorrupt": "The recovery storage is damaged or contains invalid text or metadata. It has been preserved.", + "@recoveryErrorCorrupt": { + "description": "Bounded current-text recovery: recoveryErrorCorrupt." + }, + "recoveryErrorUnsupported": "This recovery copy needs a compatible app version. Its storage has been preserved.", + "@recoveryErrorUnsupported": { + "description": "Bounded current-text recovery: recoveryErrorUnsupported." + }, + "recoveryErrorTooLarge": "This draft exceeds the 1 MiB recovery limit. Its current text is only in memory; the previous recovery copy is preserved.", + "@recoveryErrorTooLarge": { + "description": "Bounded current-text recovery: recoveryErrorTooLarge." + }, + "recoveryErrorInvalid": "The current text or filename cannot be saved as a recovery copy. Its text remains in memory; the previous copy is preserved.", + "@recoveryErrorInvalid": { + "description": "Bounded current-text recovery: recoveryErrorInvalid." + }, + "recoveryErrorConflict": "Recovery storage changed unexpectedly. Retry and review the available text before replacing it.", + "@recoveryErrorConflict": { + "description": "Bounded current-text recovery: recoveryErrorConflict." + }, + "recoveryRetry": "Retry", + "@recoveryRetry": { + "description": "Bounded current-text recovery: recoveryRetry." + }, + "recoveryContinue": "Continue without recovery", + "@recoveryContinue": { + "description": "Bounded current-text recovery: recoveryContinue." + }, + "recoveryVolatile": "Recovery is off for this session. Current edits are only in memory. Existing recovery storage is preserved.", + "@recoveryVolatile": { + "description": "Bounded current-text recovery: recoveryVolatile." + }, + "recoveryPending": "Recovery copy pending", + "@recoveryPending": { + "description": "Bounded current-text recovery: recoveryPending." + }, + "recoverySaving": "Saving recovery copy…", + "@recoverySaving": { + "description": "Bounded current-text recovery: recoverySaving." + }, + "recoverySaved": "Saved locally", + "@recoverySaved": { + "description": "Bounded current-text recovery: recoverySaved." + }, + "recoverySave": "Save recovery copy", + "@recoverySave": { + "description": "Bounded current-text recovery: recoverySave." + }, + "recoverySavedFeedback": "The requested text revision was saved locally.", + "@recoverySavedFeedback": { + "description": "Bounded current-text recovery: recoverySavedFeedback." + }, + "recoverySaveFailed": "Recovery copy not saved. Your current text remains in memory.", + "@recoverySaveFailed": { + "description": "Bounded current-text recovery: recoverySaveFailed." + }, + "recoveryOpen": "View local draft", + "@recoveryOpen": { + "description": "Bounded current-text recovery: recoveryOpen." + }, + "recoveryNewFailed": "The new draft could not replace the recovery copy. Your current draft has been kept.", + "@recoveryNewFailed": { + "description": "Bounded current-text recovery: recoveryNewFailed." + }, + "recoveryChanged": "The current draft changed. Review it and confirm again.", + "@recoveryChanged": { + "description": "Bounded current-text recovery: recoveryChanged." + }, + "recoveryCurrentText": "Current session text — copy it before replacing", + "@recoveryCurrentText": {"description": "Current volatile text heading during recovery retry."}, + "recoveryCopyCurrent": "Copy current text", + "@recoveryCopyCurrent": {"description": "Explicit copy of live text before a recovery choice."}, + "recoveryStoredText": "Stored recovery copy", + "@recoveryStoredText": {"description": "Stored candidate heading beside volatile text."}, + "recoveryVolatileReplaceDetail": "The current draft and previous recovery storage have been kept. Cancel to keep working on the current text, or explicitly replace it in a temporary session without recovery. Copy any text you need first.", + "@recoveryVolatileReplaceDetail": {"description": "Separate choice after durable replacement fails."}, + "runSourceSessionChanged": "The upload to {path} may have completed on the previous board. Run was not sent because the connection changed.", + "@runSourceSessionChanged": { + "description": "Truthful partial result when a board attachment changes after PUT but before Run.", + "placeholders": {"path": {"type": "String", "example": "/main.py"}} + }, + "recoveryVolatileShort": "Recovery off: edits stay in memory.", + "@recoveryVolatileShort": {"description": "Concise persistent volatile warning for compact layouts."}, + "recoveryClose": "Close", + "@recoveryClose": {"description": "Closes local recovery details without changing storage or text."}, + "recoveryKeepCurrent": "Keep current text without recovery", + "@recoveryKeepCurrent": {"description": "Keeps the current volatile text and existing stored copy after retry, with no replacement."} } diff --git a/app/lib/localization/gen/app_localizations.dart b/app/lib/localization/gen/app_localizations.dart index 7d83d6ad..c8677ecc 100644 --- a/app/lib/localization/gen/app_localizations.dart +++ b/app/lib/localization/gen/app_localizations.dart @@ -2990,6 +2990,300 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Downloaded files are not opened or run automatically.'** String get githubImportNoAutomaticRun; + + /// Bounded current-text recovery: recoveryTitle. + /// + /// In en, this message translates to: + /// **'Recover your current text draft'** + String get recoveryTitle; + + /// Bounded current-text recovery: recoveryRestoredTitle. + /// + /// In en, this message translates to: + /// **'Your current text draft'** + String get recoveryRestoredTitle; + + /// Bounded current-text recovery: recoveryLocalOnly. + /// + /// In en, this message translates to: + /// **'This copy stays on this device. Restoring does not connect, upload or run code. Choose and confirm a board target again before writing.'** + String get recoveryLocalOnly; + + /// Bounded current-text recovery: recoveryCheckpointTime. + /// + /// In en, this message translates to: + /// **'Recovery checkpoint: {time}'** + String recoveryCheckpointTime(String time); + + /// Bounded current-text recovery: recoveryScope. + /// + /// In en, this message translates to: + /// **'One current text draft only. Blocks workspaces, other files and projects are not included. Pending edits may be lost if the app stops before saving locally.'** + String get recoveryScope; + + /// Bounded current-text recovery: recoveryRestore. + /// + /// In en, this message translates to: + /// **'Restore'** + String get recoveryRestore; + + /// Bounded current-text recovery: recoveryDiscard. + /// + /// In en, this message translates to: + /// **'Discard recovery copy'** + String get recoveryDiscard; + + /// Bounded current-text recovery: recoveryCopy. + /// + /// In en, this message translates to: + /// **'Copy text'** + String get recoveryCopy; + + /// Bounded current-text recovery: recoveryCopied. + /// + /// In en, this message translates to: + /// **'Text copied.'** + String get recoveryCopied; + + /// Bounded current-text recovery: recoveryConnect. + /// + /// In en, this message translates to: + /// **'Connect a board'** + String get recoveryConnect; + + /// Bounded current-text recovery: recoveryDiscardTitle. + /// + /// In en, this message translates to: + /// **'Discard this recovery copy?'** + String get recoveryDiscardTitle; + + /// Bounded current-text recovery: recoveryDiscardDetail. + /// + /// In en, this message translates to: + /// **'This deletes the single local recovery copy and starts an empty draft. It cannot be undone. No board file is changed.'** + String get recoveryDiscardDetail; + + /// Bounded current-text recovery: recoveryBoardTargetTitle. + /// + /// In en, this message translates to: + /// **'Confirm the current board target'** + String get recoveryBoardTargetTitle; + + /// Bounded current-text recovery: recoveryBoardTargetRunDetail. + /// + /// In en, this message translates to: + /// **'Upload this recovered text to {path} on the currently connected board, replacing any file there, then run it? The previous board path does not authorize this action.'** + String recoveryBoardTargetRunDetail(String path); + + /// Bounded current-text recovery: recoveryBoardTargetSaveDetail. + /// + /// In en, this message translates to: + /// **'Upload this recovered text to {path} on the currently connected board, replacing any file there? This action does not run code. The previous board path does not authorize this action.'** + String recoveryBoardTargetSaveDetail(String path); + + /// Bounded current-text recovery: recoveryBoardTargetRun. + /// + /// In en, this message translates to: + /// **'Upload and run'** + String get recoveryBoardTargetRun; + + /// Bounded current-text recovery: recoveryBoardTargetSave. + /// + /// In en, this message translates to: + /// **'Upload to board'** + String get recoveryBoardTargetSave; + + /// Bounded current-text recovery: recoveryReplaceTitle. + /// + /// In en, this message translates to: + /// **'Replace the current draft?'** + String get recoveryReplaceTitle; + + /// Bounded current-text recovery: recoveryReplaceDetail. + /// + /// In en, this message translates to: + /// **'This replaces the current text and its single local recovery copy. There is no draft history. Copy any text you want to keep before continuing.'** + String get recoveryReplaceDetail; + + /// Bounded current-text recovery: recoveryReplace. + /// + /// In en, this message translates to: + /// **'Replace draft'** + String get recoveryReplace; + + /// Bounded current-text recovery: recoveryLoading. + /// + /// In en, this message translates to: + /// **'Opening local recovery storage…'** + String get recoveryLoading; + + /// Bounded current-text recovery: recoveryTimeout. + /// + /// In en, this message translates to: + /// **'Local recovery storage has not responded. Retry, or continue with a temporary draft. Your existing storage will be preserved.'** + String get recoveryTimeout; + + /// Bounded current-text recovery: recoveryUnavailable. + /// + /// In en, this message translates to: + /// **'Local recovery is unavailable. Your existing storage has been preserved.'** + String get recoveryUnavailable; + + /// Bounded current-text recovery: recoveryErrorStorage. + /// + /// In en, this message translates to: + /// **'The recovery copy could not be read or written. Check device storage and try again.'** + String get recoveryErrorStorage; + + /// Bounded current-text recovery: recoveryErrorCorrupt. + /// + /// In en, this message translates to: + /// **'The recovery storage is damaged or contains invalid text or metadata. It has been preserved.'** + String get recoveryErrorCorrupt; + + /// Bounded current-text recovery: recoveryErrorUnsupported. + /// + /// In en, this message translates to: + /// **'This recovery copy needs a compatible app version. Its storage has been preserved.'** + String get recoveryErrorUnsupported; + + /// Bounded current-text recovery: recoveryErrorTooLarge. + /// + /// In en, this message translates to: + /// **'This draft exceeds the 1 MiB recovery limit. Its current text is only in memory; the previous recovery copy is preserved.'** + String get recoveryErrorTooLarge; + + /// Bounded current-text recovery: recoveryErrorInvalid. + /// + /// In en, this message translates to: + /// **'The current text or filename cannot be saved as a recovery copy. Its text remains in memory; the previous copy is preserved.'** + String get recoveryErrorInvalid; + + /// Bounded current-text recovery: recoveryErrorConflict. + /// + /// In en, this message translates to: + /// **'Recovery storage changed unexpectedly. Retry and review the available text before replacing it.'** + String get recoveryErrorConflict; + + /// Bounded current-text recovery: recoveryRetry. + /// + /// In en, this message translates to: + /// **'Retry'** + String get recoveryRetry; + + /// Bounded current-text recovery: recoveryContinue. + /// + /// In en, this message translates to: + /// **'Continue without recovery'** + String get recoveryContinue; + + /// Bounded current-text recovery: recoveryVolatile. + /// + /// In en, this message translates to: + /// **'Recovery is off for this session. Current edits are only in memory. Existing recovery storage is preserved.'** + String get recoveryVolatile; + + /// Bounded current-text recovery: recoveryPending. + /// + /// In en, this message translates to: + /// **'Recovery copy pending'** + String get recoveryPending; + + /// Bounded current-text recovery: recoverySaving. + /// + /// In en, this message translates to: + /// **'Saving recovery copy…'** + String get recoverySaving; + + /// Bounded current-text recovery: recoverySaved. + /// + /// In en, this message translates to: + /// **'Saved locally'** + String get recoverySaved; + + /// Bounded current-text recovery: recoverySave. + /// + /// In en, this message translates to: + /// **'Save recovery copy'** + String get recoverySave; + + /// Bounded current-text recovery: recoverySavedFeedback. + /// + /// In en, this message translates to: + /// **'The requested text revision was saved locally.'** + String get recoverySavedFeedback; + + /// Bounded current-text recovery: recoverySaveFailed. + /// + /// In en, this message translates to: + /// **'Recovery copy not saved. Your current text remains in memory.'** + String get recoverySaveFailed; + + /// Bounded current-text recovery: recoveryOpen. + /// + /// In en, this message translates to: + /// **'View local draft'** + String get recoveryOpen; + + /// Bounded current-text recovery: recoveryNewFailed. + /// + /// In en, this message translates to: + /// **'The new draft could not replace the recovery copy. Your current draft has been kept.'** + String get recoveryNewFailed; + + /// Bounded current-text recovery: recoveryChanged. + /// + /// In en, this message translates to: + /// **'The current draft changed. Review it and confirm again.'** + String get recoveryChanged; + + /// Current volatile text heading during recovery retry. + /// + /// In en, this message translates to: + /// **'Current session text — copy it before replacing'** + String get recoveryCurrentText; + + /// Explicit copy of live text before a recovery choice. + /// + /// In en, this message translates to: + /// **'Copy current text'** + String get recoveryCopyCurrent; + + /// Stored candidate heading beside volatile text. + /// + /// In en, this message translates to: + /// **'Stored recovery copy'** + String get recoveryStoredText; + + /// Separate choice after durable replacement fails. + /// + /// In en, this message translates to: + /// **'The current draft and previous recovery storage have been kept. Cancel to keep working on the current text, or explicitly replace it in a temporary session without recovery. Copy any text you need first.'** + String get recoveryVolatileReplaceDetail; + + /// Truthful partial result when a board attachment changes after PUT but before Run. + /// + /// In en, this message translates to: + /// **'The upload to {path} may have completed on the previous board. Run was not sent because the connection changed.'** + String runSourceSessionChanged(String path); + + /// Concise persistent volatile warning for compact layouts. + /// + /// In en, this message translates to: + /// **'Recovery off: edits stay in memory.'** + String get recoveryVolatileShort; + + /// Closes local recovery details without changing storage or text. + /// + /// In en, this message translates to: + /// **'Close'** + String get recoveryClose; + + /// Keeps the current volatile text and existing stored copy after retry, with no replacement. + /// + /// In en, this message translates to: + /// **'Keep current text without recovery'** + String get recoveryKeepCurrent; } class _AppLocalizationsDelegate diff --git a/app/lib/localization/gen/app_localizations_en.dart b/app/lib/localization/gen/app_localizations_en.dart index 308b8d01..4aa54e9a 100644 --- a/app/lib/localization/gen/app_localizations_en.dart +++ b/app/lib/localization/gen/app_localizations_en.dart @@ -1843,4 +1843,178 @@ class AppLocalizationsEn extends AppLocalizations { @override String get githubImportNoAutomaticRun => 'Downloaded files are not opened or run automatically.'; + + @override + String get recoveryTitle => 'Recover your current text draft'; + + @override + String get recoveryRestoredTitle => 'Your current text draft'; + + @override + String get recoveryLocalOnly => + 'This copy stays on this device. Restoring does not connect, upload or run code. Choose and confirm a board target again before writing.'; + + @override + String recoveryCheckpointTime(String time) { + return 'Recovery checkpoint: $time'; + } + + @override + String get recoveryScope => + 'One current text draft only. Blocks workspaces, other files and projects are not included. Pending edits may be lost if the app stops before saving locally.'; + + @override + String get recoveryRestore => 'Restore'; + + @override + String get recoveryDiscard => 'Discard recovery copy'; + + @override + String get recoveryCopy => 'Copy text'; + + @override + String get recoveryCopied => 'Text copied.'; + + @override + String get recoveryConnect => 'Connect a board'; + + @override + String get recoveryDiscardTitle => 'Discard this recovery copy?'; + + @override + String get recoveryDiscardDetail => + 'This deletes the single local recovery copy and starts an empty draft. It cannot be undone. No board file is changed.'; + + @override + String get recoveryBoardTargetTitle => 'Confirm the current board target'; + + @override + String recoveryBoardTargetRunDetail(String path) { + return 'Upload this recovered text to $path on the currently connected board, replacing any file there, then run it? The previous board path does not authorize this action.'; + } + + @override + String recoveryBoardTargetSaveDetail(String path) { + return 'Upload this recovered text to $path on the currently connected board, replacing any file there? This action does not run code. The previous board path does not authorize this action.'; + } + + @override + String get recoveryBoardTargetRun => 'Upload and run'; + + @override + String get recoveryBoardTargetSave => 'Upload to board'; + + @override + String get recoveryReplaceTitle => 'Replace the current draft?'; + + @override + String get recoveryReplaceDetail => + 'This replaces the current text and its single local recovery copy. There is no draft history. Copy any text you want to keep before continuing.'; + + @override + String get recoveryReplace => 'Replace draft'; + + @override + String get recoveryLoading => 'Opening local recovery storage…'; + + @override + String get recoveryTimeout => + 'Local recovery storage has not responded. Retry, or continue with a temporary draft. Your existing storage will be preserved.'; + + @override + String get recoveryUnavailable => + 'Local recovery is unavailable. Your existing storage has been preserved.'; + + @override + String get recoveryErrorStorage => + 'The recovery copy could not be read or written. Check device storage and try again.'; + + @override + String get recoveryErrorCorrupt => + 'The recovery storage is damaged or contains invalid text or metadata. It has been preserved.'; + + @override + String get recoveryErrorUnsupported => + 'This recovery copy needs a compatible app version. Its storage has been preserved.'; + + @override + String get recoveryErrorTooLarge => + 'This draft exceeds the 1 MiB recovery limit. Its current text is only in memory; the previous recovery copy is preserved.'; + + @override + String get recoveryErrorInvalid => + 'The current text or filename cannot be saved as a recovery copy. Its text remains in memory; the previous copy is preserved.'; + + @override + String get recoveryErrorConflict => + 'Recovery storage changed unexpectedly. Retry and review the available text before replacing it.'; + + @override + String get recoveryRetry => 'Retry'; + + @override + String get recoveryContinue => 'Continue without recovery'; + + @override + String get recoveryVolatile => + 'Recovery is off for this session. Current edits are only in memory. Existing recovery storage is preserved.'; + + @override + String get recoveryPending => 'Recovery copy pending'; + + @override + String get recoverySaving => 'Saving recovery copy…'; + + @override + String get recoverySaved => 'Saved locally'; + + @override + String get recoverySave => 'Save recovery copy'; + + @override + String get recoverySavedFeedback => + 'The requested text revision was saved locally.'; + + @override + String get recoverySaveFailed => + 'Recovery copy not saved. Your current text remains in memory.'; + + @override + String get recoveryOpen => 'View local draft'; + + @override + String get recoveryNewFailed => + 'The new draft could not replace the recovery copy. Your current draft has been kept.'; + + @override + String get recoveryChanged => + 'The current draft changed. Review it and confirm again.'; + + @override + String get recoveryCurrentText => + 'Current session text — copy it before replacing'; + + @override + String get recoveryCopyCurrent => 'Copy current text'; + + @override + String get recoveryStoredText => 'Stored recovery copy'; + + @override + String get recoveryVolatileReplaceDetail => + 'The current draft and previous recovery storage have been kept. Cancel to keep working on the current text, or explicitly replace it in a temporary session without recovery. Copy any text you need first.'; + + @override + String runSourceSessionChanged(String path) { + return 'The upload to $path may have completed on the previous board. Run was not sent because the connection changed.'; + } + + @override + String get recoveryVolatileShort => 'Recovery off: edits stay in memory.'; + + @override + String get recoveryClose => 'Close'; + + @override + String get recoveryKeepCurrent => 'Keep current text without recovery'; } diff --git a/app/lib/main.dart b/app/lib/main.dart index 832926e2..5c565c51 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -24,6 +24,10 @@ import 'package:pyble/app/app_info.dart'; import 'package:pyble/app/licenses.dart'; import 'package:pyble/app/providers.dart'; import 'package:pyble/pble/pble.dart'; +import 'package:pyble/data/draft_repository_factory.dart'; +import 'package:pyble/editor/draft_recovery.dart'; +import 'package:pyble/editor/board_target_guard.dart'; +import 'package:pyble/editor/recovery_panel.dart'; void main() { // The production manager constructs the fbp stack whose readiness/scan/connect @@ -34,6 +38,28 @@ void main() { runApp( ProviderScope( overrides: [ + draftRepositoryFactoryProvider.overrideWithValue(openDraftRepository), + boardTargetConfirmationProvider.overrideWithValue((request) async { + final BuildContext? context = appNavigatorKey.currentContext; + if (context == null) return false; + return confirmRecoveryBoardTarget( + context, + path: request.path, + run: request.run, + ); + }), + editorVolatileReplacementConfirmationProvider.overrideWithValue( + () async { + final BuildContext? context = appNavigatorKey.currentContext; + if (context == null) return false; + return confirmVolatileReplacement(context); + }, + ), + editorReplacementConfirmationProvider.overrideWithValue(() async { + final BuildContext? context = appNavigatorKey.currentContext; + if (context == null) return false; + return confirmRecoveryReplacement(context); + }), // The REAL runtime session (ADR-0009). `.production` builds // FbpBleAdapter + FbpBleReadinessSource + the accumulating Scanner and // the `PbleConnection.fromLink` connect factory internally, so the diff --git a/app/pubspec.lock b/app/pubspec.lock index d0a6632c..648fbcd9 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -1,6 +1,22 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: cd6add6f846f35fb79f3c315296703c1a24f3cfd7f4739d91a74961c1c7e9f1b + url: "https://pub.dev" + source: hosted + version: "100.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "6ba98576948803398b69e3a444df24eacdbe12ed699c7014e120ea38552debbf" + url: "https://pub.dev" + source: hosted + version: "13.0.0" args: dependency: transitive description: @@ -41,6 +57,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: "45d14a0fb23e018d8287c32fc98d726ce466b231928ed9b9200f29bd3ccd39ae" + url: "https://pub.dev" + source: hosted + version: "4.0.7" + build_config: + dependency: transitive + description: + name: build_config + sha256: d466ed2dc9c6cd1d169948879b84ee061eb5e22c64a7c6089879c6296d272a8d + url: "https://pub.dev" + source: hosted + version: "1.3.3" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: "39ab65f7c60c19af64b9ef96e6a5d3ab48355f7dddfd98a9079d55a8cb6628fc" + url: "https://pub.dev" + source: hosted + version: "4.1.7" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "5367e521935b102bdf1e735d2aab461e36b2edca6517662d088dd04cc39f8d16" + url: "https://pub.dev" + source: hosted + version: "2.15.1" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "44922ecebd8e3d0cef743dadd3b54df4e87e2a4c52a29465a1872aa9e192caa8" + url: "https://pub.dev" + source: hosted + version: "5.1.2" + built_value: + dependency: transitive + description: + name: built_value + sha256: f87ea98192116f7093cb214551ce1929caae0681fdba282b3d8b4462adee7bb7 + url: "https://pub.dev" + source: hosted + version: "8.13.0" characters: dependency: transitive description: @@ -57,6 +121,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: "89b8801ba49f8b593fbf2d3e59efd0f566c46cd4ba6837ae2610c72b52a0f899" + url: "https://pub.dev" + source: hosted + version: "0.6.0" clock: dependency: transitive description: @@ -65,6 +145,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" collection: dependency: transitive description: @@ -73,14 +161,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" - crypto: + convert: dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + crypto: + dependency: "direct main" description: name: crypto - sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + sha256: e3626ae6270383c9e3a549f34c9e4cd26c09ae6a9adaab2bf4737a00340efdc4 url: "https://pub.dev" source: hosted - version: "3.0.7" + version: "3.0.8" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "59d53ef8eaed9d288ed9767618e2b31c4fa0383a127db59d5eb2e737a7638a60" + url: "https://pub.dev" + source: hosted + version: "3.1.9" dbus: dependency: transitive description: @@ -89,6 +193,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.14" + drift: + dependency: "direct main" + description: + name: drift + sha256: "3179e7323b36d1852b42997b92ac72e8abcb61b1d58cfa8c6da9b540ab02a540" + url: "https://pub.dev" + source: hosted + version: "2.35.3" + drift_dev: + dependency: "direct dev" + description: + name: drift_dev + sha256: ff4201b38dfbbbc85ffcd85597ad63c7146d2303126241623ebfce89fc4c4bfe + url: "https://pub.dev" + source: hosted + version: "2.35.2" equatable: dependency: transitive description: @@ -129,6 +249,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -239,6 +367,22 @@ packages: description: flutter source: sdk version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" highlight: dependency: "direct main" description: @@ -255,6 +399,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.3" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" http: dependency: "direct main" description: @@ -263,6 +415,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: "361668bdd71f0ae2dc6d17b75a593520f3c414af6534f9eed1aa72049dd71d18" + url: "https://pub.dev" + source: hosted + version: "3.2.3" http_parser: dependency: transitive description: @@ -284,6 +444,46 @@ packages: url: "https://pub.dev" source: hosted version: "0.20.2" + io: + dependency: transitive + description: + name: io + sha256: "2635216ca6a737e60de577ffa1a48a0bec76ca8a62917cfc1bb88c14c570646f" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + jni: + dependency: transitive + description: + name: jni + sha256: d8df6dab6847855512fe44dd9032b0bbe34dda7bf7597e41d30750879779813f + url: "https://pub.dev" + source: hosted + version: "1.1.1" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "0dd33d67797560f66e961a6053882d4df54bd345bf063b8fe7ea133f7ad8b191" + url: "https://pub.dev" + source: hosted + version: "1.0.4+1" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" leak_tracker: dependency: transitive description: @@ -324,6 +524,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: @@ -348,6 +556,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.18.0" + mime: + dependency: transitive + description: + name: mime + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 + url: "https://pub.dev" + source: hosted + version: "2.1.0" mocktail: dependency: transitive description: @@ -356,6 +572,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: f9c168717100ae6d9fee9ffb0be379bf1f8b26b0f6bcbd4fdddcd931993a6a72 + url: "https://pub.dev" + source: hosted + version: "0.19.2" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e + url: "https://pub.dev" + source: hosted + version: "9.5.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" package_info_plus: dependency: "direct main" description: @@ -380,6 +620,54 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + 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: @@ -404,6 +692,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "4177f68c237ea2128d1bee66ac17b2ce05ba3dbaafcbdd54c5d40a39d0b6b11c" + url: "https://pub.dev" + source: hosted + version: "1.5.3" process: dependency: transitive description: @@ -412,6 +708,38 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.5" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "9ac81c01ffd6a9cf9b2713288834fbaaf6aebde05ffdf02d380e68b8e06f27d8" + url: "https://pub.dev" + source: hosted + version: "1.7.0" + recase: + dependency: transitive + description: + name: recase + sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213 + url: "https://pub.dev" + source: hosted + version: "4.1.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" riverpod: dependency: transitive description: @@ -436,11 +764,35 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.8" + shelf: + dependency: transitive + description: + name: shelf + sha256: e75e9cf311d7fd712048038cc17c25199c38536c20330d53e7db058b543d5a48 + url: "https://pub.dev" + source: hosted + version: "1.4.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: a603f1fb984a7391ae5978d1b92bfaaa08b350dca5c825256f925818f7943bf5 + url: "https://pub.dev" + source: hosted + version: "4.2.4" source_span: dependency: transitive description: @@ -449,6 +801,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.2" + sqlite3: + dependency: "direct main" + description: + name: sqlite3 + sha256: c73fd75df1332d76a6257f4823ae4df9c791f522b97e4a60cbcad214de1becf4 + url: "https://pub.dev" + source: hosted + version: "3.5.0" + sqlparser: + dependency: transitive + description: + name: sqlparser + sha256: "8121dae2c5de4a1e39153d95c55f7fd1816e32d1232fe270bc415a4a370d6e47" + url: "https://pub.dev" + source: hosted + version: "0.45.1" stack_trace: dependency: transitive description: @@ -473,6 +841,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: a00e5f18bffc764f923e7dec1038527f7fe7a1791361a7117f0358193f13d53a + url: "https://pub.dev" + source: hosted + version: "2.1.2" string_scanner: dependency: transitive description: @@ -601,6 +977,14 @@ packages: url: "https://pub.dev" source: hosted version: "15.2.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" web: dependency: transitive description: @@ -609,6 +993,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" webdriver: dependency: transitive description: @@ -657,6 +1057,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.3.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: @@ -665,6 +1073,22 @@ packages: url: "https://pub.dev" source: hosted version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea + url: "https://pub.dev" + source: hosted + version: "3.1.4" + yaml_edit: + dependency: transitive + description: + name: yaml_edit + sha256: "07c9e63ba42519745182b88ca12264a7ba2484d8239958778dfe4d44fe760488" + url: "https://pub.dev" + source: hosted + version: "2.2.4" sdks: dart: ">=3.12.1 <4.0.0" flutter: ">=3.44.0" diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 449c3422..c7e56652 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -14,7 +14,7 @@ publish_to: 'none' # SemVer app version. Pre-1.0 through milestones M0–M3; v1.0.0 ships at the # at-parity App Store + Play release (story X-11). Build number after '+'. -version: 0.2.0+8 +version: 0.3.0+9 environment: sdk: ^3.12.1 @@ -72,6 +72,15 @@ dependencies: # have an explicit direct constraint under BLD-2. highlight: ^0.7.0 + # Bounded on-device current-text recovery (PYB-006.2 / ADR-0048). + # Drift MIT; sqlite3 MIT (SQLite public-domain amalgamation via build hooks); + # crypto BSD-3-Clause; path_provider BSD-3-Clause. One app-private store, + # background database connection and no remote sync or scratch-file backend. + drift: ^2.35.3 + sqlite3: 3.5.0 + crypto: ^3.0.8 + path_provider: ^2.1.6 + # Offline Blockly host (A-31). License: BSD-3-Clause. Min version: 4.14.1, # which is compatible with PyBLE's Android 24 / iOS 15 platform floors and # whose WebViewController supports loadFlutterAsset + JavaScript channels. @@ -87,6 +96,11 @@ dev_dependencies: integration_test: sdk: flutter + # Drift schema generation/export and migration verification (MIT), + # build_runner BSD-3-Clause. Build-time only, never app runtime. + drift_dev: ^2.35.2 + build_runner: ^2.15.1 + # Recommended Flutter lint set, activated via analysis_options.yaml. # License: BSD-3-Clause. Min version: 6.0.0 (toolchain default for the # pinned Flutter 3.44.1). diff --git a/app/test/data/draft_repository_test.dart b/app/test/data/draft_repository_test.dart new file mode 100644 index 00000000..41267d03 --- /dev/null +++ b/app/test/data/draft_repository_test.dart @@ -0,0 +1,532 @@ +// SPDX-License-Identifier: MIT +// Part of PyBLE (https://pyble.dev) — see /LICENSE. + +import 'dart:io'; +import 'dart:convert'; + +import 'package:drift/drift.dart' show driftRuntimeOptions; +import 'package:drift/native.dart' show NativeDatabase; +import 'package:sqlite3/sqlite3.dart' as sqlite; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pyble/data/draft_repository.dart'; +import 'package:pyble/data/drift_draft_repository.dart'; + +void main() { + late Directory directory; + late File file; + late DriftDraftRepository repository; + final DateTime checkpoint = DateTime.utc(2026, 10, 31, 12, 34, 56, 123); + + DraftSnapshot snapshot( + String content, { + int generation = 1, + String name = 'main.py', + String? originPath, + }) => DraftSnapshot( + name: name, + content: content, + generation: generation, + checkpointTime: checkpoint, + originPath: originPath, + ); + + Matcher failure(DraftStorageFailure reason) => throwsA( + isA().having( + (DraftStorageException e) => e.reason, + 'reason', + reason, + ), + ); + + setUpAll(() { + // Explicitly exercise separate competing connections to the same file. + driftRuntimeOptions.dontWarnAboutMultipleDatabases = true; + }); + + setUp(() async { + directory = await Directory.systemTemp.createTemp('pyble-draft-'); + file = File('${directory.path}/draft.sqlite'); + repository = await DriftDraftRepository.open(file); + }); + tearDown(() async { + await repository.close(); + await directory.delete(recursive: true); + }); + + test('first creation is an empty generation-zero slot', () async { + final DraftRead read = await repository.load(); + expect(read.generation, 0); + expect(read.snapshot, isNull); + }); + + test( + 'exact Unicode, whitespace, metadata survive closing and reopening', + () async { + const String source = '# café 🐍\r\nprint(“text”)\n\t \n'; + await repository.write( + snapshot(source, originPath: '/lib/main.py'), + expectedGeneration: 0, + ); + await repository.close(); + repository = await DriftDraftRepository.open(file); + final DraftRead read = await repository.load(); + expect(read.generation, 1); + expect(read.snapshot!.content, source); + expect(read.snapshot!.name, 'main.py'); + expect(read.snapshot!.originPath, '/lib/main.py'); + expect(read.snapshot!.checkpointTime, checkpoint); + }, + ); + + test('empty named file remains recoverable', () async { + await repository.write(snapshot(''), expectedGeneration: 0); + expect((await repository.load()).snapshot!.content, ''); + }); + + test( + 'clear persists a newer empty tombstone and rejects stale writes', + () async { + await repository.write(snapshot('first'), expectedGeneration: 0); + expect(await repository.clear(expectedGeneration: 1), 2); + await expectLater( + repository.write( + snapshot('stale', generation: 3), + expectedGeneration: 1, + ), + failure(DraftStorageFailure.conflict), + ); + await repository.close(); + repository = await DriftDraftRepository.open(file); + expect((await repository.load()).generation, 2); + expect((await repository.load()).snapshot, isNull); + }, + ); + + test('generation cannot regress or reuse the committed revision', () async { + await repository.write(snapshot('first'), expectedGeneration: 0); + await expectLater( + repository.write(snapshot('older'), expectedGeneration: 1), + failure(DraftStorageFailure.conflict), + ); + expect((await repository.load()).snapshot!.content, 'first'); + }); + + test( + 'two writers cannot overwrite a checkpoint using the same predecessor', + () async { + final DriftDraftRepository other = await DriftDraftRepository.open(file); + try { + await repository.write(snapshot('winner'), expectedGeneration: 0); + await expectLater( + other.write(snapshot('loser'), expectedGeneration: 0), + failure(DraftStorageFailure.conflict), + ); + expect((await other.load()).snapshot!.content, 'winner'); + } finally { + await other.close(); + } + }, + ); + + test( + 'UTF-8 admission accepts exact limit and refuses one byte over', + () async { + await repository.write( + snapshot('é' * (1024 * 1024 ~/ 2)), + expectedGeneration: 0, + ); + await expectLater( + repository.write( + snapshot('${'a' * (1024 * 1024)}x', generation: 2), + expectedGeneration: 1, + ), + failure(DraftStorageFailure.tooLarge), + ); + expect((await repository.load()).generation, 1); + }, + ); + + test('128 UTF-8 byte filename is accepted without renaming', () async { + final String name = 'é' * 64; + await repository.write(snapshot('x', name: name), expectedGeneration: 0); + expect((await repository.load()).snapshot!.name, name); + }); + + test( + 'one byte content and surrogate pairs round-trip without normalization', + () async { + await repository.write(snapshot('a'), expectedGeneration: 0); + await repository.write( + snapshot('🐍', generation: 2), + expectedGeneration: 1, + ); + expect((await repository.load()).snapshot!.content, '🐍'); + }, + ); + + test('unpaired UTF-16 is refused without losing prior checkpoint', () async { + await repository.write(snapshot('safe'), expectedGeneration: 0); + await expectLater( + repository.write( + snapshot(String.fromCharCode(0xd800), generation: 2), + expectedGeneration: 1, + ), + failure(DraftStorageFailure.invalidText), + ); + expect((await repository.load()).snapshot!.content, 'safe'); + }); + + test('unsafe filenames and historical paths are refused', () async { + for (final String name in [ + '', + '.', + '..', + '../x.py', + 'a/b.py', + 'a\\b.py', + 'a\n.py', + 'é' * 65, + ]) { + await expectLater( + repository.write(snapshot('x', name: name), expectedGeneration: 0), + failure(DraftStorageFailure.invalidMetadata), + ); + } + for (final String path in [ + 'relative.py', + '/../x.py', + '/a/../b.py', + '/a\n.py', + '/${'a' * 128}', + ]) { + await expectLater( + repository.write( + snapshot('x', originPath: path), + expectedGeneration: 0, + ), + failure(DraftStorageFailure.invalidMetadata), + ); + } + expect((await repository.load()).snapshot, isNull); + }); + + test('production connection uses WAL and synchronous FULL', () async { + await repository.write(snapshot('safe'), expectedGeneration: 0); + final sqlite.Database db = sqlite.sqlite3.open(file.path); + try { + expect(db.select('PRAGMA journal_mode').single.values.single, 'wal'); + // synchronous is connection-local; production verifies it at each open. + // A new connection starts at FULL, and the persistent mode remains WAL. + expect(db.select('PRAGMA synchronous').single.values.single, 2); + } finally { + db.close(); + } + }); + + test('schema fixture recreates the exact v1 tables', () async { + final Map exported = + jsonDecode( + await File('drift_schemas/drift_schema_v1.json').readAsString(), + ) + as Map; + final sqlite.Database db = sqlite.sqlite3.open(file.path); + try { + final List actual = db + .select( + "SELECT sql FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name", + ) + .map((sqlite.Row row) => row['sql'] as String) + .toList(); + final sqlite.Database fixture = sqlite.sqlite3.openInMemory(); + try { + for (final dynamic entry in exported['fixed_sql'] as List) { + for (final dynamic statement in entry['sql'] as List) { + fixture.execute(statement['sql'] as String); + } + } + final List expected = fixture + .select( + "SELECT sql FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name", + ) + .map((sqlite.Row row) => row['sql'] as String) + .toList(); + expect(actual..sort(), expected..sort()); + } finally { + fixture.close(); + } + expect(db.userVersion, 1); + } finally { + db.close(); + } + }); + + test( + 'bad content hash refuses hydration without deleting database', + () async { + await repository.write(snapshot('safe'), expectedGeneration: 0); + await repository.close(); + final sqlite.Database db = sqlite.sqlite3.open(file.path); + db.execute("UPDATE project_files SET content_hash='wrong'"); + db.close(); + final List before = await file.readAsBytes(); + await expectLater( + DriftDraftRepository.open(file), + failure(DraftStorageFailure.corrupt), + ); + expect(await file.readAsBytes(), before); + }, + ); + + test('invalid metadata and unknown recovery format fail closed', () async { + await repository.close(); + final sqlite.Database db = sqlite.sqlite3.open(file.path); + db.execute('UPDATE settings SET value=?', [ + '{"version":2,"generation":0,"empty":true}', + ]); + db.close(); + await expectLater( + DriftDraftRepository.open(file), + failure(DraftStorageFailure.unsupportedVersion), + ); + final sqlite.Database corrupt = sqlite.sqlite3.open(file.path); + corrupt.execute('UPDATE settings SET value=?', ['broken']); + corrupt.close(); + await expectLater( + DriftDraftRepository.open(file), + failure(DraftStorageFailure.corrupt), + ); + expect(await file.exists(), isTrue); + }); + + test('newer database schema is refused without downgrade or reset', () async { + await repository.write(snapshot('safe'), expectedGeneration: 0); + await repository.close(); + final sqlite.Database db = sqlite.sqlite3.open(file.path); + db.userVersion = 99; + db.close(); + final List before = await file.readAsBytes(); + await expectLater( + DriftDraftRepository.open(file), + failure(DraftStorageFailure.unsupportedVersion), + ); + expect(await file.readAsBytes(), before); + }); + + test('existing zero-version and truncated files are preserved', () async { + await repository.close(); + final sqlite.Database db = sqlite.sqlite3.open(file.path); + db.userVersion = 0; + db.close(); + await expectLater( + DriftDraftRepository.open(file), + failure(DraftStorageFailure.corrupt), + ); + await file.writeAsBytes([83, 81, 76, 105, 116, 101]); + final List before = await file.readAsBytes(); + await expectLater( + DriftDraftRepository.open(file), + failure(DraftStorageFailure.corrupt), + ); + expect(await file.readAsBytes(), before); + }); + + test( + 'metadata transaction abort rolls back content and clear tombstone', + () async { + await repository.write(snapshot('safe'), expectedGeneration: 0); + final sqlite.Database db = sqlite.sqlite3.open(file.path); + db.execute( + "CREATE TRIGGER reject_metadata BEFORE UPDATE ON settings BEGIN SELECT RAISE(ABORT, 'injected'); END", + ); + db.close(); + await expectLater( + repository.write( + snapshot('must rollback', generation: 2), + expectedGeneration: 1, + ), + failure(DraftStorageFailure.unavailable), + ); + await expectLater( + repository.clear(expectedGeneration: 1), + failure(DraftStorageFailure.unavailable), + ); + expect((await repository.load()).snapshot!.content, 'safe'); + expect((await repository.load()).generation, 1); + }, + ); + + test('real SQLITE_FULL keeps prior content and generation', () async { + await repository.write(snapshot('safe'), expectedGeneration: 0); + await repository.close(); + repository = DriftDraftRepository.forTesting( + NativeDatabase( + file, + setup: (db) { + final int pages = + db.select('PRAGMA page_count').single.values.single as int; + db.execute('PRAGMA max_page_count = ${pages + 2}'); + }, + ), + ); + await expectLater( + repository.write( + snapshot('x' * 100000, generation: 2), + expectedGeneration: 1, + ), + failure(DraftStorageFailure.unavailable), + ); + expect((await repository.load()).snapshot!.content, 'safe'); + expect((await repository.load()).generation, 1); + }); + + test('read-only storage rejects writes and preserves checkpoint', () async { + await repository.write(snapshot('safe'), expectedGeneration: 0); + await repository.close(); + repository = DriftDraftRepository.forTesting( + NativeDatabase( + file, + setup: (db) { + db.execute('PRAGMA query_only = ON'); + }, + ), + ); + await expectLater( + repository.write( + snapshot('in memory only', generation: 2), + expectedGeneration: 1, + ), + failure(DraftStorageFailure.unavailable), + ); + await repository.close(); + repository = await DriftDraftRepository.open(file); + expect((await repository.load()).snapshot!.content, 'safe'); + }); + + test( + 'oversized otherwise-valid metadata is refused before hydration', + () async { + await repository.write(snapshot('safe'), expectedGeneration: 0); + await repository.close(); + final sqlite.Database db = sqlite.sqlite3.open(file.path); + final Map metadata = + jsonDecode( + db + .select( + "SELECT value FROM settings WHERE key = 'current_text_draft'", + ) + .single['value'] + as String, + ) + as Map; + metadata['unexpected_padding'] = 'é' * 4096; + db.execute( + "UPDATE settings SET value = ? WHERE key = 'current_text_draft'", + [jsonEncode(metadata)], + ); + db.close(); + final List before = await file.readAsBytes(); + await expectLater( + DriftDraftRepository.open(file), + failure(DraftStorageFailure.corrupt), + ); + expect(await file.readAsBytes(), before); + }, + ); + + test( + 'extra internal project rows are refused without losing storage', + () async { + await repository.close(); + final sqlite.Database db = sqlite.sqlite3.open(file.path); + db.execute( + "INSERT INTO projects (id, name, created_at, updated_at) VALUES ('extra', 'extra', 0, 0)", + ); + db.close(); + final List before = await file.readAsBytes(); + await expectLater( + DriftDraftRepository.open(file), + failure(DraftStorageFailure.corrupt), + ); + expect(await file.readAsBytes(), before); + }, + ); + + test( + 'oversized internal project metadata is refused before hydration', + () async { + await repository.write(snapshot('safe'), expectedGeneration: 0); + await repository.close(); + final sqlite.Database db = sqlite.sqlite3.open(file.path); + db.execute('UPDATE projects SET name = ?', ['x' * 8192]); + db.close(); + final List before = await file.readAsBytes(); + await expectLater( + DriftDraftRepository.open(file), + failure(DraftStorageFailure.corrupt), + ); + expect(await file.readAsBytes(), before); + }, + ); + + test( + 'corrupt text in numeric metadata is refused before row mapping', + () async { + await repository.write(snapshot('safe'), expectedGeneration: 0); + await repository.close(); + final sqlite.Database db = sqlite.sqlite3.open(file.path); + db.execute('UPDATE project_files SET updated_at = ?', [ + 'x' * 8192, + ]); + db.close(); + final List before = await file.readAsBytes(); + await expectLater( + DriftDraftRepository.open(file), + failure(DraftStorageFailure.corrupt), + ); + expect(await file.readAsBytes(), before); + }, + ); + + test( + 'process kill before, during and after commit yields only whole checkpoints', + () async { + for (final String point in ['before', 'during', 'after']) { + if ((await repository.load()).generation == 0) { + await repository.write(snapshot('safe'), expectedGeneration: 0); + } + await repository.close(); + final Process process = await Process.start('dart', [ + 'run', + 'test/data/helpers/crash_writer.dart', + file.path, + point, + ]); + final Future stderr = process.stderr + .transform(utf8.decoder) + .join(); + try { + await process.stdout + .transform(utf8.decoder) + .transform(const LineSplitter()) + .firstWhere((String line) => line == 'READY') + .timeout(const Duration(seconds: 45)); + expect(process.kill(ProcessSignal.sigkill), isTrue); + await process.exitCode; + expect( + (await stderr).replaceAll('Running build hooks...', '').trim(), + isEmpty, + ); + } finally { + process.kill(ProcessSignal.sigkill); + await process.exitCode; + } + repository = await DriftDraftRepository.open(file); + final DraftRead read = await repository.load(); + expect(read.snapshot!.content, point == 'after' ? 'complete' : 'safe'); + expect(read.generation, point == 'after' ? 2 : 1); + } + }, + timeout: const Timeout(Duration(minutes: 3)), + ); +} diff --git a/app/test/data/helpers/crash_writer.dart b/app/test/data/helpers/crash_writer.dart new file mode 100644 index 00000000..4aaf401a --- /dev/null +++ b/app/test/data/helpers/crash_writer.dart @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MIT +// Part of PyBLE (https://pyble.dev) — see /LICENSE. + +// Fault-controlled child writer. The parent kills this process after READY; +// neither graceful disposal nor an app lifecycle callback can flush its work. +import 'dart:convert'; +import 'dart:io'; + +import 'package:pyble/data/draft_repository.dart'; +import 'package:pyble/data/drift_draft_repository.dart'; +import 'package:sqlite3/sqlite3.dart'; + +Future main(List arguments) async { + final File file = File(arguments[0]); + final String point = arguments[1]; + final List retained = []; + if (point == 'during') { + final Database db = sqlite3.open(file.path); + retained.add(db); + db.execute('PRAGMA synchronous = FULL'); + db.execute('BEGIN IMMEDIATE'); + // Deliberately incomplete content/metadata pair. SQLite must roll this + // uncommitted mutation back when the process dies, including its WAL. + db.execute('UPDATE project_files SET content=?', [ + utf8.encode('partial'), + ]); + } else if (point == 'after') { + final DraftRepository repository = await DriftDraftRepository.open(file); + retained.add(repository); + await repository.write( + DraftSnapshot( + name: 'main.py', + content: 'complete', + generation: 2, + checkpointTime: DateTime.utc(2026, 10, 31), + ), + expectedGeneration: 1, + ); + } + stdout.writeln('READY'); + await stdout.flush(); + await stdin.first; + for (final Object resource in retained) { + if (resource is Database) resource.close(); + if (resource is DraftRepository) await resource.close(); + } +} diff --git a/app/test/integration/draft_recovery_integration_test.dart b/app/test/integration/draft_recovery_integration_test.dart new file mode 100644 index 00000000..afb5c00a --- /dev/null +++ b/app/test/integration/draft_recovery_integration_test.dart @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: MIT +// Part of PyBLE (https://pyble.dev) — see /LICENSE. + +import 'dart:io'; + +import 'package:drift/native.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pyble/data/draft_repository.dart'; +import 'package:pyble/data/drift_draft_repository.dart'; +import 'package:pyble/editor/draft_recovery.dart'; +import 'package:pyble/editor/editor_document.dart'; + +void main() { + late Directory directory; + late File file; + final List containers = []; + final List repositories = []; + + ProviderContainer container(DraftRepository repository) { + final ProviderContainer result = ProviderContainer( + overrides: [ + draftRepositoryProvider.overrideWithValue(repository), + ], + ); + containers.add(result); + return result; + } + + Future open() async { + final DriftDraftRepository repository = await DriftDraftRepository.open( + file, + ); + repositories.add(repository); + return repository; + } + + setUp(() async { + directory = await Directory.systemTemp.createTemp( + 'pyble-recovery-integration-', + ); + file = File('${directory.path}/pyble.sqlite'); + }); + tearDown(() async { + for (final ProviderContainer container in containers.reversed) { + // Each container is disposed once below as soon as its app session ends. + // ProviderContainer.dispose is idempotent for teardown fallback. + container.dispose(); + } + containers.clear(); + for (final DraftRepository repository in repositories.reversed) { + await repository.close(); + } + repositories.clear(); + await directory.delete(recursive: true); + }); + + test( + 'checkpoint, reopen, explicit offline restore and New tombstone round-trip', + () async { + const String source = '# café 🐍\r\nprint("exact text")\n\t \n'; + final DriftDraftRepository first = await open(); + final ProviderContainer initial = container(first); + final DraftRecoveryController writer = initial.read( + draftRecoveryProvider.notifier, + ); + await writer.start(); + initial + .read(editorDocumentProvider.notifier) + .openGenerated(name: 'generated.py', content: source); + expect(await writer.saveRecoveryCopy(), isTrue); + expect( + initial.read(draftRecoveryProvider).status, + DraftRecoveryStatus.saved, + ); + expect((await first.load()).snapshot!.content, source); + initial.dispose(); + await first.close(); + + final DriftDraftRepository second = await open(); + final ProviderContainer restarted = container(second); + final DraftRecoveryController reader = restarted.read( + draftRecoveryProvider.notifier, + ); + await reader.start(); + expect( + restarted.read(draftRecoveryProvider).status, + DraftRecoveryStatus.decision, + ); + expect( + restarted.read(editorDocumentProvider), + const EditorDocument.untitled(), + ); + expect(restarted.read(draftRecoveryProvider).candidate!.content, source); + expect(reader.restore(), isTrue); + final EditorDocument restored = restarted.read(editorDocumentProvider); + expect(restored.content, source); + expect(restored.name, 'generated.py'); + expect(restored.dirty, isTrue); + expect(restored.boardPath, isNull); + expect(restarted.read(editorRecoveredDraftProvider), isTrue); + expect( + await reader.replaceDocument( + const EditorDocument.untitled(), + expectedEpoch: restarted.read(editorDocumentEpochProvider), + ), + isTrue, + ); + final int clearedGeneration = (await second.load()).generation; + expect(clearedGeneration, greaterThan(1)); + expect((await second.load()).snapshot, isNull); + restarted.dispose(); + await second.close(); + + final DriftDraftRepository third = await open(); + final ProviderContainer afterNew = container(third); + await afterNew.read(draftRecoveryProvider.notifier).start(); + expect( + afterNew.read(draftRecoveryProvider).status, + DraftRecoveryStatus.saved, + ); + expect(afterNew.read(draftRecoveryProvider).candidate, isNull); + expect( + afterNew.read(editorDocumentProvider), + const EditorDocument.untitled(), + ); + expect((await third.load()).generation, clearedGeneration); + }, + ); + + test( + 'real SQLite full failure preserves live edits and previous recoverable text', + () async { + final DriftDraftRepository first = await open(); + final ProviderContainer initial = container(first); + final DraftRecoveryController writer = initial.read( + draftRecoveryProvider.notifier, + ); + await writer.start(); + initial + .read(editorDocumentProvider.notifier) + .setContent('previous checkpoint'); + expect(await writer.saveRecoveryCopy(), isTrue); + initial.dispose(); + await first.close(); + + final DriftDraftRepository limited = DriftDraftRepository.forTesting( + NativeDatabase( + file, + setup: (db) { + final int pages = + db.select('PRAGMA page_count').single.values.single as int; + db.execute('PRAGMA max_page_count = ${pages + 2}'); + }, + ), + ); + repositories.add(limited); + final ProviderContainer editing = container(limited); + final DraftRecoveryController recovery = editing.read( + draftRecoveryProvider.notifier, + ); + await recovery.start(); + expect(recovery.restore(), isTrue); + final String source = 'x' * 100000; + editing.read(editorDocumentProvider.notifier).setContent(source); + expect(await recovery.saveRecoveryCopy(), isFalse); + expect(editing.read(editorDocumentProvider).content, source); + expect( + editing.read(draftRecoveryProvider).status, + DraftRecoveryStatus.failed, + ); + expect( + editing.read(draftRecoveryProvider).failure, + DraftStorageFailure.unavailable, + ); + expect((await limited.load()).snapshot!.content, 'previous checkpoint'); + editing.dispose(); + await limited.close(); + + final DriftDraftRepository reopened = await open(); + final ProviderContainer restarted = container(reopened); + await restarted.read(draftRecoveryProvider.notifier).start(); + expect( + restarted.read(draftRecoveryProvider).status, + DraftRecoveryStatus.decision, + ); + expect( + restarted.read(draftRecoveryProvider).candidate!.content, + 'previous checkpoint', + ); + }, + ); +} diff --git a/app/test/unit/app_version_contract_test.dart b/app/test/unit/app_version_contract_test.dart index 8906b841..71c4a178 100644 --- a/app/test/unit/app_version_contract_test.dart +++ b/app/test/unit/app_version_contract_test.dart @@ -13,7 +13,7 @@ import 'package:pyble/app/app_info.dart'; import '../support/repo_paths.dart'; void main() { - test('app 0.2.0 open-testing candidate uses monotonic build 8', () { + test('app 0.3.0 recovery candidate uses monotonic build 9', () { final String pubspec = File( '${appPackageRoot().path}/pubspec.yaml', ).readAsStringSync(); @@ -29,8 +29,8 @@ void main() { ); final RegExpMatch declaration = declarations.single; - expect(declaration.group(1), '0.2.0'); - expect(declaration.group(2), '8'); + expect(declaration.group(1), '0.3.0'); + expect(declaration.group(2), '9'); expect( kAppVersion, declaration.group(1), diff --git a/app/test/unit/draft_recovery_test.dart b/app/test/unit/draft_recovery_test.dart new file mode 100644 index 00000000..aeccd29e --- /dev/null +++ b/app/test/unit/draft_recovery_test.dart @@ -0,0 +1,386 @@ +// SPDX-License-Identifier: MIT +// Part of PyBLE (https://pyble.dev) — see /LICENSE. +import 'dart:async'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pyble/data/draft_repository.dart'; +import 'package:pyble/editor/draft_recovery.dart'; +import 'package:pyble/editor/editor_document.dart'; + +class Store implements DraftRepository { + Store({this.snapshot, this.generation = 0}); + DraftSnapshot? snapshot; + int generation; + final List writes = []; + Completer? heldLoad; + Completer? heldWrite; + Completer? heldClear; + DraftStorageFailure? failure; + int loads = 0, clears = 0, active = 0, maxActive = 0; + @override + Future load() async { + loads++; + return heldLoad?.future ?? + DraftRead(generation: generation, snapshot: snapshot); + } + + @override + Future write( + DraftSnapshot value, { + required int expectedGeneration, + }) async { + active++; + if (active > maxActive) maxActive = active; + try { + await heldWrite?.future; + if (failure != null) { + throw DraftStorageException(failure!); + } + if (generation != expectedGeneration || value.generation <= generation) { + throw const DraftStorageException(DraftStorageFailure.conflict); + } + writes.add(value); + snapshot = value; + generation = value.generation; + } finally { + active--; + } + } + + @override + Future clear({required int expectedGeneration}) async { + await heldClear?.future; + if (failure != null) { + throw DraftStorageException(failure!); + } + if (generation != expectedGeneration) { + throw const DraftStorageException(DraftStorageFailure.conflict); + } + clears++; + snapshot = null; + return ++generation; + } + + @override + Future close() async {} +} + +DraftSnapshot saved() => DraftSnapshot( + name: 'blink.py', + content: 'print(“exact”)\n', + originPath: '/lib/blink.py', + generation: 17, + checkpointTime: DateTime.utc(2026, 10, 31), +); +void main() { + ProviderContainer container(Store store) { + final c = ProviderContainer( + overrides: [draftRepositoryProvider.overrideWithValue(store)], + ); + addTearDown(c.dispose); + return c; + } + + DraftRecoveryController recovery(ProviderContainer c) => + c.read(draftRecoveryProvider.notifier); + EditorDocumentController editor(ProviderContainer c) => + c.read(editorDocumentProvider.notifier); + test('existing harnesses have no implicit disk access', () { + final c = ProviderContainer(); + addTearDown(c.dispose); + expect(c.read(draftRecoveryProvider).status, DraftRecoveryStatus.disabled); + }); + test('startup offers committed copy without hydrating or writing', () async { + final s = Store(snapshot: saved(), generation: 17), c = container(s); + await recovery(c).start(); + expect(c.read(draftRecoveryProvider).status, DraftRecoveryStatus.decision); + expect(c.read(draftRecoveryProvider).candidate?.content, saved().content); + expect(c.read(editorDocumentProvider).content, isEmpty); + expect(s.writes, isEmpty); + expect(s.clears, 0); + }); + test( + 'Restore is dirty, unbound and does not delete or rewrite copy', + () async { + final s = Store(snapshot: saved(), generation: 17), c = container(s); + await recovery(c).start(); + expect(recovery(c).restore(), isTrue); + expect(c.read(editorDocumentProvider).content, saved().content); + expect(c.read(editorDocumentProvider).dirty, isTrue); + expect(c.read(editorDocumentProvider).boardPath, isNull); + expect(c.read(editorRecoveredDraftProvider), isTrue); + expect(s.snapshot, isNotNull); + expect(s.writes, isEmpty); + expect(c.read(draftRecoveryProvider).status, DraftRecoveryStatus.saved); + }, + ); + test('failed Discard preserves prompt and stored copy', () async { + final s = Store(snapshot: saved(), generation: 17), c = container(s); + await recovery(c).start(); + s.failure = DraftStorageFailure.unavailable; + expect(await recovery(c).discard(), isFalse); + expect(c.read(draftRecoveryProvider).candidate, isNotNull); + expect(c.read(draftRecoveryProvider).blocksStartup, isTrue); + expect(s.snapshot, isNotNull); + }); + test('Discard commits newer tombstone before clearing decision', () async { + final s = Store(snapshot: saved(), generation: 17), c = container(s); + await recovery(c).start(); + expect(await recovery(c).discard(), isTrue); + expect(s.generation, 18); + expect(s.snapshot, isNull); + expect(c.read(draftRecoveryProvider).blocksStartup, isFalse); + expect(c.read(editorDocumentProvider).content, isEmpty); + }); + test( + 'manual checkpoint acknowledges captured epoch, not later edits', + () async { + final s = Store(), c = container(s); + await recovery(c).start(); + editor(c).setContent('first'); + s.heldWrite = Completer(); + final write = recovery(c).saveRecoveryCopy(); + await Future.delayed(Duration.zero); + editor(c).setContent('newer'); + s.heldWrite!.complete(); + expect(await write, isTrue); + expect(s.snapshot?.content, 'first'); + expect(c.read(draftRecoveryProvider).status, DraftRecoveryStatus.pending); + }, + ); + testWidgets( + 'idle edits checkpoint after 500ms and clean named empty file persists', + (tester) async { + final s = Store(), c = container(s); + await recovery(c).start(); + editor(c).openFromBoard(path: '/empty.py', content: ''); + await tester.pump(const Duration(milliseconds: 499)); + expect(s.writes, isEmpty); + await tester.pump(const Duration(milliseconds: 1)); + await tester.pump(); + expect(s.snapshot?.name, 'empty.py'); + expect(s.snapshot?.content, isEmpty); + expect(c.read(draftRecoveryProvider).status, DraftRecoveryStatus.saved); + }, + ); + testWidgets('continuous edits checkpoint within two seconds', (tester) async { + final s = Store(), c = container(s); + await recovery(c).start(); + for (int i = 0; i < 5; i++) { + editor(c).setContent('edit $i'); + await tester.pump(const Duration(milliseconds: 400)); + } + await tester.pump(); + expect(s.writes, isNotEmpty); + expect(s.maxActive, 1); + }); + test('failed checkpoint retains live text and previous copy', () async { + final s = Store(snapshot: saved(), generation: 17), c = container(s); + await recovery(c).start(); + recovery(c).restore(); + editor(c).setContent('newer'); + s.failure = DraftStorageFailure.tooLarge; + expect(await recovery(c).saveRecoveryCopy(), isFalse); + expect(s.snapshot?.content, saved().content); + expect(c.read(editorDocumentProvider).content, 'newer'); + expect(c.read(draftRecoveryProvider).status, DraftRecoveryStatus.failed); + expect(c.read(draftRecoveryProvider).failure, DraftStorageFailure.tooLarge); + }); + test( + 'volatile continuation fences delayed hydration and protects old storage', + () async { + final s = Store()..heldLoad = Completer(); + final c = container(s); + final startup = recovery(c).start(); + recovery(c).continueWithoutRecovery(); + editor(c).setContent('live'); + s.heldLoad!.complete(DraftRead(generation: 17, snapshot: saved())); + await startup; + expect(c.read(editorDocumentProvider).content, 'live'); + expect( + c.read(draftRecoveryProvider).status, + DraftRecoveryStatus.volatile, + ); + expect(await recovery(c).saveRecoveryCopy(), isFalse); + expect(s.writes, isEmpty); + }, + ); + testWidgets('ten second timeout and retry do not duplicate active load', ( + tester, + ) async { + final s = Store()..heldLoad = Completer(); + final c = container(s); + final startup = recovery(c).start(); + await tester.pump(const Duration(seconds: 10)); + expect(c.read(draftRecoveryProvider).timedOut, isTrue); + final retry = recovery(c).retry(); + expect(s.loads, 1); + recovery(c).continueWithoutRecovery(); + s.heldLoad!.complete(const DraftRead(generation: 0)); + await startup; + await retry; + }); + test('volatile Retry offers choice without replacing current text', () async { + final s = Store(snapshot: saved(), generation: 17), c = container(s); + recovery(c).continueWithoutRecovery(); + editor(c).setContent('live'); + await recovery(c).retry(); + expect(c.read(draftRecoveryProvider).candidate?.content, saved().content); + expect(c.read(editorDocumentProvider).content, 'live'); + expect(c.read(draftRecoveryProvider).status, DraftRecoveryStatus.decision); + }); + test( + 'replacement failure preserves current document and previous durable copy', + () async { + final s = Store(snapshot: saved(), generation: 17), c = container(s); + await recovery(c).start(); + recovery(c).restore(); + s.failure = DraftStorageFailure.unavailable; + expect( + await recovery(c).replaceDocument( + const EditorDocument(name: 'new.py', content: 'new', dirty: true), + expectedEpoch: c.read(editorDocumentEpochProvider), + ), + isFalse, + ); + expect(c.read(editorDocumentProvider).content, saved().content); + expect(s.snapshot?.content, saved().content); + }, + ); + test('replacement rejects stale edit-away/back epoch', () async { + final s = Store(), c = container(s); + await recovery(c).start(); + editor(c).setContent('first'); + final captured = c.read(editorDocumentEpochProvider); + editor(c).setContent('other'); + editor(c).setContent('first'); + expect( + await recovery(c).replaceDocument( + const EditorDocument.untitled(), + expectedEpoch: captured, + ), + isFalse, + ); + expect(c.read(editorDocumentProvider).content, 'first'); + expect(s.clears, 0); + }); + test( + 'replacement waits for active write and prevents queued resurrection', + () async { + final s = Store(), c = container(s); + await recovery(c).start(); + editor(c).setContent('first'); + s.heldWrite = Completer(); + final write = recovery(c).saveRecoveryCopy(); + await Future.delayed(Duration.zero); + editor(c).setContent('obsolete queued'); + final clear = recovery(c).replaceDocument( + const EditorDocument.untitled(), + expectedEpoch: c.read(editorDocumentEpochProvider), + ); + s.heldWrite!.complete(); + await write; + expect(await clear, isTrue); + expect(s.snapshot, isNull); + expect(c.read(editorDocumentProvider).content, isEmpty); + expect(s.maxActive, 1); + }, + ); + + test( + 'discard completion preserves newer live edits and clears busy state', + () async { + final s = Store(snapshot: saved(), generation: 17), c = container(s); + await recovery(c).start(); + s.heldClear = Completer(); + final discard = recovery(c).discard(); + await Future.delayed(Duration.zero); + editor(c).setContent('new live work'); + s.heldClear!.complete(); + expect(await discard, isFalse); + expect(c.read(editorDocumentProvider).content, 'new live work'); + expect(c.read(draftRecoveryProvider).busy, isFalse); + expect(c.read(draftRecoveryProvider).status, DraftRecoveryStatus.pending); + expect(await recovery(c).saveRecoveryCopy(), isTrue); + expect(s.snapshot?.content, 'new live work'); + }, + ); + test( + 'replacement session change during commit refuses adoption and reconciles current text', + () async { + final s = Store(), c = container(s); + await recovery(c).start(); + editor(c).setContent('live'); + bool sessionValid = true; + s.heldWrite = Completer(); + final replacing = recovery(c).replaceDocument( + const EditorDocument(name: 'other.py', content: 'other', dirty: false), + expectedEpoch: c.read(editorDocumentEpochProvider), + isStillValid: () => sessionValid, + ); + await Future.delayed(Duration.zero); + sessionValid = false; + s.heldWrite!.complete(); + expect(await replacing, isFalse); + expect(c.read(editorDocumentProvider).content, 'live'); + expect(await recovery(c).saveRecoveryCopy(), isTrue); + expect(s.snapshot?.content, 'live'); + }, + ); + test( + 'Retry waits for an explicit replacement before reading the slot', + () async { + final s = Store(), c = container(s); + await recovery(c).start(); + editor(c).setContent('live'); + s.heldWrite = Completer(); + final replacing = recovery(c).replaceDocument( + const EditorDocument(name: 'other.py', content: 'other', dirty: false), + expectedEpoch: c.read(editorDocumentEpochProvider), + ); + await Future.delayed(Duration.zero); + recovery(c).continueWithoutRecovery(); + final reading = recovery(c).retry(); + expect(s.loads, 1); + s.heldWrite!.complete(); + expect(await replacing, isFalse); + await reading; + expect(s.loads, 2); + expect(c.read(editorDocumentProvider).content, 'live'); + expect( + c.read(draftRecoveryProvider).status, + DraftRecoveryStatus.decision, + ); + }, + ); + test( + 'conflicting external writer requires reread and explicit choice on Retry', + () async { + final s = Store(), c = container(s); + await recovery(c).start(); + editor(c).setContent('first'); + expect(await recovery(c).saveRecoveryCopy(), isTrue); + s.snapshot = DraftSnapshot( + name: 'other.py', + content: 'external', + generation: 2, + checkpointTime: DateTime.utc(2026), + ); + s.generation = 2; + editor(c).setContent('live newer'); + expect(await recovery(c).saveRecoveryCopy(), isFalse); + expect( + c.read(draftRecoveryProvider).failure, + DraftStorageFailure.conflict, + ); + await recovery(c).retry(); + expect( + c.read(draftRecoveryProvider).status, + DraftRecoveryStatus.decision, + ); + expect(c.read(draftRecoveryProvider).candidate?.content, 'external'); + expect(c.read(editorDocumentProvider).content, 'live newer'); + expect(s.snapshot?.content, 'external'); + }, + ); +} diff --git a/app/test/unit/recovered_target_consent_test.dart b/app/test/unit/recovered_target_consent_test.dart new file mode 100644 index 00000000..131837c5 --- /dev/null +++ b/app/test/unit/recovered_target_consent_test.dart @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: MIT +// Part of PyBLE (https://pyble.dev) — see /LICENSE. + +import 'dart:async'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pyble/app/providers.dart'; +import 'package:pyble/editor/editor.dart'; +import 'package:pyble/editor/board_target_guard.dart'; +import 'package:pyble/files/files.dart'; +import 'package:pyble/pble/pble.dart'; +import '../support/recording_connection.dart'; + +class SessionConnection extends RecordingConnection + implements ConnectionSessionStampSource { + SessionConnection() : super(initial: ConnState.ready); + Object stamp = Object(); + @override + Object get connectionSessionStamp => stamp; +} + +void main() { + ProviderContainer setup( + SessionConnection connection, + Future Function(BoardTargetRequest)? confirm, + ) { + final c = ProviderContainer( + overrides: [ + connectionProvider.overrideWithValue(connection), + boardTargetConfirmationProvider.overrideWithValue(confirm), + ], + ); + addTearDown(c.dispose); + c + .read(editorDocumentProvider.notifier) + .openGenerated(name: 'restored.py', content: 'print(1)'); + c.read(editorRecoveredDraftProvider.notifier).state = true; + return c; + } + + test('missing or declined consent sends no Save or Run board verb', () async { + for (final confirm in Function(BoardTargetRequest)?>[ + null, + (_) async => false, + ]) { + final connection = SessionConnection(); + final c = setup(connection, confirm); + await expectLater( + c.read(saveControllerProvider).save(), + throwsA(isA()), + ); + await expectLater( + c.read(runControllerProvider).run(), + throwsA(isA()), + ); + expect(connection.putFileCalls, isEmpty); + expect(connection.runFileCalls, isEmpty); + } + }); + test( + 'Save confirms actual path; Run confirms write and execution separately', + () async { + final requests = []; + final connection = SessionConnection(); + final c = setup(connection, (request) async { + requests.add(request); + return true; + }); + await c.read(saveControllerProvider).save(); + await c.read(runControllerProvider).run(); + expect(requests.map((r) => r.path), ['/restored.py', '/restored.py']); + expect(requests.map((r) => r.run), [false, true]); + expect(connection.runFileCalls, ['/restored.py']); + }, + ); + test( + 'session or edit-away/back while confirmation is open revokes consent', + () async { + for (final changeSession in [true, false]) { + final choice = Completer(); + final asked = Completer(); + final connection = SessionConnection(); + final c = setup(connection, (_) { + asked.complete(); + return choice.future; + }); + final save = c.read(saveControllerProvider).save(); + final result = expectLater(save, throwsA(isA())); + await asked.future; + if (changeSession) { + connection.stamp = Object(); + } else { + final doc = c.read(editorDocumentProvider.notifier); + doc.setContent('changed'); + doc.setContent('print(1)'); + } + choice.complete(true); + await result; + expect(connection.putFileCalls, isEmpty); + } + }, + ); + test( + 'Files Upload previews cwd destination and declining sends no PUT', + () async { + final requests = []; + final connection = SessionConnection(); + final c = setup(connection, (request) async { + requests.add(request); + return false; + }); + final files = c.read(fileExplorerProvider.notifier); + await pumpEventQueue(); + await connection.mkdir('/lib'); + await files.into('lib'); + await files.uploadCurrentBuffer(); + expect(requests.single.path, '/lib/restored.py'); + expect(requests.single.run, isFalse); + expect(connection.putFileCalls, isEmpty); + }, + ); + test( + 'changing Files destination while consent is pending cancels the upload', + () async { + final choice = Completer(); + final asked = Completer(); + final connection = SessionConnection(); + final c = setup(connection, (_) { + asked.complete(); + return choice.future; + }); + final files = c.read(fileExplorerProvider.notifier); + await pumpEventQueue(); + await connection.mkdir('/other'); + final upload = files.uploadCurrentBuffer(); + await asked.future; + await files.into('other'); + choice.complete(true); + await upload; + expect(connection.putFileCalls, isEmpty); + }, + ); +} diff --git a/app/test/unit/recovery_board_races_test.dart b/app/test/unit/recovery_board_races_test.dart new file mode 100644 index 00000000..2b3f4654 --- /dev/null +++ b/app/test/unit/recovery_board_races_test.dart @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: MIT +// Part of PyBLE (https://pyble.dev) — see /LICENSE. + +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pyble/app/providers.dart'; +import 'package:pyble/editor/editor.dart'; +import 'package:pyble/files/files.dart'; +import 'package:pyble/pble/pble.dart'; +import '../support/recording_connection.dart'; + +class HeldConnection extends RecordingConnection + implements ConnectionSessionStampSource { + HeldConnection() : super(initial: ConnState.ready); + Object stamp = Object(); + @override + Object get connectionSessionStamp => stamp; + final entered = Completer(); + final release = Completer(); + bool holdPut = true; + bool holdGet = false; + @override + Future putFile( + String path, + Uint8List bytes, { + ProgressCb? onProgress, + }) async { + await super.putFile(path, bytes, onProgress: onProgress); + if (holdPut) { + if (!entered.isCompleted) entered.complete(); + await release.future; + } + } + + @override + Future getFile(String path, {ProgressCb? onProgress}) async { + final result = await super.getFile(path, onProgress: onProgress); + if (holdGet) { + if (!entered.isCompleted) entered.complete(); + await release.future; + } + return result; + } +} + +void main() { + ProviderContainer bind(HeldConnection connection) { + final c = ProviderContainer( + overrides: [connectionProvider.overrideWithValue(connection)], + ); + addTearDown(c.dispose); + addTearDown(() { + if (!connection.release.isCompleted) connection.release.complete(); + }); + return c; + } + + test('Save completion cannot clear an edit-away/back revision', () async { + final connection = HeldConnection(); + final c = bind(connection); + final doc = c.read(editorDocumentProvider.notifier)..setContent('original'); + final save = c.read(saveControllerProvider).save(); + await connection.entered.future; + doc.setContent('newer'); + doc.setContent('original'); + connection.release.complete(); + await save; + expect(c.read(editorDocumentProvider).dirty, isTrue); + expect(c.read(editorDocumentProvider).boardPath, isNull); + }); + test('Run never dispatches second verb on a replaced attachment', () async { + final connection = HeldConnection(); + final c = bind(connection); + c.read(editorDocumentProvider.notifier).setContent('print(1)'); + final run = c.read(runControllerProvider).run(); + final result = expectLater(run, throwsA(isA())); + await connection.entered.future; + connection.stamp = Object(); + connection.release.complete(); + await result; + expect(connection.runFileCalls, isEmpty); + expect(c.read(editorDocumentProvider).dirty, isTrue); + }); + test( + 'Board Open refuses old GET after stable facade changes attachment', + () async { + final connection = HeldConnection()..holdPut = false; + await connection.putFile( + '/board.py', + Uint8List.fromList(utf8.encode('old board')), + ); + connection.holdGet = true; + final c = bind(connection); + final files = c.read(fileExplorerProvider.notifier); + await pumpEventQueue(); + final open = files.openInEditor('board.py'); + await connection.entered.future; + connection.stamp = Object(); + connection.release.complete(); + await open; + expect(c.read(editorDocumentProvider).content, isEmpty); + expect(c.read(editorDocumentProvider).boardPath, isNull); + }, + ); + test( + 'Files upload completion cannot bind an edit-away/back revision', + () async { + final connection = HeldConnection(); + final c = bind(connection); + final files = c.read(fileExplorerProvider.notifier); + await pumpEventQueue(); + final doc = c.read(editorDocumentProvider.notifier) + ..setContent('original'); + final upload = files.uploadCurrentBuffer(); + await connection.entered.future; + doc.setContent('newer'); + doc.setContent('original'); + connection.release.complete(); + await upload; + expect(c.read(editorDocumentProvider).dirty, isTrue); + expect(c.read(editorDocumentProvider).boardPath, isNull); + }, + ); +} diff --git a/app/test/unit/recovery_replacement_guard_test.dart b/app/test/unit/recovery_replacement_guard_test.dart new file mode 100644 index 00000000..43c9d7b4 --- /dev/null +++ b/app/test/unit/recovery_replacement_guard_test.dart @@ -0,0 +1,274 @@ +// SPDX-License-Identifier: MIT +// Part of PyBLE (https://pyble.dev) — see /LICENSE. + +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pyble/data/draft_repository.dart'; +import 'package:pyble/editor/board_target_guard.dart'; +import 'package:pyble/editor/draft_recovery.dart'; +import 'package:pyble/editor/editor_document.dart'; + +/// A previously committed slot whose storage becomes unavailable for writes. +/// Failure must never be interpreted as permission to delete the old copy. +class _UnavailableStore implements DraftRepository { + final DraftSnapshot snapshot = DraftSnapshot( + name: 'retained.py', + content: 'previous exact text', + originPath: '/lib/retained.py', + generation: 7, + checkpointTime: DateTime.utc(2026, 10, 31), + ); + int writes = 0; + int clears = 0; + + @override + Future load() async => + DraftRead(generation: 7, snapshot: snapshot); + + @override + Future write( + DraftSnapshot snapshot, { + required int expectedGeneration, + }) async { + writes++; + throw const DraftStorageException(DraftStorageFailure.unavailable); + } + + @override + Future clear({required int expectedGeneration}) async { + clears++; + throw const DraftStorageException(DraftStorageFailure.unavailable); + } + + @override + Future close() async {} +} + +void main() { + const EditorDocument replacement = EditorDocument( + name: 'replacement.py', + content: 'replacement text', + dirty: false, + boardPath: '/replacement.py', + ); + + Future setup( + _UnavailableStore store, + Future Function()? confirm, + ) async { + final ProviderContainer container = ProviderContainer( + overrides: [ + draftRepositoryProvider.overrideWithValue(store), + editorVolatileReplacementConfirmationProvider.overrideWithValue( + confirm, + ), + ], + ); + addTearDown(container.dispose); + final DraftRecoveryController recovery = container.read( + draftRecoveryProvider.notifier, + ); + await recovery.start(); + expect(recovery.restore(), isTrue); + return container; + } + + Future replace( + ProviderContainer container, { + bool Function()? isStillValid, + }) { + final int epoch = container.read(editorDocumentEpochProvider); + // The app's helper deliberately takes a Ref. This small provider exercises + // that public seam without mounting widgets or creating a board transport. + final Provider> action = Provider>( + (Ref ref) => Future.microtask( + () => replaceEditorDocument( + ref, + replacement, + expectedEpoch: epoch, + isStillValid: isStillValid, + ), + ), + ); + return container.read(action); + } + + void expectRetained(_UnavailableStore store) { + expect(store.snapshot.content, 'previous exact text'); + expect(store.snapshot.generation, 7); + expect(store.clears, 0); + } + + test( + 'failed replacement keeps live text until volatile choice is declined', + () async { + final _UnavailableStore store = _UnavailableStore(); + final Completer asked = Completer(); + final Completer choice = Completer(); + int prompts = 0; + final ProviderContainer container = await setup(store, () { + prompts++; + asked.complete(); + return choice.future; + }); + final Future replacing = replace(container); + await asked.future; + expect( + container.read(editorDocumentProvider).content, + store.snapshot.content, + ); + expect( + container.read(draftRecoveryProvider).status, + DraftRecoveryStatus.failed, + ); + expectRetained(store); + choice.complete(false); + expect(await replacing, isFalse); + expect( + container.read(editorDocumentProvider).content, + store.snapshot.content, + ); + expect( + container.read(draftRecoveryProvider).status, + DraftRecoveryStatus.failed, + ); + expect(store.writes, 1); + expect(prompts, 1); + expectRetained(store); + }, + ); + + test( + 'explicit volatile acceptance adopts replacement and preserves stored copy', + () async { + final _UnavailableStore store = _UnavailableStore(); + int prompts = 0; + final ProviderContainer container = await setup(store, () async { + prompts++; + return true; + }); + expect(await replace(container), isTrue); + expect(container.read(editorDocumentProvider), replacement); + expect( + container.read(draftRecoveryProvider).status, + DraftRecoveryStatus.volatile, + ); + expect( + await container.read(draftRecoveryProvider.notifier).saveRecoveryCopy(), + isFalse, + ); + expect(store.writes, 1); + expect(prompts, 1); + expectRetained(store); + }, + ); + + test( + 'edit away and back during volatile confirmation revokes captured choice', + () async { + final _UnavailableStore store = _UnavailableStore(); + final Completer asked = Completer(); + final Completer choice = Completer(); + final ProviderContainer container = await setup(store, () { + asked.complete(); + return choice.future; + }); + final Future replacing = replace(container); + await asked.future; + final EditorDocumentController editor = container.read( + editorDocumentProvider.notifier, + ); + editor.setContent('temporary new work'); + editor.setContent(store.snapshot.content); + choice.complete(true); + expect(await replacing, isFalse); + expect( + container.read(editorDocumentProvider).content, + store.snapshot.content, + ); + expect(container.read(editorDocumentProvider).name, 'retained.py'); + expect( + container.read(draftRecoveryProvider).status, + isNot(DraftRecoveryStatus.volatile), + ); + expect(store.writes, 1); + expectRetained(store); + }, + ); + + test( + 'board-session guard change during volatile confirmation refuses adoption', + () async { + final _UnavailableStore store = _UnavailableStore(); + final Completer asked = Completer(); + final Completer choice = Completer(); + Object currentSession = Object(); + final Object capturedSession = currentSession; + final ProviderContainer container = await setup(store, () { + asked.complete(); + return choice.future; + }); + final Future replacing = replace( + container, + isStillValid: () => identical(currentSession, capturedSession), + ); + await asked.future; + currentSession = Object(); + choice.complete(true); + expect(await replacing, isFalse); + expect( + container.read(editorDocumentProvider).content, + store.snapshot.content, + ); + expect( + container.read(draftRecoveryProvider).status, + DraftRecoveryStatus.failed, + ); + expect(store.writes, 1); + expectRetained(store); + }, + ); + + test( + 'already volatile session adopts without another failing write or prompt', + () async { + final _UnavailableStore store = _UnavailableStore(); + int prompts = 0; + final ProviderContainer container = await setup(store, () async { + prompts++; + return false; + }); + container.read(draftRecoveryProvider.notifier).continueWithoutRecovery(); + expect(await replace(container), isTrue); + expect(container.read(editorDocumentProvider), replacement); + expect( + container.read(draftRecoveryProvider).status, + DraftRecoveryStatus.volatile, + ); + expect(store.writes, 0); + expect(prompts, 0); + expectRetained(store); + }, + ); + + test( + 'missing volatile confirmation preserves live text and old recovery copy', + () async { + final _UnavailableStore store = _UnavailableStore(); + final ProviderContainer container = await setup(store, null); + expect(await replace(container), isFalse); + expect( + container.read(editorDocumentProvider).content, + store.snapshot.content, + ); + expect( + container.read(draftRecoveryProvider).status, + DraftRecoveryStatus.failed, + ); + expect(store.writes, 1); + expectRetained(store); + }, + ); +} diff --git a/app/test/widget/recovery_gate_test.dart b/app/test/widget/recovery_gate_test.dart new file mode 100644 index 00000000..573918b2 --- /dev/null +++ b/app/test/widget/recovery_gate_test.dart @@ -0,0 +1,505 @@ +// SPDX-License-Identifier: MIT +// Part of PyBLE (https://pyble.dev) — see /LICENSE. + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:pyble/data/draft_repository.dart'; +import 'package:pyble/app/app.dart'; +import 'package:pyble/app/providers.dart'; +import 'package:pyble/connect/connect.dart'; +import 'package:pyble/editor/draft_recovery.dart'; +import 'package:pyble/editor/editor_document.dart'; +import 'package:pyble/editor/editor_view.dart'; +import 'package:pyble/editor/recovery_gate.dart'; +import 'package:pyble/editor/recovery_panel.dart'; +import 'package:pyble/pble/pble.dart'; + +import '../support/feature_harness.dart'; +import '../support/fake_readiness_seam.dart'; + +class _Store implements DraftRepository { + DraftRead slot = DraftRead( + generation: 1, + snapshot: DraftSnapshot( + name: 'main.py', + content: 'prior text', + originPath: '/main.py', + generation: 1, + checkpointTime: DateTime.utc(2026, 10, 31), + ), + ); + Completer? delayed; + bool failClear = false; + DraftStorageFailure? failWrite; + @override + Future load() async => delayed == null ? slot : delayed!.future; + @override + Future clear({required int expectedGeneration}) async { + if (failClear) { + throw const DraftStorageException(DraftStorageFailure.unavailable); + } + slot = DraftRead(generation: slot.generation + 1); + return slot.generation; + } + + @override + Future write( + DraftSnapshot snapshot, { + required int expectedGeneration, + }) async { + if (failWrite != null) throw DraftStorageException(failWrite!); + slot = DraftRead(generation: snapshot.generation, snapshot: snapshot); + } + + @override + Future close() async {} +} + +Future _pump(WidgetTester tester, _Store store) async { + final FakeConnection connection = FakeConnection(); + addTearDown(connection.dispose); + await pumpSurface( + tester, + const RecoveryGate(child: Text('connection workflow mounted')), + connection: connection, + extra: [draftRepositoryProvider.overrideWithValue(store)], + ); + await tester.pump(); +} + +void main() { + testWidgets( + 'startup resolves explicit local choice before mounting connection workflow', + (WidgetTester tester) async { + final _Store store = _Store(); + await _pump(tester, store); + expect(find.text('connection workflow mounted'), findsNothing); + expect(find.text('prior text'), findsOneWidget); + expect(containerOf(tester).read(editorDocumentProvider).content, isEmpty); + await tester.tap(find.byKey(kRecoveryRestoreKey)); + await tester.pumpAndSettle(); + expect(find.text('connection workflow mounted'), findsOneWidget); + final EditorDocument restored = containerOf( + tester, + ).read(editorDocumentProvider); + expect(restored.content, 'prior text'); + expect(restored.boardPath, isNull); + expect(restored.dirty, isTrue); + expect(store.slot.snapshot!.content, 'prior text'); + }, + ); + + testWidgets('failed discard retains preview and confirmation choices', ( + WidgetTester tester, + ) async { + final _Store store = _Store()..failClear = true; + await _pump(tester, store); + await tester.tap(find.byKey(kRecoveryDiscardKey)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(kRecoveryConfirmDiscardKey)); + await tester.pumpAndSettle(); + expect(find.text('connection workflow mounted'), findsNothing); + expect(find.text('prior text'), findsOneWidget); + expect(find.byKey(kRecoveryRestoreKey), findsOneWidget); + expect(store.slot.snapshot!.content, 'prior text'); + expect(find.textContaining('could not be read or written'), findsOneWidget); + }); + + testWidgets( + 'timeout offers explicit volatile session and ignores a late startup result', + (WidgetTester tester) async { + final _Store store = _Store()..delayed = Completer(); + await _pump(tester, store); + expect(find.text('connection workflow mounted'), findsNothing); + await tester.pump(const Duration(seconds: 10)); + expect(find.byKey(kRecoveryRetryKey), findsOneWidget); + expect(find.byKey(kRecoveryContinueKey), findsOneWidget); + await tester.tap(find.byKey(kRecoveryContinueKey)); + await tester.pump(); + expect(find.text('connection workflow mounted'), findsOneWidget); + expect(find.textContaining('Recovery off:'), findsOneWidget); + containerOf( + tester, + ).read(editorDocumentProvider.notifier).setContent('live text'); + store.delayed!.complete(store.slot); + await tester.pump(); + expect( + containerOf(tester).read(editorDocumentProvider).content, + 'live text', + ); + expect(find.text('prior text'), findsNothing); + }, + ); + + testWidgets( + 'retry previews both live text and stored draft before explicit replacement', + (WidgetTester tester) async { + final _Store store = _Store()..delayed = Completer(); + await _pump(tester, store); + await tester.pump(const Duration(seconds: 10)); + await tester.tap(find.byKey(kRecoveryContinueKey)); + await tester.pump(); + containerOf( + tester, + ).read(editorDocumentProvider.notifier).setContent('live text'); + store.delayed!.complete(store.slot); + await tester.pump(); + store.delayed = null; + await tester.tap(find.byKey(kRecoveryRetryKey)); + await tester.pump(); + await tester.pump(); + expect(find.text('live text'), findsOneWidget); + expect(find.text('prior text'), findsOneWidget); + expect( + containerOf(tester).read(editorDocumentProvider).content, + 'live text', + ); + await tester.tap(find.byKey(kRecoveryKeepCurrentKey)); + await tester.pump(); + expect( + containerOf(tester).read(editorDocumentProvider).content, + 'live text', + ); + expect(store.slot.snapshot!.content, 'prior text'); + expect( + containerOf(tester).read(draftRecoveryProvider).status, + DraftRecoveryStatus.volatile, + ); + }, + ); + testWidgets('local acknowledgement stays distinct from board dirty state', ( + WidgetTester tester, + ) async { + final _Store store = _Store(); + final FakeConnection connection = FakeConnection(); + addTearDown(connection.dispose); + await pumpSurface( + tester, + const RecoveryGate(child: EditorView()), + connection: connection, + extra: [draftRepositoryProvider.overrideWithValue(store)], + ); + await tester.pump(); + await tester.tap(find.byKey(kRecoveryRestoreKey)); + await tester.pump(); + expect(find.text('Saved locally'), findsOneWidget); + expect(containerOf(tester).read(editorDocumentProvider).dirty, isTrue); + containerOf( + tester, + ).read(editorDocumentProvider.notifier).setContent('new text'); + await tester.pump(); + expect(find.text('Recovery copy pending'), findsOneWidget); + expect(find.text('Saved locally'), findsNothing); + await tester.tap(find.byKey(kRecoverySaveKey)); + await tester.pump(); + expect(find.text('Saved locally'), findsOneWidget); + expect(store.slot.snapshot!.content, 'new text'); + expect(containerOf(tester).read(editorDocumentProvider).dirty, isTrue); + await tester.tap(find.text('View local draft')); + await tester.pumpAndSettle(); + expect(find.text('Your current text draft'), findsOneWidget); + expect(find.text('new text'), findsWidgets); + }); + + testWidgets( + 'New keeps draft after failed tombstone until separate volatile consent', + (WidgetTester tester) async { + final _Store store = _Store()..failClear = true; + final FakeConnection connection = FakeConnection(); + addTearDown(connection.dispose); + await pumpSurface( + tester, + const RecoveryGate(child: EditorView()), + connection: connection, + extra: [draftRepositoryProvider.overrideWithValue(store)], + ); + await tester.pump(); + await tester.tap(find.byKey(kRecoveryRestoreKey)); + await tester.pump(); + await tester.tap(find.byKey(kEditorNewButtonKey)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Replace draft')); + await tester.pumpAndSettle(); + expect( + containerOf(tester).read(editorDocumentProvider).content, + 'prior text', + ); + expect(store.slot.snapshot!.content, 'prior text'); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect( + containerOf(tester).read(editorDocumentProvider).content, + 'prior text', + ); + await tester.tap(find.byKey(kEditorNewButtonKey)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Replace draft')); + await tester.pumpAndSettle(); + await tester.tap( + find.descendant( + of: find.byType(AlertDialog), + matching: find.text('Continue without recovery'), + ), + ); + await tester.pumpAndSettle(); + expect(containerOf(tester).read(editorDocumentProvider).content, isEmpty); + expect(store.slot.snapshot!.content, 'prior text'); + expect( + containerOf(tester).read(draftRecoveryProvider).status, + DraftRecoveryStatus.volatile, + ); + }, + ); + + testWidgets( + 'New confirmation cannot discard an edit-away-and-back revision', + (WidgetTester tester) async { + final _Store store = _Store(); + final FakeConnection connection = FakeConnection(); + addTearDown(connection.dispose); + await pumpSurface( + tester, + const RecoveryGate(child: EditorView()), + connection: connection, + extra: [draftRepositoryProvider.overrideWithValue(store)], + ); + await tester.pump(); + await tester.tap(find.byKey(kRecoveryRestoreKey)); + await tester.pump(); + await tester.tap(find.byKey(kEditorNewButtonKey)); + await tester.pumpAndSettle(); + final EditorDocumentController document = containerOf( + tester, + ).read(editorDocumentProvider.notifier); + document.setContent('changed'); + document.setContent('prior text'); + await tester.tap(find.text('Replace draft')); + await tester.pump(); + expect( + containerOf(tester).read(editorDocumentProvider).content, + 'prior text', + ); + expect( + find.text('The current draft changed. Review it and confirm again.'), + findsOneWidget, + ); + }, + ); + testWidgets( + 'restored source remains visible in disconnected app without scanning', + (WidgetTester tester) async { + final _Store store = _Store(); + final FakeConnection connection = FakeConnection(); + final FakeScanner scanner = FakeScanner(); + final FakeSeamReadiness readiness = FakeSeamReadiness(); + final PbleConnectionManager manager = PbleConnectionManager( + scanner: scanner, + readiness: readiness, + connectionFactory: (String _) async => connection, + ); + addTearDown(manager.dispose); + addTearDown(scanner.dispose); + addTearDown(readiness.dispose); + addTearDown(connection.dispose); + await tester.pumpWidget( + ProviderScope( + overrides: [ + draftRepositoryProvider.overrideWithValue(store), + connectionManagerProvider.overrideWithValue(manager), + ], + child: const PybleApp(), + ), + ); + await tester.pump(); + await tester.pump(); + expect(find.byType(ConnectScreen), findsNothing); + expect(scanner.scanStartCount, 0); + await tester.tap(find.byKey(kRecoveryRestoreKey)); + await tester.pumpAndSettle(); + expect(find.text('prior text'), findsOneWidget); + expect(find.text('Your current text draft'), findsOneWidget); + expect(find.byType(ConnectScreen), findsNothing); + expect(scanner.scanStartCount, 0); + final Future answer = confirmRecoveryBoardTarget( + appNavigatorKey.currentContext!, + path: '/main.py', + run: true, + ); + await tester.pumpAndSettle(); + expect(find.byType(AlertDialog), findsOneWidget); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(await answer, isFalse); + expect(tester.takeException(), isNull); + }, + ); + testWidgets( + 'runtime unsafe storage failure offers Retry and volatile continuation', + (WidgetTester tester) async { + final _Store store = _Store(); + final FakeConnection connection = FakeConnection(); + addTearDown(connection.dispose); + await pumpSurface( + tester, + const RecoveryGate(child: EditorView()), + connection: connection, + extra: [draftRepositoryProvider.overrideWithValue(store)], + ); + await tester.pump(); + await tester.tap(find.byKey(kRecoveryRestoreKey)); + await tester.pump(); + store.failWrite = DraftStorageFailure.conflict; + containerOf( + tester, + ).read(editorDocumentProvider.notifier).setContent('live newer text'); + await tester.pump(); + await tester.tap(find.byKey(kRecoverySaveKey)); + await tester.pumpAndSettle(); + expect(find.byKey(kRecoveryRetryKey), findsOneWidget); + expect(find.byKey(kRecoveryContinueKey), findsOneWidget); + expect( + containerOf(tester).read(editorDocumentProvider).content, + 'live newer text', + ); + await tester.tap(find.byKey(kRecoveryRetryKey)); + await tester.pumpAndSettle(); + expect(find.text('prior text'), findsOneWidget); + expect(find.text('live newer text'), findsOneWidget); + expect(find.byKey(kRecoveryRestoreKey), findsOneWidget); + expect(store.slot.snapshot!.content, 'prior text'); + }, + ); + + testWidgets( + 'New in an explicitly volatile session needs no second storage-failure choice', + (WidgetTester tester) async { + final _Store store = _Store(); + final FakeConnection connection = FakeConnection(); + addTearDown(connection.dispose); + await pumpSurface( + tester, + const RecoveryGate(child: EditorView()), + connection: connection, + extra: [draftRepositoryProvider.overrideWithValue(store)], + ); + await tester.pump(); + await tester.tap(find.byKey(kRecoveryRestoreKey)); + await tester.pump(); + containerOf( + tester, + ).read(draftRecoveryProvider.notifier).continueWithoutRecovery(); + await tester.pump(); + await tester.tap(find.byKey(kEditorNewButtonKey)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Replace draft')); + await tester.pumpAndSettle(); + expect(containerOf(tester).read(editorDocumentProvider).content, isEmpty); + expect(find.byType(AlertDialog), findsNothing); + expect(store.slot.snapshot!.content, 'prior text'); + }, + ); + + testWidgets( + 'disconnected shell retains a deliberately empty named file preview', + (WidgetTester tester) async { + final _Store store = _Store()..slot = const DraftRead(generation: 0); + final FakeConnection connection = FakeConnection(); + final FakeScanner scanner = FakeScanner(); + final FakeSeamReadiness readiness = FakeSeamReadiness(); + final PbleConnectionManager manager = PbleConnectionManager( + scanner: scanner, + readiness: readiness, + connectionFactory: (String _) async => connection, + ); + addTearDown(manager.dispose); + addTearDown(scanner.dispose); + addTearDown(readiness.dispose); + addTearDown(connection.dispose); + await tester.pumpWidget( + ProviderScope( + overrides: [ + draftRepositoryProvider.overrideWithValue(store), + connectionManagerProvider.overrideWithValue(manager), + ], + child: const PybleApp(), + ), + ); + await tester.pump(); + await tester.pump(); + containerOf(tester) + .read(editorDocumentProvider.notifier) + .openFromBoard(path: '/empty.py', content: ''); + await tester.pump(); + expect(find.text('Your current text draft'), findsOneWidget); + expect(find.text('empty.py'), findsOneWidget); + expect(find.byType(ConnectScreen), findsNothing); + }, + ); + testWidgets( + 'volatile editor remains usable at 2x text with the keyboard present', + (WidgetTester tester) async { + tester.platformDispatcher.textScaleFactorTestValue = 2; + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + tester.view.viewInsets = const FakeViewPadding(bottom: 350); + addTearDown(tester.view.resetViewInsets); + final _Store store = _Store()..slot = const DraftRead(generation: 0); + final FakeConnection connection = FakeConnection(); + addTearDown(connection.dispose); + await pumpSurface( + tester, + const RecoveryGate(child: EditorView()), + connection: connection, + size: const Size(400, 800), + extra: [draftRepositoryProvider.overrideWithValue(store)], + ); + await tester.pump(); + containerOf( + tester, + ).read(editorDocumentProvider.notifier).setContent('live text'); + containerOf( + tester, + ).read(draftRecoveryProvider.notifier).continueWithoutRecovery(); + await tester.pump(); + expect(tester.takeException(), isNull); + expect( + tester + .getSize(find.byKey(const ValueKey('editorRichSurface'))) + .height, + greaterThan(80), + ); + }, + ); + testWidgets( + 'retry can keep an empty named live draft without deleting stored text', + (WidgetTester tester) async { + final _Store store = _Store()..delayed = Completer(); + await _pump(tester, store); + await tester.pump(const Duration(seconds: 10)); + await tester.tap(find.byKey(kRecoveryContinueKey)); + await tester.pump(); + containerOf(tester) + .read(editorDocumentProvider.notifier) + .openGenerated(name: 'empty.py', content: ''); + store.delayed!.complete(store.slot); + await tester.pump(); + store.delayed = null; + await tester.tap(find.byKey(kRecoveryRetryKey)); + await tester.pump(); + await tester.pump(); + expect(find.text('empty.py'), findsOneWidget); + expect(find.text('prior text'), findsOneWidget); + await tester.tap(find.byKey(kRecoveryKeepCurrentKey)); + await tester.pump(); + final EditorDocument document = containerOf( + tester, + ).read(editorDocumentProvider); + expect(document.name, 'empty.py'); + expect(document.content, isEmpty); + expect(store.slot.snapshot!.content, 'prior text'); + }, + ); +} diff --git a/app/test/widget/recovery_panel_test.dart b/app/test/widget/recovery_panel_test.dart new file mode 100644 index 00000000..8aa2d626 --- /dev/null +++ b/app/test/widget/recovery_panel_test.dart @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: MIT +// Part of PyBLE (https://pyble.dev) — see /LICENSE. + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:pyble/editor/recovery_panel.dart'; +import 'package:pyble/pble/pble.dart'; + +import '../support/feature_harness.dart'; + +void main() { + testWidgets( + 'preview requires an explicit local Restore or confirmed Discard', + (WidgetTester tester) async { + int restored = 0; + int discarded = 0; + final FakeConnection fake = FakeConnection(); + addTearDown(fake.dispose); + await pumpSurface( + tester, + RecoveryPanel( + name: 'main.py', + content: 'print("retained")\n', + checkpointTime: DateTime.utc(2026, 10, 31, 12), + onRestore: () async { + restored += 1; + }, + onDiscard: () async { + discarded += 1; + }, + ), + connection: fake, + ); + expect(find.text('print("retained")\n'), findsOneWidget); + expect(restored, 0); + expect(discarded, 0); + await tester.tap(find.byKey(kRecoveryDiscardKey)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(discarded, 0); + expect(find.byKey(kRecoveryRestoreKey), findsOneWidget); + await tester.tap(find.byKey(kRecoveryDiscardKey)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(kRecoveryConfirmDiscardKey)); + await tester.pumpAndSettle(); + expect(discarded, 1); + await tester.tap(find.byKey(kRecoveryRestoreKey)); + await tester.pumpAndSettle(); + expect(restored, 1); + }, + ); + + testWidgets( + 'restored preview stays readable offline and copies only on tap', + (WidgetTester tester) async { + int clipboardWrites = 0; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (MethodCall call) async { + if (call.method == 'Clipboard.setData') clipboardWrites += 1; + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ), + ); + final FakeConnection fake = FakeConnection(); + addTearDown(fake.dispose); + await pumpSurface( + tester, + RecoveryPanel( + name: 'main.py', + content: 'print(42)', + checkpointTime: DateTime.utc(2026), + restored: true, + onRestore: () async {}, + onDiscard: () async {}, + ), + connection: fake, + ); + expect(find.text('print(42)'), findsOneWidget); + expect(find.byKey(kRecoveryRestoreKey), findsNothing); + expect(clipboardWrites, 0); + await tester.tap(find.byKey(kRecoveryCopyKey)); + await tester.pump(); + expect(clipboardWrites, 1); + }, + ); + + testWidgets( + 'preview and destructive confirmation fit 2x text with keyboard', + (WidgetTester tester) async { + tester.platformDispatcher.textScaleFactorTestValue = 2; + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + tester.view.viewInsets = const FakeViewPadding(bottom: 250); + addTearDown(tester.view.resetViewInsets); + final FakeConnection fake = FakeConnection(); + addTearDown(fake.dispose); + await pumpSurface( + tester, + RecoveryPanel( + name: 'unicode_µ.py', + content: List.filled(50, 'print("example")').join('\n'), + checkpointTime: DateTime.utc(2026), + onRestore: () async {}, + onDiscard: () async {}, + ), + connection: fake, + size: const Size(400, 800), + ); + await tester.ensureVisible(find.byKey(kRecoveryDiscardKey)); + await tester.tap(find.byKey(kRecoveryDiscardKey)); + await tester.pumpAndSettle(); + expect(find.byKey(kRecoveryConfirmDiscardKey), findsOneWidget); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('board confirmation identifies destination and explicit Run', ( + WidgetTester tester, + ) async { + bool? approved; + final FakeConnection fake = FakeConnection(); + addTearDown(fake.dispose); + await pumpSurface( + tester, + Builder( + builder: (BuildContext context) => TextButton( + onPressed: () async { + approved = await confirmRecoveryBoardTarget( + context, + path: '/main.py', + run: true, + ); + }, + child: const Text('open'), + ), + ), + connection: fake, + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + expect(find.textContaining('/main.py'), findsOneWidget); + expect(find.textContaining('run'), findsWidgets); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(approved, isFalse); + }); +} diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 9c2cab2e..7af96d59 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -37,13 +37,14 @@ v0.7.0 remains non-normative and requires separate maintainer approval. and launches are verified on connected tablets; fresh enrollment/reinstallation was not exercised. The pilot kit, recovery design and validation inventory are prepared; this does not complete the Sprint or board qualification. -- Implement the [bounded current-text recovery contract](specifications/App/current-draft-recovery.md) - after replanning against its 45–72 hour working estimate for engineering, - recipe, representative platform checks and follow-up. Current drafts remain - volatile; full A-24 and Blocks persistence remain separate scope. +- Validate and qualify the implemented [bounded current-text recovery candidate](developments/2026-q4-reliable-workflow/c1-s2/00_Execution_Record.md) + against the [contract](specifications/App/current-draft-recovery.md). Source + candidate `0.3.0+9` supplies atomic local checkpoints and offline recovery; + full A-24 and Blocks graph persistence remain separate scope. The original + 45–72 hour estimate remains a forecast, not measured effort. - Correct the [stale public TestFlight description](developments/2026-q4-reliable-workflow/c1-s1/05_TestFlight_Metadata_Correction.md) through the distribution owner; replacement copy is prepared, not published. -- Continue the app 0.2.0 beta train; source is currently `0.2.0+8`. The +- Prepare the next app `0.3.0+9` candidate while retaining the published 0.2.0 channel baseline. The [Android publication review](testing/website/android-open-testing-2026-09-25.md) records live open-testing availability. The [prepared build-8 handoff](testing/google-play/0.2.0-build-8.md) remains a diff --git a/docs/decisions/0010-working-loop-in-memory-document.md b/docs/decisions/0010-working-loop-in-memory-document.md index c59f341e..16d1bd42 100644 --- a/docs/decisions/0010-working-loop-in-memory-document.md +++ b/docs/decisions/0010-working-loop-in-memory-document.md @@ -1,6 +1,6 @@ # ADR 0010 — The working-loop current document is a volatile in-memory provider; feature packages cross-import public providers -**Status:** Accepted (2026-07-02). Extends [ADR-0007](0007-riverpod-state-management.md) (Riverpod / D3) and [ADR-0009](0009-runtime-connection-manager.md) (runtime session behind the seam). Enables the **working feature loop** increment across stories **A-20** (editor), **A-21** (console), **A-16** (run-control toolbar; wire lands on the A-11 client) and **A-30** (files). Does **not** supersede the `Connection` seam (D1) or the offline-first mandate — it scopes a temporary, recorded deviation with a hard re-freeze at **A-24**. +**Status:** Accepted (2026-07-02); volatile-only current-text limitation partially superseded by [ADR-0048](0048-bounded-current-text-draft-recovery.md) in the 2026-10-31 candidate. The historical decision below remains the original working-loop record. Extends [ADR-0007](0007-riverpod-state-management.md) (Riverpod / D3) and [ADR-0009](0009-runtime-connection-manager.md) (runtime session behind the seam). Enables the **working feature loop** increment across stories **A-20** (editor), **A-21** (console), **A-16** (run-control toolbar; wire lands on the A-11 client) and **A-30** (files). Does **not** supersede the `Connection` seam (D1) or the offline-first mandate — it scopes a temporary, recorded deviation with a hard re-freeze at **A-24**. ## Context @@ -66,6 +66,9 @@ the rich-editor deferral. [ADR-0048](0048-bounded-current-text-draft-recovery.md now specifies the bounded current-text recovery successor, with its [write/failure/board-authority contract](../specifications/App/current-draft-recovery.md) and [C1-S1 review and sizing](../developments/2026-q4-reliable-workflow/c1-s1/03_Recovery_Design_and_Sizing.md). -The inspected source is still volatile; implementation and acceptance evidence -are pending. The remaining A-24 project, settings, console, import and Blocks -obligations are carried, not satisfied by the successor design. +That 25 September inspection was design-only. The 31 October candidate now +implements the bounded current-text successor; its +[C1-S2 execution record](../developments/2026-q4-reliable-workflow/c1-s2/00_Execution_Record.md) +separates source implementation from pending integrated/physical acceptance and +release admission. The remaining A-24 project, settings, console, import and +Blocks obligations remain carried. diff --git a/docs/decisions/0011-connection-gated-shell.md b/docs/decisions/0011-connection-gated-shell.md index 9c9f1405..0f3d1c42 100644 --- a/docs/decisions/0011-connection-gated-shell.md +++ b/docs/decisions/0011-connection-gated-shell.md @@ -36,5 +36,11 @@ Unchanged rejection list, seam, and layering as in [ADR-0009](0009-runtime-conne specify a narrow successor exception: a local recovery preview/Restore/Discard panel must be reachable without a board, permission prompt or network access. The normal IDE remains connection-gated; this is not a full offline IDE or an -authorization to restore board-write authority. Implementation is pending, so -the current shell behavior above remains the recorded runtime baseline. +authorization to restore board-write authority. The 31 October candidate now +implements this narrow exception: local storage is resolved before the Connect +workflow mounts, and a local preview/copy surface remains reachable while +disconnected. The ordinary IDE remains connection-gated. See the +[C1-S2 record](../developments/2026-q4-reliable-workflow/c1-s2/00_Execution_Record.md) +for pending integration, physical evidence and release admission. The earlier +shell behavior above remains the historical baseline, not a description of the +candidate recovery gate. diff --git a/docs/decisions/0048-bounded-current-text-draft-recovery.md b/docs/decisions/0048-bounded-current-text-draft-recovery.md index 0b1e99df..4bf725f2 100644 --- a/docs/decisions/0048-bounded-current-text-draft-recovery.md +++ b/docs/decisions/0048-bounded-current-text-draft-recovery.md @@ -3,21 +3,23 @@ # ADR 0048 — Recover one current text draft through the planned Drift store -**Status:** Specified for implementation (2026-09-25) under the user's request -to execute C1-S1. Source implementation and physical acceptance remain pending; no named human -sign-off is claimed and this is not release admission. +**Status:** Accepted for the bounded implementation; candidate source implemented +2026-10-31 under the user's next-release request. The contract was specified +2026-09-25 during C1-S1. Final integrated checks, required physical evidence and +release admission remain pending; no named human sign-off is claimed. ## Context [ADR-0010](0010-working-loop-in-memory-document.md) recorded an intentionally volatile current document and deferred Drift persistence to A-24. That -limitation remains in the inspected source. Its old rich-editor deferral has +limitation was present in the C1-S1 inspected source. The 31 October candidate +implements the bounded successor described below. Its old rich-editor deferral has already been superseded by [ADR-0012](0012-flutter-code-editor-behind-editorsurface.md), so the persistence problem must be scoped against the current rich Editor, Blocks handoff and file-backed Run implementation. -The current provider has a mutation epoch, but Save/Run/Files completion guards -still compare document values. A stable Connection facade also makes object +The C1-S1 provider had a mutation epoch, but Save/Run/Files completion guards +still compared document values. A stable Connection facade also makes object identity insufficient to bind a multi-step text action to one attached board. Recovery must not amplify either gap by restoring a historical target as current write authority. @@ -29,7 +31,7 @@ records this distinction and the proposed reforecast. ## Decision Adopt [the bounded current-draft contract](../specifications/App/current-draft-recovery.md) -for the next implementation slice: +for the bounded implementation slice: 1. Use one current-text slot in the planned Drift `projects`/`project_files` subset and single SettingsStore. Commit its content and recovery metadata @@ -49,7 +51,7 @@ for the next implementation slice: Keep Blocks graph recovery and the rest of A-24 explicitly carried. This decision supersedes only ADR-0010's volatile-only current-text design for -the future accepted slice. It does not retroactively claim that released builds +the bounded candidate slice, subject to its acceptance evidence. It does not retroactively claim that released builds persist drafts, change PBLE/1 or remove the existing provider/Connection seams. ## Alternatives considered @@ -64,9 +66,11 @@ persist drafts, change PBLE/1 or remove the existing provider/Connection seams. ## Consequences -The implementation needs the actual Drift/native database dependencies, -versioned schemas, local recovery UI and stronger completion guards. None of -these exist merely because this ADR is written. The estimate exceeds the -original C1-S2 recovery allowance; future Sprint selection must be reforecast -without weakening the failure cases. The narrower feature can eventually -protect useful current text, while full project persistence remains unfinished. +The candidate adds Drift/native database dependencies, a versioned schema, +local recovery UI and epoch/session completion guards. The +[implementation record](../developments/2026-q4-reliable-workflow/c1-s2/00_Execution_Record.md) +and [operator recipe](../developments/2026-q4-reliable-workflow/c1-s2/01_Operator_Recipe.md) +record their evidence and remaining admission work. The estimate exceeds the +original C1-S2 allowance; the 60-hour S2–S4 option remains a planning proposal, +not measured effort or confirmed staffing. Physical evidence is still required. +Full project persistence and Blocks graph recovery remain unfinished. diff --git a/docs/developments/2026-q4-reliable-workflow/01_Overview_and_Scrum_Framework.md b/docs/developments/2026-q4-reliable-workflow/01_Overview_and_Scrum_Framework.md index 3266428b..2701f617 100644 --- a/docs/developments/2026-q4-reliable-workflow/01_Overview_and_Scrum_Framework.md +++ b/docs/developments/2026-q4-reliable-workflow/01_Overview_and_Scrum_Framework.md @@ -18,6 +18,8 @@ This package derives execution views from the strategy. It does not replace normative specifications, expand hardware claims, create GitHub issues, or authorize store/website publication. The strategy and original package were recorded together in planning baseline `d039db0`. The [C1-S1 execution record](c1-s1/00_Execution_Record.md) owns actual results and blockers. +**31 October implementation update:** bounded recovery work is underway for candidate `0.3.0+9`, following the user’s next-release request. [C1-S2 implementation record](c1-s2/00_Execution_Record.md) records source, automated and physical states separately. S1 remains six Done / two Prepared / four Awaiting evidence. Original dates/hours below remain the planning baseline; no completed Sprint, adopted 60-hour capacity forecast or release admission is inferred. + ## Navigation and ownership of information | File | Canonical responsibility in this package | Update trigger | @@ -29,6 +31,7 @@ This package derives execution views from the strategy. It does not replace norm | [05 — Product backlog and stories](05_Product_Backlog_and_Stories.md) | Ordered work, parent/child identities, acceptance criteria, and source traceability | Refinement or changed user evidence | | [06 — Sprint backlogs](06_Sprint_Backlogs.md) | Task allowances, dependencies, C1-S1 execution status and later forecasts requiring replanning | Developers select/adapt work during a Sprint | | [C1-S1 — Execution and evidence](c1-s1/00_Execution_Record.md) | Actual channel checks, pilot kit, recovery design/sizing and validation inventory | New evidence, source review or a resolved blocker | +| [C1-S2 — Implementation and evidence](c1-s2/00_Execution_Record.md) | Bounded recovery candidate, actual test states, remaining physical/release work and scoped operator recipe | Source integration, actual platform runs or admission decision | ```mermaid flowchart LR @@ -61,7 +64,7 @@ flowchart LR | App source is `0.2.0+8`; physical retry verifies Play `0.2.0 (8)` and TestFlight `0.2.0 (6)` | Android payloads map to the historical build-8 bundle; iPad source mapping remains unknown. Neither channel check proves BLE or older-OS compatibility | | Firmware v0.6.1 is published on owner confirmation across five exact profiles | Pending automated results stay pending; no automatic PyBLE Ready badge | | Issue #24 is closed/superseded | Do not resurrect its old release umbrella as a dependency | -| Editor draft is volatile under [ADR-0010](../../decisions/0010-working-loop-in-memory-document.md) | Close an accepted subset of A-24; do not claim full project persistence or Blocks recovery | +| S1 baseline draft was volatile; [ADR-0048](../../decisions/0048-bounded-current-text-draft-recovery.md) now has a bounded candidate implementation | Complete applicable recovery acceptance; full project persistence and Blocks graph recovery remain carried | | Lifecycle protections, tutorials, imports, and test tools already exist | Reuse them; refine only observed gaps and the documented persistence gap | ## Applying Scrum to PyBLE diff --git a/docs/developments/2026-q4-reliable-workflow/02_Product_Roadmap.md b/docs/developments/2026-q4-reliable-workflow/02_Product_Roadmap.md index f82adef9..c9ef164c 100644 --- a/docs/developments/2026-q4-reliable-workflow/02_Product_Roadmap.md +++ b/docs/developments/2026-q4-reliable-workflow/02_Product_Roadmap.md @@ -13,6 +13,8 @@ | Planning authority | Product Owner orders the Product Backlog; Developers forecast feasible work during Sprint Planning; names and capacity remain unassigned | | Related documents | [Framework](01_Overview_and_Scrum_Framework.md), [milestones](03_Milestones.md), [Sprint Plan](04_Sprint_Plan.md), [stories](05_Product_Backlog_and_Stories.md), [Sprint Backlogs](06_Sprint_Backlogs.md) | +**31 October implementation update:** bounded recovery work is underway for candidate `0.3.0+9`, following the user’s next-release request. [C1-S2 implementation record](c1-s2/00_Execution_Record.md) records source, automated and physical states separately. S1 remains six Done / two Prepared / four Awaiting evidence. Original dates/hours below remain the planning baseline; no completed Sprint, adopted 60-hour capacity forecast or release admission is inferred. + ## Product Goal and decision principle **Proposed Product Goal:** tablet-owning makers can complete useful BLE board work, @@ -35,8 +37,9 @@ continues to communicate direction without promising release dates. These records distinguish the historical baseline from the dated device retry. The [C1-S1 record](c1-s1/00_Execution_Record.md) records completed design/evidence preparation and existing channel installations separately from prepared pilot -work and missing observations. Future work remains **Proposed** until explicitly -selected. +work and missing observations. The user selected bounded recovery implementation on 31 October. Later work +remains **Proposed** until selected; that instruction does not establish actual +team capacity or a completed formal Sprint. | Baseline item | Recorded state at the planning date | Consequence for this roadmap | |---|---|---| @@ -46,7 +49,7 @@ selected. | Existing workflow | PBLE/1 editing, files, run/stop, console, Blocks, tutorials, and reviewed import already exist | Reuse implementation and tests; improve measured failures rather than rebuild the workflow | | Firmware v0.6.1 | Owner-confirmed publication for five exact profiles; incomplete automated evidence remains visible | New qualification and compatibility claims require their own applicable evidence | | Historical issue #24 | Closed as superseded; incomplete checklist was not declared passed | Do not recreate closure as a new dependency or treat historical closure as qualification | -| Durable drafts | A-24/ADR-0010 current-document persistence remains a recorded implementation gap | Select a bounded local recovery increment with explicit text/Blocks scope | +| Durable drafts | Bounded current-text recovery now has candidate source under ADR-0048; required acceptance remains unfinished | Complete integrated/physical evidence for PYB-006.2; retain broader A-24/Blocks scope | | Adoption and Ready | No measured cohort results or awarded PyBLE Ready badge established by the strategy | Treat outcome targets and badge eligibility as proposals requiring evidence | The [Android publication review](../../testing/website/android-open-testing-2026-09-25.md) diff --git a/docs/developments/2026-q4-reliable-workflow/03_Milestones.md b/docs/developments/2026-q4-reliable-workflow/03_Milestones.md index 134eb858..0b085d12 100644 --- a/docs/developments/2026-q4-reliable-workflow/03_Milestones.md +++ b/docs/developments/2026-q4-reliable-workflow/03_Milestones.md @@ -5,13 +5,15 @@ | Control | Value | |---|---| -| Status | M-01/SG0 remains Blocked on 25 September by pilot ownership/cohort/setup and capacity decisions; existing channel installations verified on retry. Other milestones remain Proposed and forecast windows need replanning | +| Status | M-01/SG0 remains Blocked on 25 September by pilot ownership/cohort/setup and capacity decisions; existing channel installations verified on retry. M-02 is in progress in the 31 October candidate with acceptance pending; other milestones remain Proposed and forecast windows need replanning | | Planning date | 25 September 2026; dates use Asia/Bangkok | | Source | [Implementation strategy revision 1.4](../../planning/PyBLE_Implementation_Plan_and_Strategy_v1.0.md), especially §§4, 8–10, 14, and 19 | | Inspected baseline | `main` at `23ed764b0ef88dce9720c66fbd1c0655268860cf` | | Related documents | [Framework](01_Overview_and_Scrum_Framework.md), [roadmap](02_Product_Roadmap.md), [Sprint Plan](04_Sprint_Plan.md), [stories](05_Product_Backlog_and_Stories.md), [Sprint Backlogs](06_Sprint_Backlogs.md) | | Meaning | Milestones describe inspectable outcomes and decision evidence; they are not additional Scrum events or promised releases | +**31 October implementation update:** bounded recovery work is underway for candidate `0.3.0+9`, following the user’s next-release request. [C1-S2 implementation record](c1-s2/00_Execution_Record.md) records source, automated and physical states separately. S1 remains six Done / two Prepared / four Awaiting evidence. Original dates/hours below remain the planning baseline; no completed Sprint, adopted 60-hour capacity forecast or release admission is inferred. + ## Assessment and evidence states Use **Proposed** until work is selected. At a milestone assessment, record one of @@ -45,7 +47,7 @@ no role in this table implies committed staffing or independent review. | ID / status | Outcome and value | Forecast window / stories | Dependencies and evidence | Assessment / accountable contribution | |---|---|---|---|---| | M-01 — Blocked (25 Sep) | Accepted baseline and bounded cycle scope make installation, pilot, and engineering decisions reproducible | C1-S1, 28 Sep–11 Oct; PYB-001/003/004, residual 002, sizing 006/009 | Dated channel/install/source mapping or explicit owned unknowns; one primary cohort/job; safe task/consent rules; initial observations or recruitment blocker; actual capacity; persistence and physical-matrix estimates | SG0: accept a bounded scope or record gaps; Product Owner with release, pilot, and Developers | -| M-02 — Proposed | A usable, bounded local-draft recovery increment protects accepted user work after interruption | C1-S2–C1-S3, 12 Oct–8 Nov; selected PYB-006 scope, supporting 009/008, observed 005 only if selected | Accepted App specification/TDD and ADR-0010 change; text/Blocks scope; persistence boundary; relaunch, interrupted-write, and corrupt-state scenarios; no implicit board overwrite/run; relevant regression and physical evidence | Review against the selected story acceptance criteria and Definition of Done; Developers with a named review path | +| M-02 — In progress; acceptance pending | A usable, bounded local-draft recovery increment protects accepted user work after interruption | C1-S2–C1-S3, 12 Oct–8 Nov; selected PYB-006 scope, supporting 009/008, observed 005 only if selected | Accepted App specification/TDD and ADR-0010 change; text/Blocks scope; persistence boundary; relaunch, interrupted-write, and corrupt-state scenarios; no implicit board overwrite/run; relevant regression and physical evidence | Review against the selected story acceptance criteria and Definition of Done; Developers with a named review path | | M-03 — Proposed | Observed first-use and meaningful second-task evidence explains activation and its largest obstacle | C1-S4, 9–22 Nov; PYB-004/011 and justified 005/012 | Safe recorded pilot setup; at least 12 eligible primary-cohort task starts, all outcomes retained; successful unassisted timing; Task C attempts, prior workflow, assistance and reasons | SG2: assess provisional completion/timing targets and severe failures; pilot lead supplies evidence, Product Owner records next investment | | M-04 — Proposed | A frozen maintenance candidate is qualified for its declared release scope | Normally C1-S5, 23 Nov–6 Dec; PYB-008/009/013 and included fixes | Applicable exact-artifact matrix and normative gates pass; provenance/recovery/instructions; no unresolved release-blocking data-loss, unintended/cross-session write, or critical workflow regression within the accepted threat model | SG1: release or submission decision with required sign-off; release owner and QA/review path | | M-05 — Proposed | Users can obtain the selected qualified app build through a verified existing channel | C1-S5–C1-S6, 23 Nov–20 Dec; PYB-014 and residual 002 | M-04; distribution-owner access; separate submission, availability, installed build/provenance, and installation outcome records; current user-facing claims | Verified-channel milestone passes only for the stated evidence; store delays are recorded as blockers; release owner | diff --git a/docs/developments/2026-q4-reliable-workflow/04_Sprint_Plan.md b/docs/developments/2026-q4-reliable-workflow/04_Sprint_Plan.md index ef30e9a5..96a6bf9d 100644 --- a/docs/developments/2026-q4-reliable-workflow/04_Sprint_Plan.md +++ b/docs/developments/2026-q4-reliable-workflow/04_Sprint_Plan.md @@ -5,13 +5,15 @@ | Field | Value | |---|---| -| Status | C1-S1 early execution recorded; original S2–S6 forecasts are not ready for selection after the new recovery/matrix estimates | +| Status | C1-S1 remains partial; bounded S2 candidate implementation selected 31 October with acceptance pending. Original S2–S6 dates/hours remain baseline forecasts requiring capacity reconciliation | | Cycle | `C1`, 28 September–20 December 2026, Asia/Bangkok | | Basis | [Strategy revision 1.4, §8](../../planning/PyBLE_Implementation_Plan_and_Strategy_v1.0.md#8-twelve-week-implementation-schedule) | | Product Goal and quality rules | [Working agreement](01_Overview_and_Scrum_Framework.md) | | Acceptance criteria | [Product backlog and stories](05_Product_Backlog_and_Stories.md) | | Task-level proposal | [Sprint backlogs](06_Sprint_Backlogs.md) | +**31 October implementation update:** bounded recovery work is underway for candidate `0.3.0+9`, following the user’s next-release request. [C1-S2 implementation record](c1-s2/00_Execution_Record.md) records source, automated and physical states separately. S1 remains six Done / two Prepared / four Awaiting evidence. Original dates/hours below remain the planning baseline; no completed Sprint, adopted 60-hour capacity forecast or release admission is inferred. + ## Forecast and commitment boundary | Planning element | Treatment | @@ -37,6 +39,21 @@ The remaining 18h under S2–S4's combined 78h ceiling cannot silently absorb all displaced matrix, activation and repair tasks. Reorder those tasks, review the November recruitment window, and reforecast S5/S6 if required. The tables below preserve the original allowance/calendar for comparison; they are **not** an adopted revised forecast. A tested foundation is not the completed recovery user story. All selected increments still need their applicable Definition of Done. +## C1-S2 implementation execution — 31 October + +The next-release instruction selects PYB-006.2 engineering and its required +verification, supporting PYB-009.1 recipe work and applicable PYB-008.1 evidence. +The [execution record](c1-s2/00_Execution_Record.md) owns actual results. Source +storage/coordinator/UI/guard work is implemented; the usable story remains in +progress until all applicable acceptance passes. The original 12–25 October S2 +window is historical; calendar position does not mark S2 or S3 complete. + +The 45–72-hour estimate and 60-hour S2–S4 allocation option remain estimates. +They are not confirmed staffing, measured effort or an adopted replacement for +the tables below. Reorder displaced matrix/activation work with actual capacity; +keep pilot evidence and the M-01/SG0 gap visible while known engineering work +continues. No automatic completion of S1, SG0, broader A-24 or PyBLE Ready follows. + ## Capacity model The source assumes one primary maintainer at 20 hours/week. Confirm real availability and any contributor capacity at Planning; extra roles do not create extra hours. diff --git a/docs/developments/2026-q4-reliable-workflow/05_Product_Backlog_and_Stories.md b/docs/developments/2026-q4-reliable-workflow/05_Product_Backlog_and_Stories.md index 15a24346..5024df4a 100644 --- a/docs/developments/2026-q4-reliable-workflow/05_Product_Backlog_and_Stories.md +++ b/docs/developments/2026-q4-reliable-workflow/05_Product_Backlog_and_Stories.md @@ -9,10 +9,12 @@ | Source | [Implementation strategy revision 1.4](../../planning/PyBLE_Implementation_Plan_and_Strategy_v1.0.md), reviewed 25 September 2026 | | Cycle | Proposed 28 September–20 December 2026; Asia/Bangkok | | Baseline | `23ed764b0ef88dce9720c66fbd1c0655268860cf`; completed publication work is evidence, not a new commitment | -| Status | C1-S1: PYB-001.1/.2 and PYB-006.1 scoped work Done; pilot preparation awaits setup, observation work awaits evidence, PYB-009.1 partial; other work remains forecast | +| Status | C1-S1: PYB-001.1/.2 and PYB-006.1 scoped work Done; pilot preparation awaits setup, observation work awaits evidence, PYB-009.1 partial; PYB-006.2 candidate source implemented with integrated/physical acceptance pending; later work remains forecast | | Accountabilities | Product Owner orders the backlog; Developers size and select work in Sprint Planning; named people and capacity remain to be assigned | | Navigation | [Overview and Scrum](01_Overview_and_Scrum_Framework.md) · [Roadmap](02_Product_Roadmap.md) · [Milestones](03_Milestones.md) · [Sprint plan](04_Sprint_Plan.md) · [Sprint Backlogs](06_Sprint_Backlogs.md) | +**31 October implementation update:** bounded recovery work is underway for candidate `0.3.0+9`, following the user’s next-release request. [C1-S2 implementation record](c1-s2/00_Execution_Record.md) records source, automated and physical states separately. S1 remains six Done / two Prepared / four Awaiting evidence. Original dates/hours below remain the planning baseline; no completed Sprint, adopted 60-hour capacity forecast or release admission is inferred. + ## 1. How to use this backlog | Rule | Application | @@ -36,7 +38,7 @@ | — | PYB-002 / P0 accuracy | Keep maintained availability claims correct | 002.1: public TestFlight description | PR #33 Android correction complete; residual found during 001 | Correction packet prepared; actual store update/public verification pending | | 2 | PYB-003 / P0 | Conduct a safe, consented pilot with bounded data collection | 003.1 | C1-S1; 001 informs setup | Prepared — awaiting pilot setup and named collection decision | | 3 | PYB-004 / P0 | Observe one reachable audience completing reproducible, useful tasks | 004.1, 004.2 | C1-S1; 001, 003 | 004.1 Prepared — awaiting cohort/setup; 004.2 Awaiting participant evidence | -| 4 | PYB-006 / P0 | Recover the current text draft after process restart | 006.1, 006.2 | Sized in C1-S1; S2–S4 working option requires replanning; known defect does not wait for recruitment | 006.1 Done; 006.2 implementation pending | +| 4 | PYB-006 / P0 | Recover the current text draft after process restart | 006.1, 006.2 | Sized in C1-S1; S2–S4 working option requires replanning; known defect does not wait for recruitment | 006.1 Done; 006.2 candidate source implemented, integrated/physical acceptance pending | | 5 | PYB-009 / P0 | Make existing validation reproducible by an operator | 009.1; optional 009.2 | Inventory C1-S1, scoped recipe C1-S2; reproduction only with assigned capacity | T12 Done; 009.1 remains partial | | 6 | PYB-005 / P0 if observed | Remove a demonstrated first-use blocker | 005.1 | C1-S2 candidate; actual 004 findings | Conditional; no defect invented | | 7 | PYB-008 / P0 for claims | Establish the exact candidate's applicable compatibility evidence | 008.1, 008.2 | Representative checks C1-S2; staged matrix C1-S3–S5; 001, 009.1, artifacts | Proposed; hardware and mandatory workload must be costed | @@ -99,7 +101,7 @@ Acceptance criteria below remain the completion authority. The [C1-S1 execution | A-24 scope guard | Required treatment | |---|---| -| Existing implementation | [EditorDocument](../../../app/lib/editor/editor_document.dart) is volatile; [data barrel](../../../app/lib/data/data.dart) remains a placeholder. Navigation survival is not restart survival | +| Existing implementation | Candidate [EditorDocument](../../../app/lib/editor/editor_document.dart) retains the shared in-memory seam; the recovery coordinator checkpoints a bounded current-text slot through `lib/data/`. Final integrated/physical acceptance is tracked in the C1-S2 record; full A-24 remains unfinished | | Persistence design | [App TDD](../../specifications/App/TDD.md) §9 describes the broader Drift model; its frozen working-loop contract records the current deviation. Do not interpret the target architecture as shipped code | | Backend | Preserve Drift and the single settings-store direction; ADR-0010 rejects an ad-hoc scratch-file substitute. A different design requires an explicit superseding decision | | Completion boundary | A bounded current-text recovery story does not complete full project management, all six content tables, saved boards, console history, import provenance, durable settings or Blocks recovery | diff --git a/docs/developments/2026-q4-reliable-workflow/06_Sprint_Backlogs.md b/docs/developments/2026-q4-reliable-workflow/06_Sprint_Backlogs.md index 359baa1f..745555fa 100644 --- a/docs/developments/2026-q4-reliable-workflow/06_Sprint_Backlogs.md +++ b/docs/developments/2026-q4-reliable-workflow/06_Sprint_Backlogs.md @@ -5,7 +5,7 @@ | Field | Rule | |---|---| -| Status | C1-S1 device retry 25 September: six tasks Done, two Prepared and four Awaiting evidence. Both current channel installations verified; participant-dependent work remains unfinished. C1-S2–C1-S6 retain original Proposed allowances and require replanning before selection | +| Status | C1-S1: six Done, two Prepared, four Awaiting evidence. C1-S2 bounded candidate implementation is underway on 31 October; integrated/physical acceptance remains pending. Original allowances/calendar remain baseline; later work requires replanning | | Product/Sprint Goals | [Framework](01_Overview_and_Scrum_Framework.md) and [Sprint plan](04_Sprint_Plan.md) | | Acceptance authority | [Product backlog and stories](05_Product_Backlog_and_Stories.md); task completion never overrides a parent story's acceptance criteria | | Selection | Developers confirm scope, ownership, estimates and task plan at each Sprint Planning | @@ -14,12 +14,14 @@ | Evidence | Record artifact identity and actual results; use Prepared for reviewed partial work, Awaiting evidence for unperformed sessions/observations, and Blocked for a concrete impediment with an owner; unfinished work is never Done | | Privacy | Keep private consent/contact/device records outside Git; link only reviewed public aggregates and redacted evidence | -Actual task evidence and blocker owners are maintained in the [C1-S1 execution record](c1-s1/00_Execution_Record.md). The original future task tables below are retained as the baseline; the new 45–72h recovery estimate and matrix cost model invalidate their assumed fit. Use the [reforecast requirements](04_Sprint_Plan.md#c1-s1-findings-and-required-reforecast) before selection. Prepared TestFlight correction copy records residual PYB-002.1; its external application is not an extra committed task. +Actual task evidence and blocker owners are maintained in the [C1-S1 execution record](c1-s1/00_Execution_Record.md). The original task allowances/calendar are retained as the baseline, with current S2 execution states updated below; the new 45–72h recovery estimate and matrix cost model invalidate their assumed fit. Use the [reforecast requirements](04_Sprint_Plan.md#c1-s1-findings-and-required-reforecast) before selection. Prepared TestFlight correction copy records residual PYB-002.1; its external application is not an extra committed task. The initial tasks are small planning units, generally 1–4 hours. If a task or safe story is larger after inspection, split/reforecast before selection. A time allowance does not limit a required test's counts or justify declaring partial qualification complete. The actual Sprint Backlog includes its confirmed Goal, selected items, and the Developers' current plan; update this document as that plan changes. Record optional follow-up consent and the prospective day-7–14 window at each activation. Collect follow-up as windows arrive, starting with the first cohort; do not postpone early cohorts until S5. S1 observation allowances include any follow-up due within S1; S2-T11, S3-T10 and later follow-up rows continue it. Developers must reallocate headroom or other selected work if actual pilot effort exceeds these allowances, preserving required recovery review and tests. Record missed windows as missing/late evidence rather than reconstructing an on-time response. +**31 October implementation update:** bounded recovery work is underway for candidate `0.3.0+9`, following the user’s next-release request. [C1-S2 implementation record](c1-s2/00_Execution_Record.md) records source, automated and physical states separately. S1 remains six Done / two Prepared / four Awaiting evidence. Original dates/hours below remain the planning baseline; no completed Sprint, adopted 60-hour capacity forecast or release admission is inferred. + ## C1-S1 — reproduce the current workflow and size recovery | Sprint control | Value | @@ -62,21 +64,27 @@ Five baseline starts is a target, not the implied result of reserving six hours. | Task ID | Parent child item | Proposed task and expected output | Dependency | Contribution role | h | Status | |---|---|---|---|---|---:|---| -| C1-S2-T01 | PYB-009.1 | Complete the scoped restart/setup/operator recipe and expected evidence for this increment | 006.1 scope; initial 009.1 inventory | QA + App | 2 | Proposed | -| C1-S2-T02 | PYB-006.2 | Add meaningful failing tests for the accepted write boundary, relaunch recovery and absence of implicit board/network actions | Approved 006.1; specification-first sequence | App | 2 | Proposed | -| C1-S2-T03 | PYB-006.2 | Add failing tests for interrupted/corrupt/full local storage and stale completions against newer edits | Approved 006.1 | App | 2 | Proposed | -| C1-S2-T04 | PYB-006.2 | Implement the minimal accepted durable storage/migration behavior until relevant tests pass | T02/T03 red evidence | App | 4 | Proposed | -| C1-S2-T05 | PYB-006.2 | Implement hydration and specified recovery/discard behavior through existing providers | T04 and accepted UI contract | App | 4 | Proposed | -| C1-S2-T06 | PYB-006.2 | Complete non-destructive error handling, truthful scope and localized recovery guidance | T04/T05 | App | 2 | Proposed | -| C1-S2-T07 | PYB-006.2 | Run affected controller/widget/lifecycle regressions and repository gates; resolve scoped failures | T04–T06 | App + QA | 2 | Proposed | -| C1-S2-T08 | PYB-008.1 | Run representative Android restart/interruption scenarios on the recorded candidate; retain actual results | T01/T07; device/candidate | QA | 2 | Proposed | -| C1-S2-T09 | PYB-008.1 | Run representative iPad restart/interruption scenarios on the recorded candidate; retain actual results | T01/T07; device/candidate | QA | 2 | Proposed | -| C1-S2-T10 | PYB-006.2 | Review integration/evidence and update user guidance plus carried A-24/Blocks limitations | T07–T09 and all applicable acceptance | App + reviewer | 1 | Proposed | -| C1-S2-T11 | PYB-011.2 | Collect due consented follow-up and account for early windows, missing responses and task opportunity | 004 prospective method; actual activation dates | Pilot | 1 | Proposed | +| C1-S2-T01 | PYB-009.1 | Complete the scoped restart/setup/operator recipe and expected evidence for this increment | 006.1 scope; initial 009.1 inventory | QA + App | 2 | Prepared — scoped recipe; execution/reproduction pending | +| C1-S2-T02 | PYB-006.2 | Add meaningful failing tests for the accepted write boundary, relaunch recovery and absence of implicit board/network actions | Approved 006.1; specification-first sequence | App | 2 | Implemented — component tests; final integration pending | +| C1-S2-T03 | PYB-006.2 | Add failing tests for interrupted/corrupt/full local storage and stale completions against newer edits | Approved 006.1 | App | 2 | Implemented — failure/race tests; final integration pending | +| C1-S2-T04 | PYB-006.2 | Implement the minimal accepted durable storage/migration behavior until relevant tests pass | T02/T03 red evidence | App | 4 | Implemented — candidate storage; acceptance pending | +| C1-S2-T05 | PYB-006.2 | Implement hydration and specified recovery/discard behavior through existing providers | T04 and accepted UI contract | App | 4 | Implemented — candidate coordinator/UI; acceptance pending | +| C1-S2-T06 | PYB-006.2 | Complete non-destructive error handling, truthful scope and localized recovery guidance | T04/T05 | App | 2 | Implemented — candidate guidance/errors; acceptance pending | +| C1-S2-T07 | PYB-006.2 | Run affected controller/widget/lifecycle regressions and repository gates; resolve scoped failures | T04–T06 | App + QA | 2 | In progress — integrated regression and gates | +| C1-S2-T08 | PYB-008.1 | Run representative Android restart/interruption scenarios on the recorded candidate; retain actual results | T01/T07; device/candidate | QA | 2 | Awaiting physical candidate evidence — Android | +| C1-S2-T09 | PYB-008.1 | Run representative iPad restart/interruption scenarios on the recorded candidate; retain actual results | T01/T07; device/candidate | QA | 2 | Awaiting physical candidate evidence — iPad | +| C1-S2-T10 | PYB-006.2 | Review integration/evidence and update user guidance plus carried A-24/Blocks limitations | T07–T09 and all applicable acceptance | App + reviewer | 1 | In progress — integration review and evidence reconciliation | +| C1-S2-T11 | PYB-011.2 | Collect due consented follow-up and account for early windows, missing responses and task opportunity | 004 prospective method; actual activation dates | Pilot | 1 | Awaiting participant evidence — no activation dates supplied | | **C1-S2 total** | | | | | **24** | | These allowances do not assert that persistence fits 24 hours. Developers must confirm the estimate or change the forecast at Planning. Required corruption/recovery protections remain part of the usable slice. Representative runs cannot complete the whole five-profile release matrix. +Current S2 task states are source/evidence progress, not additional Done +increments. [The S2 record](c1-s2/00_Execution_Record.md) supplies current proof; +[the scoped recipe](c1-s2/01_Operator_Recipe.md) keeps physical steps and expected +oracles explicit. Missing participants are not technical test failures and have +not been replaced by maintainer or automated sessions. + ## C1-S3 — establish reproducible evidence and useful tasks | Sprint control | Value | diff --git a/docs/developments/2026-q4-reliable-workflow/c1-s1/00_Execution_Record.md b/docs/developments/2026-q4-reliable-workflow/c1-s1/00_Execution_Record.md index 938d2108..ee5485be 100644 --- a/docs/developments/2026-q4-reliable-workflow/c1-s1/00_Execution_Record.md +++ b/docs/developments/2026-q4-reliable-workflow/c1-s1/00_Execution_Record.md @@ -69,6 +69,15 @@ The original 136-hour task allocation, 20-hour planned headroom and 84-hour rese | Worktree method | Recovery design, pilot preparation and qualification inventory used separate worktrees; the retry also isolates Android, iPad, planning updates and integration before review | | Required next human actions | Name the pilot owner and consenting cohort, confirm exact reference board/setup and perform its smoke check; collect five actual baseline starts and analyze records; confirm actual capacity before selecting the revised next Sprint | +## 31 October handoff to bounded implementation + +The user requested the next-release implementation after the C1-S1 integration +and cleanup. The [C1-S2 record](../c1-s2/00_Execution_Record.md) now tracks candidate +source implementation and its outstanding integrated/physical acceptance. The +25 September findings above remain historical evidence. This handoff does not +complete the two Prepared or four Awaiting-evidence S1 tasks, M-01/SG0, full A-24, +participant observations, a Ready badge or store delivery. + ## Initial integrated verification — before the device retry | Check | Actual result / scope | diff --git a/docs/developments/2026-q4-reliable-workflow/c1-s1/03_Recovery_Design_and_Sizing.md b/docs/developments/2026-q4-reliable-workflow/c1-s1/03_Recovery_Design_and_Sizing.md index 635b85df..17d39959 100644 --- a/docs/developments/2026-q4-reliable-workflow/c1-s1/03_Recovery_Design_and_Sizing.md +++ b/docs/developments/2026-q4-reliable-workflow/c1-s1/03_Recovery_Design_and_Sizing.md @@ -14,6 +14,12 @@ | Runtime / physical results | None produced by this design task; current released/source behavior remains volatile | | Effort records | Original S1 allowance: 3h + 1h. These are planning allowances, not an assertion of four elapsed hours or a team velocity | +**31 October handoff:** this is the S1 design/inventory baseline. The +[C1-S2 implementation record](../c1-s2/00_Execution_Record.md) and +[scoped operator recipe](../c1-s2/01_Operator_Recipe.md) now track candidate source +and actual acceptance states. Original estimates are not measured effort or +confirmed future capacity; historical device access is not a new candidate run. + ## 1. Source-grounded findings | Source inspected | Observed behavior | Design consequence | diff --git a/docs/developments/2026-q4-reliable-workflow/c1-s1/04_Validation_Inventory_and_Operator_Recipe.md b/docs/developments/2026-q4-reliable-workflow/c1-s1/04_Validation_Inventory_and_Operator_Recipe.md index 4dd322a0..5c5495f7 100644 --- a/docs/developments/2026-q4-reliable-workflow/c1-s1/04_Validation_Inventory_and_Operator_Recipe.md +++ b/docs/developments/2026-q4-reliable-workflow/c1-s1/04_Validation_Inventory_and_Operator_Recipe.md @@ -15,6 +15,12 @@ This is an operator entry point to maintained suites, not a new harness or release schema. Follow the selected candidate's versioned contracts and the current [HIL guide](../../../../tests/firmware_tests/hil/README.md). A host test proves the tested software contract; a physical run proves only its recorded artifact, device and scenario. Neither supplies participant-observation evidence or a PyBLE Ready award. +**31 October handoff:** this is the S1 design/inventory baseline. The +[C1-S2 implementation record](../c1-s2/00_Execution_Record.md) and +[scoped operator recipe](../c1-s2/01_Operator_Recipe.md) now track candidate source +and actual acceptance states. Original estimates are not measured effort or +confirmed future capacity; historical device access is not a new candidate run. + ## 1. Evidence and gate selection | Proposed claim | Required evidence and authority | What remains separate | diff --git a/docs/developments/2026-q4-reliable-workflow/c1-s2/00_Execution_Record.md b/docs/developments/2026-q4-reliable-workflow/c1-s2/00_Execution_Record.md new file mode 100644 index 00000000..eeef9d1b --- /dev/null +++ b/docs/developments/2026-q4-reliable-workflow/c1-s2/00_Execution_Record.md @@ -0,0 +1,133 @@ + + + +# C1-S2 — bounded current-text recovery implementation + +| Field | Record | +|---|---| +| Execution date | 31 October 2026; Asia/Bangkok | +| Authorization | User requested implementation for the next release after the C1-S1 integration and stage review | +| Integrated starting baseline | `8dde8d6107e4b3c3fab29340ecec5b40c877444b`; previously merged C1-S1 work | +| Candidate | App `0.3.0+9`; local source/build candidate, with final revision/artifact identity to be recorded at qualification | +| Scope | PYB-006.2 bounded current-text recovery; supporting PYB-009.1 recipe and applicable PYB-008.1 observations | +| Contract | [Current-draft recovery](../../../specifications/App/current-draft-recovery.md), [ADR-0048](../../../decisions/0048-bounded-current-text-draft-recovery.md) | +| Overall state | Candidate source implemented; 972 ordinary Flutter tests pass, Android engineering recovery checks pass, and required physical acceptance remains partial. PYB-006.2 and M-02 are not Done | +| S1 state | Unchanged: six Done, two Prepared, four Awaiting evidence; M-01/SG0 and pilot work remain unfinished | +| Capacity/calendar | Original S2 allowance 24h and 12–25 October dates remain historical forecasts. The 45–72h estimate / 60h S2–S4 option is not measured effort or confirmed human capacity | +| Method | Separate storage, coordinator and UI Git worktrees; root integrates board/session guards, regressions, release records and qualification attempts | + +## User outcome and implementation + +An acknowledged current-text checkpoint can be offered after an app restart for +explicit local Restore or Discard. The bounded feature protects one text slot; +it does not implement project management, Blocks workspace recovery or backup. +Existing channel installations are separate from this unqualified candidate. + +| Component | Candidate behavior | Evidence boundary | +|---|---|---| +| Drift repository | `projects` / `project_files` subset and one SettingsStore; atomic content/metadata/generation; exact UTF-8 and SHA-256; 1 MiB admission bound | File-backed tests/schema fixtures plus Android/iPad native storage harnesses; full interaction/board acceptance remains pending | +| Production storage | `pyble.sqlite` below the platform app-private Application Support directory; owned background database connection with WAL and FULL synchronization | No cache/scratch/preferences substitute; preserve database/WAL/journal on failure | +| Recovery coordinator | One active checkpoint and one coalesced pending revision; 500ms idle and maximum 2s active scheduling; manual/lifecycle flush; durable acknowledgement separate from board dirty | Controller timer/epoch/failure tests; callbacks are not a promise that every last keystroke survives termination | +| Startup and local panel | Resolve storage before Connect mounts; explicit preview/Restore/Discard; ten-second bounded wait; Retry / clearly volatile continuation | ARB-backed widget/semantics checks; disconnected preview/copy is a narrow shell exception | +| Guarded replacement | Confirm meaningful pending/dirty replacements; commit replacement/tombstone before adoption; keep live text if storage fails; require explicit volatile fallback | New, Board Open and Blocks-to-Editor keep captured editor epochs across confirmation and asynchronous work | +| Board authority | Restored text is dirty and unbound; Save/Run/Files Upload require current destination consent; paths in recovery metadata confer no authority | Recording-connection/session tests; physical board consent and absence of hidden writes remain required | +| Stale work | Epoch checks detect edit-away/back; facade/session guards fence Board Open and Save/Upload completion; PUT-to-Run refuses a changed attachment | A stable Connection facade is tested; no persistent board identity/session stamp is introduced | +| Unsafe store state | Conflict/corrupt/unsupported checkpoint state stops writes; Retry rereads and offers a choice without replacing live text | Generation conflict regression; no silent reset, downgrade or deletion | + +## Sprint tasks and delivery state + +These statuses report progress toward the selected outcome. **Implemented** is +source progress; it is not a Done Increment or release admission. + +| Task | Current state | Remaining acceptance | +|---|---|---| +| C1-S2-T01 / PYB-009.1 | Prepared — [scoped recipe](01_Operator_Recipe.md) | Execute against exact candidate; reconcile observations and operator gaps | +| C1-S2-T02 / PYB-006.2 | Implemented — checkpoint/relaunch/side-effect tests | Final integrated regression and candidate evidence | +| C1-S2-T03 / PYB-006.2 | Implemented — storage failure/interruption/stale-operation tests | Final integrated regression and candidate evidence | +| C1-S2-T04 / PYB-006.2 | Implemented — durable storage and schema | Final integration and physical storage evidence | +| C1-S2-T05 / PYB-006.2 | Implemented — startup choices/coordinator/UI | Final integration and physical interaction/restart evidence | +| C1-S2-T06 / PYB-006.2 | Implemented — failure guidance/localization/volatile choices | Final localization/accessibility/regression checks | +| C1-S2-T07 / PYB-006.2 | Implemented and checked — full analysis, 972 ordinary tests, formatting and source gates pass | Pinned golden CI and final release evidence remain tracked separately | +| C1-S2-T08 / PYB-008.1 | Awaiting physical Android candidate evidence | Lenovo connected and isolated engineering storage/relaunch/offline UI checks pass; remaining board/interaction matrix recorded in [platform report](02_Platform_Qualification.md) | +| C1-S2-T09 / PYB-008.1 | Awaiting physical iPad candidate evidence | iPad USB signed profile seed → kill → verify checks pass; full physical UI/board interaction matrix remains pending | +| C1-S2-T10 / PYB-006.2 | In progress — integration/evidence review | Record final checks, immutable candidates, physical results and carried limitations | +| C1-S2-T11 / PYB-011.2 | Awaiting participant evidence | No consenting activation dates or mature follow-up records supplied | + +## Required scenario evidence + +Component reports below are dated engineering results before final integration. +Overlapping runs must not be added into one test count. The integration owner +records the final complete run and exact candidate revision before acceptance. + +| Scenario | Implemented evidence | Current acceptance state | +|---|---|---| +| DR-T01 | Real repository validation/round-trip tests: exact Unicode, filenames, content bound and invalid metadata | Automated integrated suite passing | +| DR-T02 | File-backed close/reopen retains the committed slot and generation | Automated suite and Lenovo separate-process harness passing; iPad separate-process profile harness passing; full interaction matrix pending | +| DR-T03 | Child-process SIGKILL before/after write checkpoints; whole old/new committed content oracle | Host interruption tests and Lenovo force-stop/relaunch pass; precisely timed physical interruption phases and complete interaction matrix pending | +| DR-T04 | Real storage failure injection plus controller failure state retains previous copy and live edits | Automated integrated suite passing | +| DR-T05 | Bad hash/metadata, corrupt/truncated database and unsupported version fixtures; bounded startup error UI | Automated integrated suite passing | +| DR-T06 | Fresh v1/reopen, exported first schema and unsupported-version refusal; no fictional released v0 | Component tests passing; future schema migrations remain gated on new fixtures | +| DR-T07 | Explicit Restore/Discard, failed discard, volatile Retry, late hydration and no implicit board writes | Automated suite and Lenovo Restore/offline preview/relaunch pass; complete interaction matrix pending | +| DR-T08 | Deterministic 500ms / continuous-edit 2s scheduling; delayed commits, startup timeout and epoch tests | Component tests passing; physical lifecycle interruption pending | +| DR-T09 | Active write/replacement serialization, tombstones and no queued resurrection | Automated integrated suite passing | +| DR-T10 | Recovered-target cancellation and stale document/path/session consent | Recording-connection tests and Lenovo current-target Save/Run consent pass; iPad physical board consent pending | +| DR-T11 | Stable-facade board swap, delayed GET, stale completion and PUT-to-Run attachment refusal | Recording-connection tests passing; final integrated regression pending | +| DR-T12 | Explicit generated text handoff with editor-epoch/revision guards; existing workspace/sidecar behavior retained | Affected automated regression required in final suite; representative device checks pending | +| DR-T13 | Local/board status separation, ARB error/status copy, disconnected preview, keyboard and 2× text | Automated UI/localization pass; physical Lenovo 2× preview observed; local golden baseline mismatch reproduced unchanged, pinned CI pending | +| DR-T14 | Exact recorded Android/iPad candidate, offline restore and explicit current target consent | Partial Lenovo engineering observations; signed iPad profile seed/verify pass; physical board consent still pending | + +## Verification and remaining admission + +| Check | Reported result / limitation | +|---|---| +| Storage component suite | 21 passing, including file-backed tests and fault-controlled child-process SIGKILL checks | +| Coordinator/document suite | 27 passing, including failed discard, delayed hydration, replacement/session races and conflict Retry choice | +| UI component suite | 57 passing in the latest component report; included in the final ordinary suite | +| Integrated storage/coordinator/guard subset | 51 passing in an integration subset; overlaps component suites | +| Static analysis | Full app analysis reported no issues at the latest integration checkpoint | +| Full app regression and repository gates | At app source `90311f3`: 972 ordinary Flutter tests pass; analysis clean; 204 Dart files formatted; no-leak/SPDX/import/locale/DCO pass; 42 publication tests and Markdown links pass after documentation updates | +| Android additional harness | Lenovo Android 12 separate-process seed/verify and production-entry offline UI checks pass in an isolated app; [scope/artifacts](02_Platform_Qualification.md) | +| iPad additional harness | Signed profile build/install/launch succeed. Seed and verify assertion receipts pass across a killed process; closed database hash unchanged. Flutter debug path remains unavailable | +| Candidate store state | No Google Play/TestFlight submission, approval, new public availability or installed-store verification established by this work | +| Firmware and Ready | No new firmware publication or complete five-profile qualification; no second-operator reproduction or PyBLE Ready award | + +```mermaid +flowchart LR + S1["S1 design and existing-installation evidence"] --> SOURCE["Bounded candidate source"] + SOURCE --> HOST["Automated integration and source gates"] + HOST --> PHYSICAL["Android and iPad candidate observations"] + PHYSICAL --> REVIEW["M-02 and applicable release review"] + REVIEW --> CHANNEL["Submission, availability, then installed evidence"] + PILOT["Pilot owner, cohort and actual sessions"] --> M01["M-01 and usability evidence"] + M01 -. "Independent evidence stream" .-> REVIEW +``` + +The next required work is the final integrated regression plus exact-candidate +physical recovery/consent observations and any affected release matrix. Preserve +failed/not-run rows rather than weakening the contract to meet a date. Human +pilot work continues as its own evidence stream. No full A-24 completion, +qualified release or publication follows from source commits alone. + +## Local golden diagnosis and review branch + +The 33 local pixel comparisons fail on macOS 27.0.1 with Flutter 3.44.1. +All 33 rendered test images match byte-for-byte with the same tests run from +unchanged `origin/main` at `8dde8d6` on this host. Existing baselines are retained; +this result is not a golden pass. The workflow pins macOS 15, whose result is +tracked on [draft PR #40](https://github.com/PyBLE-dev/PyBLE/pull/40). +At `709556e`, both the push and pull-request workflow runs passed all 18 jobs +(36 successful checks total), including the pinned golden and native Android +jobs. Subsequent changes in this record are documentation-only; their workflow +state remains visible on the PR. The source and documentation are reviewable +on `feature/current-draft-recovery`. +No merge or store publication is implied by that draft. + +The final boundedness audit added SQL byte/count preflights before metadata or +row materialization: corrupt metadata is capped before JSON reaches the UI +isolate. The final ordinary suite is 972 passing tests. [Platform results](02_Platform_Qualification.md) +include successful iPad profile assertion receipts and a killed-process boundary. +All five boards now appear in the Lenovo scan after the [classic ESP32 recovery](03_Classic_ESP32_Workspace_Recovery.md). +Its workspace exactly matched an intentional invalid-media fixture left by an +earlier qualification run. A fresh backup and the verified valid original +workspace were preserved; restoring only that region returned `8C9E` to normal +advertising. No firmware or scan-count logic changed. diff --git a/docs/developments/2026-q4-reliable-workflow/c1-s2/01_Operator_Recipe.md b/docs/developments/2026-q4-reliable-workflow/c1-s2/01_Operator_Recipe.md new file mode 100644 index 00000000..f62f3a4d --- /dev/null +++ b/docs/developments/2026-q4-reliable-workflow/c1-s2/01_Operator_Recipe.md @@ -0,0 +1,155 @@ + + + +# C1-S2 — scoped recovery operator recipe + +| Field | Record | +|---|---| +| Status | Prepared 31 October 2026; candidate execution/reproduction and final results remain pending | +| Applies to | Bounded current-text candidate `0.3.0+9`, PYB-006.2; no full project/Blocks recovery claim | +| Source | [Normative contract](../../../specifications/App/current-draft-recovery.md), [S1 inventory/cost model](../c1-s1/04_Validation_Inventory_and_Operator_Recipe.md) | +| Result owner | Actual operator records name, date, immutable candidate/artifacts and raw-evidence location; no absent owner is invented | +| Public result | [Execution record](00_Execution_Record.md); private device IDs, source captures and raw screenshots stay in ignored local evidence | + +## Setup and artifact binding + +| Prerequisite | Required record / check | +|---|---| +| Source and app | Exact source SHA, clean/dirty state, `pubspec` version/build, platform build command, retained artifact SHA-256 and installed identity; do not identify a candidate solely by a branch name | +| Devices | Actual Android model/API and iPad model/iPadOS; available application installation/signing route; accessible persistent storage | +| Current access | Android Lenovo is absent from current ADB discovery; an emulator is supplemental only. iPad is visible wirelessly but Developer Mode currently blocks development execution | +| User data | Preserve existing store installations and their data. Use a separate qualification identity/container when running test applications; never uninstall or clear app data to manufacture a passing recovery result | +| Board-backed steps | Exact supported board profile, firmware identity, harmless setup and explicit destination; reserve a disposable user file that cannot affect startup or the PyBLE agent | +| Evidence | Ignored local folder with commands, timestamps, outcome logs and artifact hashes; publish only reviewed redacted results, not device identifiers or user source | +| Connectivity | Keep network/BLE unavailable during local startup/Restore checks; connect deliberately only for the later target-consent steps | + +A remembered September installation is not this candidate's evidence. An +emulator, recording connection or unit fake cannot replace required physical +observations. Do not flash a different firmware merely to exercise this +app-only storage feature; use a recorded applicable baseline and the selected +affected matrix. + +## Automated checks + +Run commands from `app/` with the repository's pinned Flutter toolchain. Preserve +exit codes and the exact integrated SHA. Component runs aid diagnosis; the +integrated suite is authoritative for the combined candidate. + +```sh +flutter pub get --enforce-lockfile +flutter analyze +flutter test test/data/draft_repository_test.dart +flutter test test/unit/draft_recovery_test.dart test/unit/editor_document_test.dart +flutter test test/unit/recovery_board_races_test.dart +flutter test test/widget/recovery_gate_test.dart test/widget/recovery_panel_test.dart +flutter test +``` + +From the repository root, run the applicable source/document/dependency gates: + +```sh +tools/ci/no_leak.sh +tools/ci/import_boundary.sh +tools/ci/locale_parity.sh +tools/ci/spdx_lint.sh +python3 tools/ci/docs_links.py +``` + +Use the existing Drift schema fixture/export tests to verify schema v1. A future +schema bump needs explicit forward migration and old/new fixtures; never invent +a persisted pre-recovery release. Local full/corrupt/I/O fault injection is +storage evidence, distinct from a board PBLE `ENOSPC` response. Child-process +SIGKILL tests demonstrate host database behavior, not iPad/Android OS lifecycle. + +## Physical current-text workflow + +Run the following on **each** required physical candidate. Record actual +pass/fail/not_run/blocked and the observed state; repeat affected rows after a +source/artifact change. Retain the original failure before any repair/rerun. + +| Step | Operator action | Required oracle / evidence | +|---|---|---| +| P01 | Launch the recorded candidate with network/BLE unavailable | Storage choice/error resolves before any connection workflow; no permission/scan/board operation is required for recovery | +| P02 | Reach Editor through the normal existing workflow; enter a disposable named draft containing whitespace, Unicode comments and punctuation | Accepted text stays exact locally; board normalization is a separate explicit board-action behavior | +| P03 | Invoke **Save recovery copy** and wait for its completion | Saved-local status appears only after the matching transaction; board dirty state remains independently truthful | +| P04 | Terminate the app process using platform tooling; relaunch the same app/container without clearing data | Recovery preview shows the last acknowledged exact draft, filename and checkpoint; no implicit board/network action | +| P05 | Use Back/dismiss and return to recovery | Decision remains unresolved and durable copy remains intact; an empty Editor never overwrites it | +| P06 | Explicitly Restore while disconnected | Exact text visible locally; `dirty=true`, no active historical board target; copy/export only on explicit user action | +| P07 | Edit again; exercise normal idle/continuous editing and a pause/relaunch | Local pending/saving/saved states are truthful. A last uncommitted edit may be absent; the previous complete acknowledged checkpoint must remain recoverable | +| P08 | Relaunch and cancel Discard confirmation; then confirm Discard | Cancel retains copy/prompt; success commits an empty slot before installing a fresh buffer; next launch has no prompt for that empty untitled slot | +| P09 | Exercise a deliberately empty **named** file through checkpoint/relaunch | Empty named document remains recoverable; it is not confused with a confirmed empty untitled slot | +| P10 | Restore a draft, connect deliberately to a harmless recorded board and request Save/Run | Consent shows the actual current destination and write/run effect. Cancel sends no write/run; recovery itself never listed/probed or wrote a board | +| P11 | Request Files Upload after moving the current directory; change document/session/target while consent is pending where the harness permits | Actual `cwd/name` destination is shown; stale consent cannot authorize another target/session/revision | +| P12 | Confirm a harmless board action, then check dirty/local-copy state; reconnect to another session and act again | Only matching completion binds/clears board dirty. Recovered text still requires explicit current-session consent; no automatic upload/run | +| P13 | Try New, Board Open and Blocks-to-Editor over meaningful pending/dirty text | Captured-epoch confirmation protects current text; failed local replacement keeps it and offers Cancel or explicit volatile fallback | +| P14 | Copy generated Python from Blocks into Editor and checkpoint/relaunch | The text can recover; workspace graph/revisions/sidecars are not represented as restored by the local text slot | +| P15 | Check recovery choice/error/local panel with 2× text, keyboard and screen reader | Controls scroll/remain reachable, focus/semantics announce correctly, no technical filename/path/source is translated | + +Platform-controlled interruption before/during/after checkpoint should preserve +whole permitted old/new content. If a required phase cannot be deterministically +hit on a device, record that limitation and its host fault-test evidence; do not +claim the physical phase passed from timing alone. Use the existing safe HIL +procedures for any affected BLE/profile matrix rows. + +## Additional isolated platform-storage harness + +`app/integration_test/draft_recovery_device_qualification.dart` provides `seed` and `verify` +phases using the actual platform Application Support store and a recording board +seam. The phase is selected with `PYBLE_RECOVERY_TEST_PHASE=seed` or `verify`. +The qualification database is `qualification-current-draft.sqlite`, separate +from the production slot. Build/launch seed, actually terminate its process, +then build/launch verify using the same isolated application identity/container. +Record that transition and artifact identities. A successful single-process +rerun does not prove process restart. The harness verifies local storage and +cancellation with a fake board; it does not prove physical BLE behavior or the +production recovery UI, and does not replace P01–P15. + +## Failure, storage and rollback boundaries + +| Condition | Required handling | +|---|---| +| Read/commit timeout | After ten seconds expose Retry / Continue without recovery. Late completion cannot hydrate newer live text; do not reopen/delete storage under an active task | +| Full/permission/I/O failure | Retain live text and previous committed copy; report failed local protection and offer Retry. Export/copy only by explicit user action | +| Corrupt or unsupported store | Preserve `pyble.sqlite` and companions; offer bounded choices without reset/downgrade. Retrying must not silently overwrite either current or recovered text | +| Upgrade from volatile release | First persistence schema starts empty; no promise to recover a pre-upgrade in-memory draft | +| Downgrade | Do not write an unsupported newer schema. Retain data for a compatible app; do not delete the database as a rollback procedure | +| App removal/data clearing | Outside the guarantee. Do not use it as a recovery or acceptance step on user data | + +Production storage uses the platform `getApplicationSupportDirectory()` path. +The Android manifest currently sets no app-specific `allowBackup`, custom +`fullBackupContent` or `dataExtractionRules` override. The iOS source currently +sets no app-managed backup exclusion or alternate data-protection attribute for +this database. OS backup/restore behavior therefore follows platform/user/device +policy; it is not a PyBLE backup guarantee and has not been qualified here. +PyBLE adds no app-managed remote synchronization or source telemetry. Record any +packaged settings that differ from this source before admitting a candidate. + +## Recording and admission + +| Evidence row | Required content | +|---|---| +| Automated | Integrated SHA, command/toolchain, exit code and result log; counts not summed across overlapping suites | +| Physical | Platform/device redacted identity, exact installed app/firmware, procedure step, expected/observed result, timestamp, operator and raw-evidence reference | +| Limitation | Exact unrun/failed scenario, concrete access/artifact/setup gap and next action; no inferred pass | +| Admission | DR-T01–14 and applicable release matrix accounted for; required failures resolved; qualified artifact identity and review recorded before publication | +| Follow-up | Participant/retention evidence remains under the S1 pilot protocol; no fake cohort, activation or named human sign-off | + +Two representative recovery runs do not qualify five firmware profiles or award +PyBLE Ready. This recipe is complete as a prepared instruction set; execution, +correction from actual observations and second-operator reproduction remain +separate evidence obligations under PYB-009. + +The manual harness is deliberately separate from the single CI Android smoke +entry point. Use `--keep-app-running` with `flutter drive` for the seed phase: +its default cleanup uninstalls the app and removes its private data. Force-stop +that exact isolated package before the verify phase, retain both artifacts, and +verify the second installation preserves the same data container. Never run a +qualification harness over a user's installed store app. + +For iOS where Flutter's debug launch is unavailable, a signed profile build of +this same harness can launch through `devicectl`. Copy the phase's result JSON +from app-private Application Support and require `passed=true`; its receipt is +written only after every assertion and repository close. Kill and verify absence +of the seed process before installing/launching verify into the same container. +This is native harness evidence, not a substitute for production UI/physical BLE +checks. See the [actual platform report](02_Platform_Qualification.md). diff --git a/docs/developments/2026-q4-reliable-workflow/c1-s2/02_Platform_Qualification.md b/docs/developments/2026-q4-reliable-workflow/c1-s2/02_Platform_Qualification.md new file mode 100644 index 00000000..dd45e395 --- /dev/null +++ b/docs/developments/2026-q4-reliable-workflow/c1-s2/02_Platform_Qualification.md @@ -0,0 +1,172 @@ + + + +# Bounded recovery — platform engineering observations + +| Field | Record | +|---|---| +| Date / operator | 31 October 2026, Asia/Bangkok; automated operator on the maintainer's Mac | +| Source | App source candidate `0.3.0+9`; final bounded source `90311f3` (including storage preflight fix `8657e5a`); earlier source snapshots identified below | +| Scope | Local storage, explicit offline recovery and platform builds; partial evidence toward DR-T02/03/07/13/14 | +| Acceptance | Engineering observations only; full physical board consent, interruption/interaction matrix and release qualification remain pending | +| Raw evidence | Ignored `local/recovery-implementation/`: commands/results, source manifests, screenshots/UI dumps, retained checkpoint and APK; raw captures are not committed | +| Review | [Execution record](00_Execution_Record.md), [operator recipe](01_Operator_Recipe.md), [draft PR #40](https://github.com/PyBLE-dev/PyBLE/pull/40) | + +## Isolation and artifact identity + +The installed store applications were preserved. Qualification used copied app +sources and a separate application identity. Android used +`dev.pyble.recoveryqualification.integrationtest`; iOS used +`dev.pyble.recoveryqualification`. These are engineering identities, not new +production packages or store submissions. Only the ignored copied projects had +bundle/application IDs and the Android namespace changed. + +The Android production-entry build uses `lib/main.dart` and the integration +flavor/debug signing. All Dart runtime source matches integrated `4479ff6` +except two subsequently removed redundant imports in `app/shell.dart` and +`blocks/blocks_document.dart`; the exact build source manifest is retained. +Neither that relationship nor the passing checks establish release-signature +or whole store-binary equivalence. + +| Artifact / record | Identity | +|---|---| +| Retained Android production-entry APK | `artifacts/pyble-0.3.0-9-recovery-qualification.apk` beneath the ignored evidence directory | +| APK SHA-256 | `1492d55e1cb9583afcbe1c0c083365ad16044722fe267814751ac0d90b9155c4` | +| Android version / SDKs | `0.3.0 (9)`; min SDK 24, target/compile SDK 36 | +| Earlier verify-harness APK hash at execution | `da234794379dc4b3ed249481a304d9891b4eda61020b802b111a57f7b56bbcab`; that build output was subsequently replaced by the retained production-entry APK | +| Seed/verify closed database SHA-256 | `a2e9027dbc29cd743b28bdcaf49e90d876c2a35b37b94253304c45fa0b2f3ac4`; identical after separate-process verify | +| Build toolchain | Flutter 3.44.1, Dart 3.12.1, macOS 27.0.1 on Apple Silicon | + +## Lenovo TB-J616X — Android 12 / API 31 + +| Step | Actual execution and result | +|---|---| +| Availability | Device reconnected by the owner and recognized by ADB/Flutter over USB | +| Seed | Real platform Application Support database received exact Unicode text, filename, origin context and committed generation; native integration harness passed | +| Process boundary | Seed app retained with `flutter drive --keep-app-running`; exact isolated package then force-stopped through ADB. Default Flutter drive cleanup uninstalls the app, so an initial non-retained attempt was excluded from restart evidence | +| Verify | Verify-phase APK installed over the same isolated package/container and launched separately; exact text restored dirty/unbound and declined board Save sent no PUT through the recording seam; harness passed | +| Integrity | Closed database hash after verify equals the seed checkpoint hash | +| Production entry | Normal `lib/main.dart` candidate launched with an explicitly seeded disposable `pyble.sqlite`; startup displayed filename, real checkpoint time, exact Unicode text and Restore/Copy/Discard controls | +| Explicit Restore | Restore selected through the native UI; app displayed Disconnected, Saved locally and the exact current-text preview. No board was selected or connected | +| Relaunch | Package force-stopped, absence of its process checked, then launched afresh. After startup settled, Restore/Copy/Discard were offered again; no implicit restoration | +| Large text | System font scale temporarily set to 2.0; startup preview, source and all actions remained visible/reachable in landscape. Screenshot reviewed. Original font scale restored in a `finally` block | +| Clipboard | No Copy action was invoked during the native UI observation; source was not copied automatically | + +The harness uses a recording connection. It proves native local storage and +cancellation behavior, not physical PBLE/1 traffic. The production-entry +observations prove the visible disconnected recovery path; no claim of a +connected board overwrite/run test or packet capture is made. Keyboard and +screen-reader regressions have automated coverage; full physical assistive +technology and P01–P15 coverage are still outstanding. + +## iPad16,10 — iPadOS 26.6.2 + +| Step | Actual execution and result | +|---|---| +| Discovery | Initially wireless, then connected over USB by the owner and listed by Flutter/devicectl | +| Release compile | `flutter build ios --release --no-codesign` succeeded in the isolated copied project | +| Signed harness compile | `flutter build ios --debug --target integration_test/draft_recovery_device_qualification.dart --dart-define=PYBLE_RECOVERY_TEST_PHASE=seed` succeeded using an available development identity | +| Installation | Direct `devicectl` installation of the signed isolated harness succeeded; the existing TestFlight application was preserved | +| Launch | Wireless and USB Flutter debug attempts reported that Developer Mode must be enabled; subsequent signed profile harness execution succeeded as recorded below | +| Next action | Complete production UI/board P01–P15 as applicable. Profile seed → terminate → verify has passed using current settings; Developer Mode remains an optional prerequisite for the separate debug/JIT route | + +A signed build is build evidence. It does not establish successful installation, +execution, offline restoration, TestFlight availability or physical BLE behavior. +Any further install/launch result must be recorded separately. + +## Final bounded candidates and iPad native execution + +| Evidence | Result | +|---|---| +| Final ordinary suite | 972 tests pass; full analysis clean after the metadata byte/count preflight fix | +| Final Android production-entry APK | `artifacts/pyble-0.3.0-9-recovery-final.apk`; SHA-256 `957982f5acec969f8003d04b2d6444eecbba9af26b7392d760badb5be5d6bd0e`; installed on Lenovo, version `0.3.0 (9)` | +| Final runtime source | Copied `lib/` is byte-identical to `90311f3`; only copied native bundle/application identities differ. Final source manifests retained | +| iPad route | Signed **profile** mode launches through `devicectl` using current device settings; this bypasses the unavailable Flutter debug/JIT route | +| iPad seed | All seed assertions and repository close completed; bounded test receipt says `phase=seed`, `passed=true`, `platform=ios`, completion `2026-10-31T04:06:31.192251Z` | +| iPad process boundary | Seed process 5826 killed with `devicectl device process terminate --kill`; subsequent process enumeration confirms it absent | +| iPad verify | Final bounded candidate installed into the same isolated container and launched separately; all exact-text, dirty/unbound, explicit Restore and declined-board-write assertions completed; receipt `passed=true` at `2026-10-31T04:08:45.983001Z` | +| iPad database integrity | Seed/verify closed files both SHA-256 `d3c028da3b7439503b9a6f54e5ab2520d60f9a4a7ca7af59a62b0d3b841587b6` | +| Retained iPad profile archives | Seed with receipt: SHA-256 `6d707ede856b1cd8de9c4ccd383b60b5ba86fe52c2d302d5c83e16edddc10514`; verify: `9588a9e94a9eb484b9408a8e86f61e0c12c8fadcc5147caf970f72f3411bcb87` | + +Receipts are written only after assertions and store close, are removed before +rerunning that phase, and contain bounded test metadata rather than user source. +The seed preceded the final corrupt-metadata bound; verify used the new bound +against that same valid v1 store. This proves compatible valid-state reopening. +These native profile harness checks do not claim the full iPad production UI or +physical BLE board scenarios have passed. + +## Five-board discovery observation + +The owner reported all five boards connected. A fresh Lenovo scan in the final +qualification app shows `PyBLE-DA86`, `PyBLE-3DCB`, `PyBLE-5646` and `PyBLE-C81A`. +The [historical hardware record](../../../testing/tutorial-five-board-screenshot-provenance-2026-08-28.md) +associates the absent `8C9E` label with classic ESP32, and `3DCB` with Pico 2 W. +Names are contextual labels, not new identity or authorization evidence. + +Source inspection found no four-result limit: the adapter filters by PBLE +service UUID, the accumulator retains distinct OS device IDs and the list +renders the complete hit count. Both ESP/Pico agents resume advertising after +disconnection. The live classic ESP32 was subsequently identified through its CP2102 ROM +interface: ESP32-D0WD revision 1.0, base MAC ending `8C:9C` (Bluetooth suffix +`8C9E`). A 115200-baud read of the first 256 KiB matches the retained c8f549ee +0.6.1 install prefix at offset 0x1000. The initial 460800-baud attempt had a +framing error; no flash write occurred. Exact complete-image equivalence is +not inferred from a prefix. + +A 45-second controlled startup trace then captured: +`PyBLE workspace recovery is required; reconnect by USB.`, followed by the +MicroPython 1.28.0 / PyBLE 0.6.1 banner and REPL. This confirms the workspace +mount/refusal path, which deliberately withholds BLE agent startup. It explains +the absent fifth advertisement. Short early captures missed the later message; +the guard scans the complete 2 MiB workspace before refusing it. + +Retained September qualification records for the same board contain an +intentional invalid-media fixture and a failed acquisition before its planned +recovery. Its fixture hash is preserved separately; this history alone does not +prove current contents. The fresh complete current workspace backup exactly matched that refusal fixture. The verified original owner backup +and its valid LFS2 workspace remain intact, with identical partition geometry. +ROM reads and startup tracing involved controlled resets. The verified valid pre-test workspace was then restored only to its original region, and the next scan shows all five boards. See the [recovery report](03_Classic_ESP32_Workspace_Recovery.md) for backups, hashes, offline inventory and the verified write. + +## Remaining release evidence + +Complete the iPad production interaction matrix, remaining Android interaction/interruption rows, +explicit harmless physical board Save/Run/Files destination consent, applicable +Blocks handoff checks and the selected release matrix. Preserve candidate +provenance and report every unrun row. Participant follow-up and the unfinished +M-01/SG0 pilot work remain separate from engineering recovery evidence. + +## Lenovo production workflow on recovered classic ESP32 + +The final production entry connected to `8C9E` and showed **Ready**, runtime +`esp32` and agent **0.6.1**. The initial Editor was empty. A disposable +`print(123)` draft was entered, explicitly checkpointed and shown **Saved +locally**. The app was force-stopped and relaunched; startup preview offered the +exact text before any connection. Explicit Restore displayed it while +Disconnected. Only then was the classic board selected again. + +Board Save displayed the exact `/untitled.py` destination and explained that +upload does not run code. Cancel dismissed that choice. A subsequent explicitly +confirmed upload succeeded and cleared board-dirty state while local status +remained separate. Run requested separate upload-and-run consent for the same +current destination. On confirmation the board console returned `123` and +**Finished**, and the connection returned Ready. Files refresh showed the +10-byte test file. The transfer stayed on the selected physical board; this is +an observed happy path, not a physical board-swap race test. + +Raw UI XML records under the ignored evidence directory include +`lenovo-live-relaunch-prompt.xml`, `lenovo-live-restored-offline.xml`, +`lenovo-real-save-consent.xml`, `lenovo-real-run-consent.xml` and +`lenovo-print-run-result.xml`. The automated recording seams remain the evidence +for exact zero-verb cancellation/race assertions; no radio packet capture was +claimed for the physical cancellation. + +Afterward only the created 10-byte `/untitled.py` test file was explicitly +deleted, with its exact-name confirmation. Existing directories were preserved. +New then committed the empty local slot; the disposable Editor text was cleared +and its saved-local status retained. A fresh launch after that tombstone showed no stale recovery prompt or test text, and the final disconnected scan again showed all five boards. + +The iPad's isolated harness was terminated and removed after its receipts, +checkpoint files and signed archives were retained. The original TestFlight +application remained installed and was brought forward without terminating it. +The separate Lenovo production-entry candidate remains available for review; +its test draft and board test file were cleared as described above. diff --git a/docs/developments/2026-q4-reliable-workflow/c1-s2/03_Classic_ESP32_Workspace_Recovery.md b/docs/developments/2026-q4-reliable-workflow/c1-s2/03_Classic_ESP32_Workspace_Recovery.md new file mode 100644 index 00000000..02b4b1b8 --- /dev/null +++ b/docs/developments/2026-q4-reliable-workflow/c1-s2/03_Classic_ESP32_Workspace_Recovery.md @@ -0,0 +1,85 @@ + + + +# Classic ESP32 — missing advertisement and workspace recovery + +| Field | Record | +|---|---| +| Date | 31 October 2026; Asia/Bangkok | +| Trigger | Owner connected all five boards but Lenovo scan showed four | +| Physical board | Classic ESP32-D0WD revision 1.0; CP2102 adapter; ROM base MAC ending `8C:9C`, Bluetooth suffix `8C9E` | +| Missing / present | `8C9E` absent; `C81A`, `3DCB`, `5646` and `DA86` present | +| Confirmed cause | Workspace contained exactly the intentional invalid-media fixture from the earlier qualification run; firmware correctly refused mounting and withheld BLE agent startup | +| Result | Verified pre-test workspace restored; native scan subsequently shows all five boards including `PyBLE-8C9E` | +| Firmware | Existing PyBLE 0.6.1 retained; no application, bootloader or partition-table update | +| Evidence | Ignored `local/recovery-implementation/`: startup trace, fresh backups, hashes, restoration log, offline inventory and five-board UI XML | + +## Diagnosis and preservation + +Source inspection established that there is no four-result limit. The adapter +filters only by PBLE service UUID, the accumulator retains distinct OS device +IDs and the UI renders the full result count. Pico 2 W (`3DCB`) was present. + +Serial ownership was free. Short UART captures saw only the ROM boot; a longer +45-second controlled capture reached the workspace recovery message, then the +MicroPython 1.28.0 / PyBLE 0.6.1 banner and REPL. This is the intentional +[nonblank-media protection](../../../specifications/firmware/TDD.md#91-vfs--littlefs) +path, rather than evidence of unsupported silicon or a scan-list cap. The +complete read-only media scan explains the delayed startup message. + +A 115200-baud ROM read established that the installed first 256 KiB matches the +retained c8f549ee candidate image prefix at flash offset 0x1000. An earlier +460800-baud attempt had framing errors; no write was attempted at that speed. +Only prefix correspondence is claimed, not an independently read whole-image +hash. + +The fresh **entire 2 MiB workspace** exactly matched the earlier refusal fixture: +first 8,192 bytes `0x00`, remainder `0xFF`. This proves the current region contains +the synthetic test fixture rather than a new user filesystem. The earlier run +failed after that test and never produced its planned erased-media/recovery +receipt. That leftover test state should have been restored before handback. + +| Preserved or prepared input | SHA-256 / validation | +|---|---| +| Fresh current workspace backup | `cc5fcfcad390f42a5f7b0603f08f0142e9d0618c3ac102cb2ffab08722626ee1`; 2,097,152 bytes, exact equality with retained fixture | +| Immutable original full owner backup | `2aa2280b9fa0d66bbd60e73d0858e8df62f8025aa1e93f1c900ea86eb36aca3e`; rehashed before extraction | +| Original valid workspace extraction | `33490a100808d7fc89854d079bfa072e53ee0d48a80aa2ce8cd895ca643a0f37`; same 2 MiB region and identical partition geometry | +| Offline original-workspace inspection | Explicit LFS2 mount on a copied buffer; one empty diagnostic `.pbltmp` file, no user programs or boot scripts; original bytes unchanged | +| Existing NVS region | Erased in the captured image; workspace restoration does not restore old autorun settings | + +The offline reader (`littlefs-python` 0.19.0) was installed only in an ignored +inspection environment. Its buffer was mounted explicitly without formatting; +no board or immutable backup was changed during inspection. It is not an app +or firmware dependency. + +## Controlled restoration and result + +The explicit operator restoration wrote **only flash 0x200000–0x3fffff**, using +the verified original workspace extraction. Both current and original backups +remain preserved. The operation restored the board from synthetic test data to +its valid pre-test workspace; it did not format unknown owner data or install a +new firmware image. + +The flash tool reported `Hash of data verified.` before its normal reset. The +next native Lenovo scan included `PyBLE-8C9E` at approximately −45 dBm and +reported **Boards seen: 5**. The other four advertisements remained present. + +```mermaid +flowchart LR + FIXTURE["Invalid test fixture left on board"] --> REFUSE["Workspace mount refused"] + REFUSE --> OFF["BLE agent not started"] + OFF --> FOUR["Four boards visible"] + BACKUP["Fresh backup and verified original workspace"] --> RESTORE["Restore workspace region only"] + RESTORE --> MOUNT["Valid LFS2 mount"] + MOUNT --> ADV["8C9E advertises again"] + ADV --> FIVE["Five boards visible"] +``` + +## Future qualification handback + +After intentional invalid-media or interrupted-install tests, retain the actual +failed evidence and explicitly restore or hand off the disposable board state. +A completed host report does not prove that the physical board is ready for +ordinary use. Verify normal startup and advertisement, and record remaining +work before disconnecting the bench. Do not run old destructive qualification +collectors as a recovery shortcut or silently format a nonblank workspace. diff --git a/docs/planning/PyBLE_Implementation_Plan_and_Strategy_v1.0.md b/docs/planning/PyBLE_Implementation_Plan_and_Strategy_v1.0.md index e1cf66ea..263d13dd 100644 --- a/docs/planning/PyBLE_Implementation_Plan_and_Strategy_v1.0.md +++ b/docs/planning/PyBLE_Implementation_Plan_and_Strategy_v1.0.md @@ -11,9 +11,9 @@ | Field | Value | |---|---| -| Document version | 1.4 — C1-S1 device retry and precise remaining states; original filename retained | +| Document version | 1.5 — bounded recovery candidate implementation; original filename retained | | Prepared | 25 September 2026 | -| Last reviewed | 25 September 2026; connected Lenovo/iPad channel verification and remaining pilot dependencies incorporated | +| Last reviewed | 31 October 2026; bounded current-text recovery candidate selected and implemented; acceptance tracked separately | | Status | C1-S1 has six tasks Done, two Prepared awaiting pilot setup and four Awaiting evidence; Sprint and SG0 remain unfinished. Future capacity and scope need replanning; no new protocol behavior approved | | Product | PyBLE — Python over Bluetooth Low Energy | | Organization | SciLabPro | @@ -37,6 +37,7 @@ | 1.2 | Focused pilot selection and useful second tasks; contextualized retention; defined one version-bound PyBLE Ready badge, installation labels, maintenance rules, and a staged existing-profile/partner pilot | | 1.3 | Executed C1-S1 repository deliverables in parallel worktrees; recorded device/pilot blockers, stale TestFlight metadata, a bounded recovery contract and larger engineering/matrix estimates; future allocations require replanning | | 1.4 | Verified existing Play/TestFlight installations on connected tablets; recorded artifact evidence and remaining provenance/metadata limits; replaced blanket pilot blockers with Prepared/Awaiting evidence states without waiving acceptance criteria | +| 1.5 | Implemented bounded current-text recovery for source candidate 0.3.0+9; added candidate execution/operator records; preserved remaining pilot, platform and release gates | ## Contents @@ -118,10 +119,10 @@ The inspected repository already contains the following capabilities and develop | Area | Existing baseline | Consequence for implementation | |---|---|---| -| Tablet app | Flutter app; inspected source identifies `0.2.0+8` | Improve and qualify the existing app rather than create a new client | +| Tablet app | Flutter app; historical source baseline `0.2.0+8`; next candidate `0.3.0+9` | Improve and qualify the existing app rather than create a new client | | Distribution | Android 0.2.0 published on Google Play for open testing; iPad external TestFlight beta | Verify installed build identity separately; production-track availability is not claimed | | Normal workflow | BLE discovery, PBLE/1 connection, editor, files, run/stop, soft reboot, console | Measure and repair the complete workflow, not isolated features | -| Mobile state | Session ownership and connection retirement protections already implemented; editor document remains in memory | Preserve lifecycle fixes; close the recorded process-restart persistence gap | +| Mobile state | Session ownership and connection retirement protections already implemented; current-text recovery implemented in the next source candidate | Qualify bounded recovery on recorded platform candidates; retain full-state obligations | | Learning | Blockly, tutorials, reviewed screenshots, official examples repository | Improve discoverability and completion; do not duplicate the Learning Center | | Import | Reviewed, SHA-pinned public GitHub `.py` import | Preserve preview, explicit overwrite approval, and non-execution on import | | Firmware | Public v0.6.1 recorded as owner-confirmed | Preserve the distinction between publication and complete automated qualification | @@ -160,7 +161,7 @@ The [execution record](../developments/2026-q4-reliable-workflow/c1-s1/00_Execut | PYB-001 | Reviewed ledger; [Lenovo Play `0.2.0 (8)`](../developments/2026-q4-reliable-workflow/c1-s1/06_Android_Channel_Verification.md) and [iPad TestFlight `0.2.0 (6)`](../developments/2026-q4-reliable-workflow/c1-s1/07_iPad_Channel_Verification.md) existing installations, channel/Open and launch verified | iPad build-to-source mapping remains unknown; Android code/asset correspondence does not establish complete binary equivalence or store-console provenance. Fresh enrollment/reinstallation was not performed | | Residual PYB-002.1 | Accurate replacement TestFlight app-level description prepared after the public page exposed its older ESP/agent-0.4.0 baseline | Distribution owner must apply it and verify the public result; also reconcile build-6 installed `MinimumOSVersion` 18 with TestFlight notes stating 15 before asserting support. No store mutation performed; Android's earlier correction remains complete | | PYB-003/004 | Operational pilot protocol, safe Tasks A/B/C, consent/retention rules and empty observation/support templates | Human owner, reachable cohort, tested setup and collection decision unconfirmed; no participants or observed outcomes | -| PYB-006.1 | [Recovery contract](../specifications/App/current-draft-recovery.md), ADR-0048, source/peer review, scenario matrix and estimate | Runtime implementation pending; broader A-24/Blocks scope remains carried | +| PYB-006.1 | [Recovery contract](../specifications/App/current-draft-recovery.md), ADR-0048, source/peer review, scenario matrix and estimate | Bounded implementation now tracked in the [C1-S2 record](../developments/2026-q4-reliable-workflow/c1-s2/00_Execution_Record.md); platform/release acceptance and broader A-24/Blocks scope remain carried | | PYB-009.1 | Existing tool/gate inventory, ten platform/profile rows, cost model and initial operator recipe; 110 focused host harness tests pass | S2 scoped procedure, hardware/operator access, actual physical runs and independent reproduction remain pending | The recovery estimate is **38–63 engineering hours**, or **45–72 hours** with the original recipe, representative-platform and follow-up allowances. The [design review](../developments/2026-q4-reliable-workflow/c1-s1/03_Recovery_Design_and_Sizing.md) offers a conditional 60-hour S2–S4 allocation, displacing earlier matrix and activation forecasts. The [validation inventory](../developments/2026-q4-reliable-workflow/c1-s1/04_Validation_Inventory_and_Operator_Recipe.md) estimates 13.5–26 operator hours for the app matrix and 30–59 for fresh full firmware qualification, excluding builds, unavailable access and defect repair. These are working estimates, not actual effort or promises. **SG0 remains blocked.** @@ -178,13 +179,13 @@ Do not spend the first sprint rebuilding a large analytics dashboard. A small pr | Area and inspected source | Already present | Remaining work for this plan | |---|---|---| | [Connection manager](../../app/lib/pble/connection_manager.dart) | Session ownership, stale acquisition protection, serialized connection retirement | Observe failures and extend targeted regressions; preserve the existing `Connection` seam | -| [Editor document](../../app/lib/editor/editor_document.dart), [ADR-0010](../decisions/0010-working-loop-in-memory-document.md), [data placeholder](../../app/lib/data/data.dart) | One document survives navigation in memory; A-24 persistence explicitly deferred | Specify and implement a bounded durable-draft increment; distinguish process eviction/relaunch from a BLE disconnect | +| [Editor document](../../app/lib/editor/editor_document.dart), [ADR-0010](../decisions/0010-working-loop-in-memory-document.md), [recovery storage](../../app/lib/data/drift_draft_repository.dart) | One current-text recovery slot implemented with atomic Drift transactions | Verify candidate durability, failure and platform evidence; full projects/Blocks persistence remains deferred | | [PBLE connection](../../app/lib/pble/pble_connection.dart) | Transfer fails on link loss; a subsequent PUT can consume a firmware-provided resume offset | Measure user retry/reconnect behavior; do not claim automatic continuation from protocol resumability alone | | [First-program tutorial](../../tools/web/src/app/learn/first-program/page.tsx) | Console-only `print("Hello from PyBLE!")` task without wiring | Reuse it for baseline observation and improve only demonstrated friction | | [Device information types](../../app/lib/pble/types.dart) | Negotiated chip, agent version, and capabilities | A support export must distinguish observed data from operator-supplied profile/provenance; current HELLO does not prove an artifact hash | | [Host conformance fixtures](../../tests/firmware_tests/host/conformance/README.md), [HIL guide](../../tests/firmware_tests/hil/README.md) | Shared vectors, compiled-native/portable tests, physical benches and release evidence tools | Add a coverage map and operator recipe before creating another harness or schema | -**First engineering candidate:** PYB-006, the carried A-24 persistence gap. Its bounded specification and sizing are now recorded in C1-S1; select implementation against that estimate while collecting first-use observations; an already documented loss condition does not need rediscovery by participants. Start with durable recovery of the current text document and explicitly scope Blocks recovery. The existing ADR selects Drift and rejects an ad-hoc scratch-file workaround; any different design needs an explicit superseding decision. This does not approve a full project manager, automatic board writes, or new protocol operations.[^app-contract] +**Selected next-release engineering candidate:** PYB-006.2, the bounded part of the carried A-24 persistence gap. Implementation and evidence are tracked in the [C1-S2 record](../developments/2026-q4-reliable-workflow/c1-s2/00_Execution_Record.md). Its bounded specification and sizing are now recorded in C1-S1; select implementation against that estimate while collecting first-use observations; an already documented loss condition does not need rediscovery by participants. Start with durable recovery of the current text document and explicitly scope Blocks recovery. The existing ADR selects Drift and rejects an ad-hoc scratch-file workaround; any different design needs an explicit superseding decision. This does not approve a full project manager, automatic board writes, or new protocol operations.[^app-contract] ## 3. Target users and positioning diff --git a/docs/specifications/App/TDD.md b/docs/specifications/App/TDD.md index ca295208..cd7354af 100644 --- a/docs/specifications/App/TDD.md +++ b/docs/specifications/App/TDD.md @@ -192,7 +192,7 @@ abstract interface class BleSession { **Public API:** an app-owned `EditorSurface` push-in/stream-out widget contract: the caller supplies the current text, change callback, editor text style, localized hint, and Signal colors. The default factory builds the rich `flutter_code_editor` adapter; tests and the R1 escape hatch can select the retained plain-field adapter without changing feature code. A later `EditorTabs` controller is backed by Drift `project_files`. -**Key state:** the volatile current buffer (dirty flag) plus session-wide editor display settings. Font size defaults to 14 sp, moves in one-point steps over the inclusive 10–24 sp range, and applies only to code plus its line-number gutter; the platform `TextScaler` still composes over it. A-24 hydrates that display value through the single Drift settings store. Open buffers, active tab, and find/replace state land with the full persistent editor. Save persists to Drift (D4); Run uploads then runs through `Connection` (the §8.2 loop). +**Key state:** the shared in-memory current buffer (board dirty flag), bounded current-text recovery checkpoints ([§9.3](#93-bounded-current-text-recovery-specified)), and session-wide editor display settings. Font size defaults to 14 sp, moves in one-point steps over the inclusive 10–24 sp range, and applies only to code plus its line-number gutter; the platform `TextScaler` still composes over it. A-24 hydrates that display value through the single Drift settings store. Open buffers, active tab, and find/replace state land with the full persistent editor. Save persists to Drift (D4); Run uploads then runs through `Connection` (the §8.2 loop). **Dependencies:** `Connection` (run/save), `lib/data/` (persist), `lib/localization/`. No `lib/ble/`. @@ -684,7 +684,7 @@ The label is UTF-8 and **bounded** — the client enforces a length cap before s ## 9. Persistence design (`lib/data/`) -The full target Drift schema models exactly the six content tables of [specs.md §8](specs.md) / [PRD §12.1](../prd.md), plus a lightweight settings store. This is target architecture, not a claim that the current `lib/data/` placeholder implements it; the bounded first subset is specified in [§9.3](#93-bounded-current-text-recovery-specified). No managed-fleet/lab/calibration tables (DAT-8, CON-7). — *(satisfies DAT-1..8, FR-PROJ-1..7, IF-3.)* +The full target Drift schema models exactly the six content tables of [specs.md §8](specs.md) / [PRD §12.1](../prd.md), plus a lightweight settings store. This is target architecture; the candidate implements only the bounded current-text subset, not the complete six-table system; the bounded first subset is specified in [§9.3](#93-bounded-current-text-recovery-specified). No managed-fleet/lab/calibration tables (DAT-8, CON-7). — *(satisfies DAT-1..8, FR-PROJ-1..7, IF-3.)* ### 9.1 Schema @@ -705,7 +705,7 @@ Drift schema versions with explicit `MigrationStrategy` step migrations (forward ### 9.3 Bounded current-text recovery (specified) -**C1-S1 / PYB-006.1, 2026-09-25 — implementation pending.** +**C1-S1 / PYB-006.1 contract, 2026-09-25; C1-S2 / PYB-006.2 candidate source implementation, 2026-10-31. Integrated and physical acceptance pending.** [ADR-0048](../../decisions/0048-bounded-current-text-draft-recovery.md) and [current-draft-recovery.md](current-draft-recovery.md) own the bounded successor to ADR-0010. Start with the necessary `projects`/`project_files` subset and the @@ -730,8 +730,11 @@ include real file-backed restart/failure tests and actual iPad/Android evidence. exceeds the original one-Sprint recovery allowance and proposes reforecasting; safety cases remain required. Blocks graph recovery, full projects/history, console state, saved boards, durable settings and import provenance remain -carried to their existing A-24/related stories. No runtime behavior or new -protocol operation is implemented by this specification change. +carried to their existing A-24/related stories. The candidate now implements +the bounded repository/coordinator/UI and guarded action paths described here; +[the execution record](../../developments/2026-q4-reliable-workflow/c1-s2/00_Execution_Record.md) +tracks final verification and admission. This app-only change adds no protocol +operation and does not qualify or publish firmware. ## 10. GitHub import design (`lib/github_import/`) @@ -1125,7 +1128,7 @@ those semantics early. `flutter_code_editor` 0.3.5 behind the app-owned `EditorSurface` interface ([§4.3](#43-libeditor--code-editor), [ADR-0012](../../decisions/0012-flutter-code-editor-behind-editorsurface.md)), with the existing plain `TextField` retained as the immediate fallback and a WebView-hosted editor (Monaco/CodeMirror) remaining the documented later fallback ([PRD §16.1](../prd.md), [OI-1](specs.md)). Python highlighting, line numbers, auto-indent (FR-EDIT-1); tabs + Save/Run (FR-EDIT-2); find, optional find/replace (FR-EDIT-3); external-keyboard shortcuts for Save/Run/Stop/comment/find (FR-EDIT-4); large touch targets, no phone breakage, and editor-only font adjustment that preserves system scaling (FR-EDIT-5). Save persists to Drift; Run uploads then runs through `Connection` (FR-EDIT-7). -**Working-loop increment — FROZEN (A-20 subset · `[docs]` 2026-07-02, amended 2026-08-22 by [ADR-0012](../../decisions/0012-flutter-code-editor-behind-editorsurface.md), [ADR-0010](../../decisions/0010-working-loop-in-memory-document.md)).** The shippable editor keeps the **single volatile in-memory current document** ([§11.6](#116-working-loop-provider-contract-frozen)) while moving its presentation behind `EditorSurface`. The default rich adapter supplies Python highlighting and an always-visible, one-based, vertically synchronized line-number gutter; only line numbers are enabled in the gutter (package errors and folding handles stay off), package find/autocomplete UI stays off, and no network analyzer is reachable from `lib/editor`. The plain `TextField` remains executable as the R1 fallback. Both paths preserve the smart-punctuation invariant, and adapter-owned Tab handling must indent a multi-line selection without replacing it. +**Working-loop increment — FROZEN (A-20 subset · `[docs]` 2026-07-02, amended 2026-08-22 by [ADR-0012](../../decisions/0012-flutter-code-editor-behind-editorsurface.md), [ADR-0010](../../decisions/0010-working-loop-in-memory-document.md)).** The editor keeps the **single shared in-memory current document**; the 31 October candidate adds the bounded durable checkpoint from [§9.3](#93-bounded-current-text-recovery-specified) ([§11.6](#116-working-loop-provider-contract-frozen)) while moving its presentation behind `EditorSurface`. The default rich adapter supplies Python highlighting and an always-visible, one-based, vertically synchronized line-number gutter; only line numbers are enabled in the gutter (package errors and folding handles stay off), package find/autocomplete UI stays off, and no network analyzer is reachable from `lib/editor`. The plain `TextField` remains executable as the R1 fallback. Both paths preserve the smart-punctuation invariant, and adapter-owned Tab handling must indent a multi-line selection without replacing it. The same bounded increment adds session-wide editor zoom: default 14 sp, inclusive 10–24 sp, one-point steps. Code and gutter share the selected family/size/height and update immediately; changing it preserves document bytes, selection, undo history, dirty state, and board path. Controls remain accessible at phone width and compose with the full platform text scaler. Durable display preference, syntax-editor tabs, app-owned localized find/replace, the remaining shortcuts, Save-to-project (FR-EDIT-7), and buffers-survive-restart (NFR-REL-3/FR-PROJ-6) remain carried to A-24 or later. The document and display providers are the hooks A-24 rebinds to the one Drift `SettingsStore`; no parallel preferences backend is admitted. @@ -1856,14 +1859,17 @@ board display. Two UX surfaces: **FROZEN (working-loop increment · `[docs]` 2026-07-02, [ADR-0010](../../decisions/0010-working-loop-in-memory-document.md)).** This is the shared Riverpod contract the edit→run→watch→manage loop implements to, across A-20 (editor), A-21 (console), A-16 (run-control toolbar) and A-30 (files). Ownership is **disjoint** so the feature engineers work in parallel. Every provider binds only to the neutral `Connection` seam + neutral types ([§5](#5-the-connection-api-design)) — **no UI package imports `lib/ble`** (CON-8, FR-UI-7); app-layer packages MAY cross-import each other's **public** providers (`files → editor`, shell → run-control). API sketches are illustrative; the frozen interface is whatever the `[red]` tests pin ([§15](#15-test-design)). -**Specified successor (2026-09-25).** The volatile behavior below remains the -current implemented baseline. [§9.3](#93-bounded-current-text-recovery-specified) -and [the current-draft contract](current-draft-recovery.md) define its bounded -persistence replacement, including stronger epoch/session checks for affected -asynchronous actions. Do not read either the older illustrative API or the -successor design as evidence that persistence has shipped. - -**(a) Current document — `lib/editor` (owner: app-editor-console-engineer).** A single volatile in-memory document; survives navigation, not restart (persistence deferred to A-24, [§11.1](#111-editor)): +**Bounded successor implemented in candidate source (2026-10-31).** The +volatile behavior below records the historical working-loop baseline. +[§9.3](#93-bounded-current-text-recovery-specified) and +[the current-draft contract](current-draft-recovery.md) define its current-text +replacement, including explicit offline recovery and epoch/session checks. +The shared document provider remains in memory while the recovery coordinator +checkpoints accepted text through Drift. The +[C1-S2 record](../../developments/2026-q4-reliable-workflow/c1-s2/00_Execution_Record.md) +tracks integrated/physical acceptance separately; source is not store delivery. + +**(a) Historical working-loop current document — `lib/editor` (owner: app-editor-console-engineer).** The original single volatile in-memory document survived navigation, not restart (persistence deferred to A-24, [§11.1](#111-editor)): ```dart @immutable diff --git a/docs/specifications/App/current-draft-recovery.md b/docs/specifications/App/current-draft-recovery.md index bb5a4757..20b34eb4 100644 --- a/docs/specifications/App/current-draft-recovery.md +++ b/docs/specifications/App/current-draft-recovery.md @@ -6,14 +6,18 @@ | Field | Value | |---|---| | Design baseline | 25 September 2026; C1-S1-T10/T11, PYB-006.1 | -| Status | Specified for implementation under the requested C1-S1 work; implementation and acceptance evidence pending | +| Status | Bounded source implementation for candidate `0.3.0+9`, 31 October 2026; final integrated checks and required physical acceptance remain pending | | Decision | [ADR-0048](../../decisions/0048-bounded-current-text-draft-recovery.md) | | Parent obligations | A-24; FR-EDIT-7, FR-PROJ-2/6, NFR-REL-3; their broader acceptance remains carried | | Delivery and sizing | [C1-S1 design review](../../developments/2026-q4-reliable-workflow/c1-s1/03_Recovery_Design_and_Sizing.md) | -This is the normative contract for the proposed PYB-006.2 current-text slice. -It does not describe shipped behavior. The current implementation remains the -volatile provider documented in [ADR-0010](../../decisions/0010-working-loop-in-memory-document.md). +This is the normative contract for PYB-006.2. Candidate source now implements +its bounded current-text storage, coordinator, recovery UI and guarded board +actions. [The implementation record](../../developments/2026-q4-reliable-workflow/c1-s2/00_Execution_Record.md) +tracks automated and physical acceptance separately. Existing Play/TestFlight +installations remain the previously recorded versions; candidate source is not +store availability or release qualification. [ADR-0048](../../decisions/0048-bounded-current-text-draft-recovery.md) +supersedes the volatile-only current-text limitation in ADR-0010 for this slice. No PBLE/1 change or board-identity allowlist is introduced. ## 1. Scope and observable promise @@ -247,7 +251,10 @@ are not included. Their absence must not cause hidden deletion or a startup loop All rows below are required for the behavior they cover. Unit fakes cannot prove process durability, and a passing physical happy path cannot replace failure injection. Evidence links/statuses belong in the implementation record; every -row is **Not run for this design-only increment**. +row has its actual implementation status in the +[C1-S2 evidence matrix](../../developments/2026-q4-reliable-workflow/c1-s2/00_Execution_Record.md#required-scenario-evidence). +Component tests do not substitute for the final integrated run or required +physical observations. | Scenario ID | Exercise and oracle | Required evidence | |---|---|---| diff --git a/docs/specifications/App/specs.md b/docs/specifications/App/specs.md index b81956a8..9af35bf2 100644 --- a/docs/specifications/App/specs.md +++ b/docs/specifications/App/specs.md @@ -167,8 +167,9 @@ The client implements the PBLE/1 wire contract; it references [protocol.md](../p **Working-loop increment — FROZEN (A-20 subset · `[docs]` 2026-07-02, amended 2026-08-22 by [ADR-0012](../../decisions/0012-flutter-code-editor-behind-editorsurface.md), [ADR-0010](../../decisions/0010-working-loop-in-memory-document.md), [TDD §11.1](TDD.md#111-editor)/[§11.6](TDD.md#116-working-loop-provider-contract-frozen); run path superseded 2026-07-27 by [ADR-0013](../../decisions/0013-clean-room-blockly-one-way-file-backed.md)).** In scope: the ADR-0012 `EditorSurface` seam with `flutter_code_editor` 0.3.5 as the default rich adapter and the existing plain `TextField` as a live fallback; Python highlighting, always-visible one-based line numbers, safe auto-indent/Tab handling, and an editor-only 10–24 sp font adjustment in one-point steps (default 14 sp). Code and gutter resize together, platform text scaling still applies, and resizing MUST NOT modify the document, selection, dirty state, or board path. The size is app-session state, independent of file and board, until A-24's single Drift `SettingsStore` makes it durable; a second preferences store MUST NOT be introduced. The single volatile in-memory current document (name/content/dirty/boardPath), **New**, and board-backed Save/Run behavior remain as frozen by ADR-0010/0013. Save and Run capture one normalized document snapshot; transfer completion marks it saved/bound only when the live document still equals that snapshot, so mid-transfer edits or a replacement document remain dirty. The editor binds to `Connection` only (FR-EDIT-6, CON-8), and editor source MUST NOT reach a network analyzer. **Still deferred to A-24 or later:** multi-file tabs, app-owned localized find/replace, the remaining external-keyboard shortcuts, durable display settings, Save-to-project, and buffers-survive-restart. The document provider's `openFromBoard`/`markSaved` and the session display provider are the A-24 persistence hooks (RECORDED deviations, ADR-0010/0012). -**Current-text recovery successor — SPECIFIED, implementation pending -(`[docs]` 2026-09-25, C1-S1/PYB-006.1, +**Current-text recovery successor — IMPLEMENTED IN CANDIDATE SOURCE, +acceptance pending (31 October 2026, C1-S2/PYB-006.2; contract +`[docs]` 2026-09-25, C1-S1/PYB-006.1, [ADR-0048](../../decisions/0048-bounded-current-text-draft-recovery.md)).** The [bounded recovery contract](current-draft-recovery.md) defines DR-01–07: one atomic Drift current-text checkpoint, explicit offline Restore/Discard, @@ -177,9 +178,12 @@ handling and current-session target consent. It strengthens affected asynchronou Editor/Files actions from document-value equality to epoch and session guards. A local recovery panel extends the connection-gated shell without claiming a complete offline IDE. Blocks JSON and the remaining A-24 project/settings/history -requirements stay carried. The working-loop text above remains the implemented -baseline until this successor's automated and applicable physical evidence passes; -writing the contract does not satisfy FR-EDIT-7, FR-PROJ-2/6 or NFR-REL-3 in full. +requirements stay carried. The candidate uses durable local checkpoints and +epoch/session guards; the older frozen working-loop paragraph above records the +historical volatile release, not the candidate's persistence behavior. The +[C1-S2 implementation record](../../developments/2026-q4-reliable-workflow/c1-s2/00_Execution_Record.md) +owns final integrated and physical acceptance. No store publication is implied, +and FR-EDIT-7, FR-PROJ-2/6 and NFR-REL-3 are not satisfied in full. ### 4.7 Console Panel (`lib/console/`) — FR-CONSOLE diff --git a/tests/firmware_tests/hil/README.md b/tests/firmware_tests/hil/README.md index 7f4c12b9..59f0ff9e 100644 --- a/tests/firmware_tests/hil/README.md +++ b/tests/firmware_tests/hil/README.md @@ -152,6 +152,15 @@ finishes post-readback in loader mode; returning to normal runtime or another clean install is a separate, explicitly sequenced qualification operation. Keep both receipts and all their siblings beside the later hardening result. +Before handing a board back for ordinary use, account for intentional +invalid-media fixtures even when acquisition failed. Preserve the failed +receipts and fresh current-media backup, then explicitly restore the verified +pre-test workspace or record that the board remains in recovery. Verify normal +startup and advertising after that separate operation. A host report or USB +connection does not establish runtime readiness. See the [classic ESP32 +recovery record](../../../docs/developments/2026-q4-reliable-workflow/c1-s2/03_Classic_ESP32_Workspace_Recovery.md) +for the diagnosed leftover-fixture case; do not silently format nonblank media. + The live runner receives the protected candidate directory, exact profile, private BLE address, both receipt paths, and new raw-log/output paths. It emits no passing result unless all seven live scenarios and both pre-service diff --git a/tests/publication/test_public_claims.py b/tests/publication/test_public_claims.py index a7519240..806d1883 100644 --- a/tests/publication/test_public_claims.py +++ b/tests/publication/test_public_claims.py @@ -188,7 +188,7 @@ def test_current_public_surfaces_agree_on_v061_publication(self) -> None: def test_readme_distinguishes_app_source_from_store_availability(self) -> None: firmware = markdown_section(self.readme, "What works") normalized = " ".join(firmware.split()) - self.assertIn("0.2.0+8", firmware) + self.assertIn("0.3.0+9", firmware) self.assertIn("Google Play open testing", normalized) self.assertIn("published Android version is `0.2.0`", normalized) self.assertIn("exact source", normalized)