From d52e34191f989e16dd51477a85e0d8b123e538cc Mon Sep 17 00:00:00 2001 From: Viwat Vchirawongkwin Date: Fri, 7 Aug 2026 16:19:46 +0700 Subject: [PATCH 1/4] [red] Guard Android Blockly lifecycle Signed-off-by: Viwat Vchirawongkwin --- .../blockly_webview_suite.dart | 134 +++++++- app/test/unit/android_build_flavor_test.dart | 99 ++++++ app/test/unit/blockly_asset_policy_test.dart | 300 ++++++++++++++++++ app/test/unit/blocks_document_test.dart | 54 ++++ app/test/unit/blocks_examples_test.dart | 14 + app/test/unit/permission_manifest_test.dart | 77 ++++- .../widget/blockly_examples_view_test.dart | 241 +++++++++++++- app/test/widget/blocks_view_test.dart | 34 ++ docs/specifications/App/TDD.md | 79 ++++- docs/specifications/App/specs.md | 65 +++- tests/publication/test_ci_contract.py | 19 +- tools/test_blockly_assets.mjs | 83 ++++- 12 files changed, 1154 insertions(+), 45 deletions(-) create mode 100644 app/test/unit/android_build_flavor_test.dart diff --git a/app/integration_test/blockly_webview_suite.dart b/app/integration_test/blockly_webview_suite.dart index e7d53b6..ab1efd7 100644 --- a/app/integration_test/blockly_webview_suite.dart +++ b/app/integration_test/blockly_webview_suite.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:webview_flutter/webview_flutter.dart'; import 'package:pyble/app/providers.dart'; import 'package:pyble/blocks/blocks.dart'; @@ -281,6 +282,12 @@ Future _pumpUntil( ); } +Future _tapVisible(WidgetTester tester, Finder finder) async { + await tester.ensureVisible(finder); + await tester.pumpAndSettle(); + await tester.tap(finder); +} + Widget _testApp(ProviderContainer container, {required bool showBlocks}) { return UncontrolledProviderScope( container: container, @@ -313,6 +320,11 @@ void registerBlocklyWebViewIntegrationTests() { controller.receiveBridgeMessage(_seedSnapshot()); + // Android creates the real platform view lazily after the native Flutter + // surface is attached. Reproduce that production lifecycle instead of + // making a WebView the integration runner's first rendered frame. + await tester.pumpWidget(_testApp(container, showBlocks: false)); + await tester.pumpAndSettle(); await tester.pumpWidget(_testApp(container, showBlocks: true)); await _pumpUntil( @@ -364,6 +376,82 @@ void registerBlocklyWebViewIntegrationTests() { ); } + // Reload the same platform view, not merely its Flutter wrapper. The + // retained graph must restore through a fresh page generation, and an + // exact duplicate readiness event must remain host-control traffic. + final WebViewWidget webView = tester.widget( + find.byType(WebViewWidget), + ); + final WebViewController liveWebViewController = + WebViewController.fromPlatform(webView.platform.params.controller); + final int beforeReloadRevision = container + .read(blocksDocumentProvider) + .program! + .revision; + await liveWebViewController.reload(); + await _pumpUntil( + tester, + () { + final BlocksDocument state = container.read(blocksDocumentProvider); + return state.status == BlocksStatus.ready && + state.error == null && + state.program != null && + state.program!.revision > beforeReloadRevision && + state.program!.source == _expectedGpioSource && + controller.hasActiveReadyHost; + }, + reason: + 'the same WebView did not restore through a fresh page generation', + ); + final int afterReloadRevision = container + .read(blocksDocumentProvider) + .program! + .revision; + final String supersededSnapshot = jsonEncode({ + 'version': 1, + 'type': 'snapshot', + 'hostEpoch': 1, + 'revision': afterReloadRevision + 100, + 'source': 'print("superseded page")\n', + 'workspace': { + 'blocks': { + 'languageVersion': 0, + 'blocks': [], + }, + }, + }); + final String supersededError = jsonEncode({ + 'version': 1, + 'type': 'error', + 'hostEpoch': 1, + 'message': 'superseded page failed', + }); + await liveWebViewController.runJavaScript( + 'window.PybleBlocks.postMessage(${jsonEncode(supersededSnapshot)});' + 'window.PybleBlocks.postMessage(${jsonEncode(supersededError)});', + ); + await tester.pump(const Duration(milliseconds: 300)); + final BlocksDocument afterSupersededTraffic = container.read( + blocksDocumentProvider, + ); + expect(afterSupersededTraffic.status, BlocksStatus.ready); + expect(afterSupersededTraffic.error, isNull); + expect(afterSupersededTraffic.workspaceError, isNull); + expect(afterSupersededTraffic.program?.revision, afterReloadRevision); + expect(afterSupersededTraffic.program?.source, _expectedGpioSource); + + await liveWebViewController.runJavaScript( + "window.PybleBlocks.postMessage(" + "'{\"version\":1,\"type\":\"hostReady\"}');", + ); + await tester.pump(const Duration(milliseconds: 300)); + final BlocksDocument afterDuplicateReady = container.read( + blocksDocumentProvider, + ); + expect(afterDuplicateReady.status, BlocksStatus.ready); + expect(afterDuplicateReady.error, isNull); + expect(afterDuplicateReady.program?.revision, afterReloadRevision); + await tester.tap(find.byKey(kBlocksPreviewButtonKey)); await tester.pumpAndSettle(); final Finder previewDialog = find.byType(AlertDialog); @@ -549,6 +637,12 @@ void registerBlocklyWebViewIntegrationTests() { () => find.byKey(kBlocksExamplesCatalogKey).evaluate().isNotEmpty, reason: 'the bundled beginner example chooser did not open', ); + expect( + find.textContaining("print('Hello, PyBLE!')"), + findsNothing, + reason: 'opening the chooser must not generate through the WebView', + ); + await _tapVisible(tester, find.byKey(kBlocksExamplePreviewButtonKey)); await _pumpUntil( tester, () => @@ -556,6 +650,13 @@ void registerBlocklyWebViewIntegrationTests() { reason: 'the real scratch Blockly workspace did not generate the Hello preview', ); + await tester.tap( + find.descendant( + of: find.byType(AlertDialog), + matching: find.text('Close'), + ), + ); + await tester.pumpAndSettle(); final BlocksDocument afterHelloPreview = container.read( blocksDocumentProvider, ); @@ -576,6 +677,13 @@ void registerBlocklyWebViewIntegrationTests() { find.widgetWithText(TextField, 'NeoPixel data GPIO'), '48', ); + await tester.pumpAndSettle(); + expect( + find.textContaining('from neopixel import NeoPixel'), + findsNothing, + reason: 'GPIO editing must not generate through the WebView', + ); + await _tapVisible(tester, find.byKey(kBlocksExamplePreviewButtonKey)); await _pumpUntil( tester, () => @@ -588,6 +696,13 @@ void registerBlocklyWebViewIntegrationTests() { 'the real scratch Blockly workspace did not generate the standard ' 'NeoPixel API from the user-selected GPIO', ); + await tester.tap( + find.descendant( + of: find.byType(AlertDialog), + matching: find.text('Close'), + ), + ); + await tester.pumpAndSettle(); final BlocksDocument afterNeoPixelPreview = container.read( blocksDocumentProvider, ); @@ -633,16 +748,31 @@ pixels.write() ); await tester.pumpAndSettle(); await tester.enterText(find.widgetWithText(TextField, 'LED GPIO'), '17'); + await tester.pumpAndSettle(); + expect( + find.textContaining('Pin(17, Pin.OUT'), + findsNothing, + reason: 'GPIO editing must remain local until an explicit action', + ); + await _tapVisible(tester, find.byKey(kBlocksExamplePreviewButtonKey)); await _pumpUntil( tester, () => find.textContaining('Pin(17, Pin.OUT').evaluate().isNotEmpty, reason: 'the real scratch Blockly workspace did not materialize the selected LED GPIO', ); + await tester.tap( + find.descendant( + of: find.byType(AlertDialog), + matching: find.text('Close'), + ), + ); + await tester.pumpAndSettle(); // A real Android IME can still cover the example action after enterText, // even when ensureVisible has scrolled its RenderBox into the viewport. - // Close it before asserting hit-testability so the integration gate tests - // the action rather than emulator keyboard-animation timing. + // Close it after the explicit preview before asserting hit-testability, + // so the integration gate tests the action rather than emulator + // keyboard-animation timing. FocusManager.instance.primaryFocus?.unfocus(); await SystemChannels.textInput.invokeMethod('TextInput.hide'); await tester.pumpAndSettle(); diff --git a/app/test/unit/android_build_flavor_test.dart b/app/test/unit/android_build_flavor_test.dart new file mode 100644 index 0000000..4c1938e --- /dev/null +++ b/app/test/unit/android_build_flavor_test.dart @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MIT +// Part of PyBLE (https://pyble.dev) — see /LICENSE. + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +import '../support/repo_paths.dart'; + +void main() { + test('Android integration tests use an isolated application ID', () { + final String appRoot = appPackageRoot().path; + final String gradle = File( + '$appRoot/android/app/build.gradle.kts', + ).readAsStringSync(); + final File manifest = File( + '$appRoot/android/app/src/integration/AndroidManifest.xml', + ); + final String ci = File( + '$appRoot/../.github/workflows/ci.yml', + ).readAsStringSync(); + final String contributing = File( + '$appRoot/../CONTRIBUTING.md', + ).readAsStringSync(); + + expect(gradle, contains('flavorDimensions += "purpose"')); + expect(gradle, contains('create("production")')); + expect(gradle, contains('create("integration")')); + expect(gradle, contains('applicationIdSuffix = ".integrationtest"')); + expect(manifest.existsSync(), isTrue); + expect(manifest.readAsStringSync(), contains('PyBLE Integration Test')); + + final RegExpMatch? integrationPrebuild = RegExp( + r'- name: Prebuild Android integration application.*?' + r'(?=\n - name:)', + dotAll: true, + ).firstMatch(ci); + expect(integrationPrebuild, isNotNull); + expect(integrationPrebuild!.group(0), contains('--flavor integration')); + expect(integrationPrebuild.group(0), contains('app-integration-debug.apk')); + + final RegExpMatch? androidIntegrationStep = RegExp( + r'- name: Real About \+ Blockly integration \(Android API 34\).*?' + r'(?=\n - name:)', + dotAll: true, + ).firstMatch(ci); + expect(androidIntegrationStep, isNotNull); + expect( + androidIntegrationStep!.group(0), + contains('--flavor integration'), + reason: + 'device tests must never install or uninstall the production app ID', + ); + expect( + androidIntegrationStep.group(0), + contains('app-integration-debug.apk'), + ); + + final RegExpMatch? productionReleaseStep = RegExp( + r'- name: Production release APK build.*?' + r'(?=\n - name:)', + dotAll: true, + ).firstMatch(ci); + expect(productionReleaseStep, isNotNull); + expect( + productionReleaseStep!.group(0), + contains( + 'flutter build apk --release --flavor production ' + '--target lib/main.dart', + ), + reason: + 'CI must compile the normal artifact after host tests regenerate ' + 'the dev-plugin registrant', + ); + expect( + productionReleaseStep.group(0), + contains("package: name='dev.pyble.pyble'"), + ); + + expect( + contributing, + contains('flutter run --flavor production --target lib/main.dart'), + ); + expect(contributing, isNot(contains('flutter run -d '))); + expect( + contributing, + contains( + 'flutter build apk --release --flavor production ' + '--target lib/main.dart', + ), + ); + expect(contributing, contains('--flavor integration')); + expect( + gradle, + contains('flutter run --release --flavor production'), + reason: 'the signing comment must not advertise an ambiguous command', + ); + }); +} diff --git a/app/test/unit/blockly_asset_policy_test.dart b/app/test/unit/blockly_asset_policy_test.dart index 4ef68ad..71f37c6 100644 --- a/app/test/unit/blockly_asset_policy_test.dart +++ b/app/test/unit/blockly_asset_policy_test.dart @@ -3,6 +3,7 @@ // // A-31 — the authored Blockly host must load only pinned, app-bundled assets. +import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -129,6 +130,293 @@ void main() { contains('window.addEventListener("resize", resizeWorkspace)'), ); }); + + test('announces host readiness only after the authored API exists', () { + final String script = File( + '${index.parent.path}/pyble_blockly.js', + ).readAsStringSync(); + final int apiIndex = script.indexOf( + 'window.pybleBlocks = Object.freeze({', + ); + final Iterable readyMessages = RegExp( + r'type:\s*"hostReady"', + ).allMatches(script); + + expect(apiIndex, greaterThanOrEqualTo(0)); + expect(readyMessages, hasLength(1)); + expect( + readyMessages.single.start, + greaterThan(apiIndex), + reason: 'Dart must never configure a partially loaded authored host', + ); + }); + }); + + group('A-31 Blockly host readiness gate', () { + const String ready = '{"version":1,"type":"hostReady"}'; + const String firstSnapshot = + '{"version":1,"type":"snapshot","hostEpoch":101}'; + const String secondSnapshot = + '{"version":1,"type":"snapshot","hostEpoch":202}'; + + test('routes one readiness per page and swallows exact duplicates', () { + final BlocklyHostStartupGate gate = BlocklyHostStartupGate(); + + expect( + gate.classify(ready), + BlocklyHostChannelDisposition.ignore, + reason: 'readiness before an allowed main-frame start is inert', + ); + gate.beginPage(hostEpoch: 101); + expect(gate.classify(ready), BlocklyHostChannelDisposition.initialise); + gate.finishInitialisation(gate.generation, succeeded: true); + expect(gate.classify(ready), BlocklyHostChannelDisposition.ignore); + expect( + gate.classify(firstSnapshot), + BlocklyHostChannelDisposition.forward, + ); + + gate.beginPage(hostEpoch: 202); + expect( + gate.classify(ready), + BlocklyHostChannelDisposition.initialise, + reason: 'an allowed reload must configure its new document generation', + ); + gate.finishInitialisation(gate.generation, succeeded: true); + expect(gate.classify(ready), BlocklyHostChannelDisposition.ignore); + expect( + gate.classify(firstSnapshot), + BlocklyHostChannelDisposition.ignore, + reason: 'traffic from the superseded document epoch must stay inert', + ); + expect( + gate.classify(secondSnapshot), + BlocklyHostChannelDisposition.forward, + ); + }); + + test('re-arms failed readiness and retries one duplicate in flight', () { + final BlocklyHostStartupGate gate = BlocklyHostStartupGate() + ..beginPage(hostEpoch: 101); + final int generation = gate.generation; + + expect(gate.classify(ready), BlocklyHostChannelDisposition.initialise); + expect( + gate.classify(ready), + BlocklyHostChannelDisposition.ignore, + reason: 'a duplicate must not configure the same page concurrently', + ); + expect( + gate.finishInitialisation(generation, succeeded: false), + isTrue, + reason: 'the queued readiness must trigger one serialized retry', + ); + expect(gate.finishInitialisation(generation, succeeded: false), isFalse); + expect( + gate.classify(ready), + BlocklyHostChannelDisposition.initialise, + reason: 'a failed retry must leave the generation re-armed', + ); + expect(gate.finishInitialisation(generation, succeeded: true), isFalse); + expect(gate.classify(ready), BlocklyHostChannelDisposition.ignore); + }); + + test( + 'dispatch never forwards duplicate readiness to the document bridge', + () { + final BlocklyHostStartupGate gate = BlocklyHostStartupGate(); + int initialisations = 0; + int rejections = 0; + final List forwarded = []; + void dispatch(String message) => dispatchBlocklyHostChannelMessage( + gate: gate, + message: message, + initialise: () => initialisations += 1, + forward: forwarded.add, + reject: () => rejections += 1, + ); + + dispatch(ready); + expect(initialisations, 0); + expect(forwarded, isEmpty); + + gate.beginPage(hostEpoch: 101); + dispatch(ready); + dispatch(ready); + gate.finishInitialisation(gate.generation, succeeded: true); + dispatch(firstSnapshot); + expect(initialisations, 1); + expect(forwarded, [firstSnapshot]); + expect(rejections, 0); + + gate.beginPage(hostEpoch: 202); + dispatch(ready); + gate.finishInitialisation(gate.generation, succeeded: true); + dispatch(firstSnapshot); + dispatch(secondSnapshot); + expect(initialisations, 2); + expect(forwarded, [firstSnapshot, secondSnapshot]); + expect(rejections, 0); + }, + ); + + test('rejects malformed epochs and ignores superseded epochs', () { + final BlocklyHostStartupGate gate = BlocklyHostStartupGate() + ..beginPage(hostEpoch: 202); + for (final String message in [ + '{"version":1,"type":"snapshot"}', + '{"version":1,"type":"snapshot","hostEpoch":"202"}', + '{"version":2,"type":"hostReady"}', + '{"version":1,"type":"hostReady","unexpected":true}', + 'not JSON', + ]) { + expect( + gate.classify(message), + BlocklyHostChannelDisposition.reject, + reason: message, + ); + } + expect( + gate.classify(firstSnapshot), + BlocklyHostChannelDisposition.ignore, + ); + expect( + gate.classify(secondSnapshot), + BlocklyHostChannelDisposition.forward, + ); + expect(gate.classify(ready), BlocklyHostChannelDisposition.initialise); + }); + + test( + 'serializes JavaScript, channel, and navigation controller setup', + () async { + final Completer javascript = Completer(); + final Completer channel = Completer(); + final Completer navigation = Completer(); + final List events = []; + + final Future setup = configureBlocklyControllerSequentially( + isCancelled: () => false, + enableJavaScript: () async { + events.add('javascript'); + await javascript.future; + }, + installJavaScriptChannel: () async { + events.add('channel'); + await channel.future; + }, + installNavigationDelegate: () async { + events.add('navigation'); + await navigation.future; + }, + ); + + await Future.delayed(Duration.zero); + expect(events, ['javascript']); + javascript.complete(); + await Future.delayed(Duration.zero); + expect(events, ['javascript', 'channel']); + channel.complete(); + await Future.delayed(Duration.zero); + expect(events, ['javascript', 'channel', 'navigation']); + navigation.complete(); + await setup; + }, + ); + + test('controller setup stops between phases after cancellation', () async { + final Completer javascript = Completer(); + final List events = []; + bool cancelled = false; + final Future setup = configureBlocklyControllerSequentially( + isCancelled: () => cancelled, + enableJavaScript: () async { + events.add('javascript'); + await javascript.future; + }, + installJavaScriptChannel: () async => events.add('channel'), + installNavigationDelegate: () async => events.add('navigation'), + ); + + await Future.delayed(Duration.zero); + cancelled = true; + javascript.complete(); + await setup; + expect(events, ['javascript']); + }); + + test( + 'withholds the page load until asynchronous controller setup ends', + () async { + final Completer setup = Completer(); + final Completer loadAllowed = Completer(); + bool loaded = false; + final Future startup = loadBlocklyAssetAfterControllerSetup( + setup: setup.future, + waitUntilLoadAllowed: () => loadAllowed.future, + isCancelled: () => false, + load: () async { + loaded = true; + }, + ); + + await Future.delayed(Duration.zero); + expect(loaded, isFalse); + setup.complete(); + await Future.delayed(Duration.zero); + expect(loaded, isFalse); + loadAllowed.complete(); + await startup; + expect(loaded, isTrue); + }, + ); + + test('page load stays cancelled after setup or frame readiness', () async { + for (final bool cancelDuringSetup in [true, false]) { + final Completer setup = Completer(); + final Completer loadAllowed = Completer(); + bool cancelled = false; + bool loaded = false; + final Future startup = loadBlocklyAssetAfterControllerSetup( + setup: setup.future, + waitUntilLoadAllowed: () => loadAllowed.future, + isCancelled: () => cancelled, + load: () async { + loaded = true; + }, + ); + + if (cancelDuringSetup) cancelled = true; + setup.complete(); + await Future.delayed(Duration.zero); + if (!cancelDuringSetup) cancelled = true; + loadAllowed.complete(); + await startup; + expect(loaded, isFalse, reason: 'cancelDuringSetup=$cancelDuringSetup'); + } + }); + + test( + 'guard owns startup errors and suppresses them after disposal', + () async { + for (final bool cancelled in [false, true]) { + final Completer mayReport = Completer(); + final List errors = []; + final Future startup = runBlocklyStartupGuarded( + startup: () async => throw StateError('setup failed'), + waitUntilFailureCanBeReported: () => mayReport.future, + isCancelled: () => cancelled, + reportFailure: errors.add, + ); + + await Future.delayed(Duration.zero); + expect(errors, isEmpty); + mayReport.complete(); + await startup; + expect(errors, cancelled ? isEmpty : hasLength(1)); + } + }, + ); }); group('A-31 Blockly navigation policy', () { @@ -154,6 +442,18 @@ void main() { isTrue, ); expect(isAllowedBlocklyNavigation('about:blank'), isTrue); + expect( + isBlocklyAssetDocumentNavigation('about:blank'), + isFalse, + reason: 'the inert bootstrap page must not re-arm host readiness', + ); + expect( + isBlocklyAssetDocumentNavigation( + 'https://appassets.androidplatform.net/assets/' + 'assets/blockly/index.html', + ), + isTrue, + ); }); test('rejects unrelated files, traversal, network, and custom schemes', () { diff --git a/app/test/unit/blocks_document_test.dart b/app/test/unit/blocks_document_test.dart index 4cd4ba5..4df0940 100644 --- a/app/test/unit/blocks_document_test.dart +++ b/app/test/unit/blocks_document_test.dart @@ -446,6 +446,60 @@ void main() { }, ); + test('same-host reload clears readiness but retains restore state', () { + final ProviderContainer container = bind(); + final BlocksDocumentController controller = container.read( + blocksDocumentProvider.notifier, + ); + final int hostId = controller.beginHost( + requestSnapshot: (int requestId) async {}, + ); + controller.markHostLoading(hostId); + controller.receiveBridgeMessage( + snapshot(revision: 8, source: 'print("retained")\n'), + hostId: hostId, + ); + expect(controller.hasActiveReadyHost, isTrue); + + controller.markHostLoading(hostId); + + final BlocksDocument loading = container.read(blocksDocumentProvider); + expect(loading.status, BlocksStatus.loading); + expect(loading.retainedWorkspaceRevision, 8); + expect(loading.program?.source, 'print("retained")\n'); + expect( + controller.hasActiveReadyHost, + isFalse, + reason: 'preview actions must wait for the reloaded renderer snapshot', + ); + + expect( + controller.receiveBridgeMessage( + snapshot(revision: 8, source: 'print("stale")\n'), + hostId: hostId, + ), + BlocksBridgeResult.staleSnapshot, + ); + expect(controller.hasActiveReadyHost, isFalse); + expect( + container.read(blocksDocumentProvider).status, + BlocksStatus.loading, + ); + + expect( + controller.receiveBridgeMessage( + snapshot(revision: 9, source: 'print("restored")\n'), + hostId: hostId, + ), + BlocksBridgeResult.snapshotAccepted, + ); + expect(controller.hasActiveReadyHost, isTrue); + expect( + container.read(blocksDocumentProvider).program?.source, + 'print("restored")\n', + ); + }); + test( 'a dismissed transient preview host restores the exact prior document', () { diff --git a/app/test/unit/blocks_examples_test.dart b/app/test/unit/blocks_examples_test.dart index 92eeade..fc4f8f0 100644 --- a/app/test/unit/blocks_examples_test.dart +++ b/app/test/unit/blocks_examples_test.dart @@ -273,6 +273,20 @@ void main() { } test('semantic empty detection includes variables and invalid graphs', () { + expect(isSemanticallyEmptyBlocksWorkspace(null), isFalse); + expect(isSemanticallyEmptyBlocksWorkspace('not JSON'), isFalse); + expect( + isSemanticallyEmptyBlocksWorkspace( + jsonEncode({'variables': []}), + ), + isFalse, + reason: 'a missing blocks section is unknown unless the object is `{}`', + ); + expect( + isSemanticallyEmptyBlocksWorkspace('{}'), + isTrue, + reason: 'Blockly serializes its canonical empty workspace as `{}`', + ); expect( isSemanticallyEmptyBlocksWorkspace( jsonEncode({ diff --git a/app/test/unit/permission_manifest_test.dart b/app/test/unit/permission_manifest_test.dart index f9c0e0e..3bd8b7d 100644 --- a/app/test/unit/permission_manifest_test.dart +++ b/app/test/unit/permission_manifest_test.dart @@ -9,12 +9,9 @@ // iOS NSBluetoothAlwaysUsageDescription // Android BLUETOOTH_SCAN with android:usesPermissionFlags="neverForLocation" // BLUETOOTH_CONNECT -// ACCESS_FINE_LOCATION with android:maxSdkVersion="30" (Android ≤ 11) -// -// CURRENTLY RED: the manifests are the default Flutter templates with no BLE -// permission block. HAND-OFF: `app/ios/Runner/Info.plist` + -// `app/android/app/src/main/AndroidManifest.xml` → app-ble-engineer (permission -// block only; app-build-smith owns the surrounding skeleton). +// BLUETOOTH / BLUETOOTH_ADMIN / ACCESS_FINE_LOCATION capped at 30 +// ACCESS_COARSE_LOCATION capped at 28 +// android.hardware.bluetooth_le required import 'dart:io'; @@ -85,6 +82,53 @@ void main() { expect(manifest, contains('BLUETOOTH_CONNECT')); }); + test('BLE hardware is required because BLE is the primary transport', () { + expect( + RegExp( + r'', + ).hasMatch(manifest), + isTrue, + ); + }); + + test('legacy permissions do not require Bluetooth Classic hardware', () { + expect( + RegExp( + r'', + ).hasMatch(manifest), + isTrue, + reason: 'PyBLE requires BLE, not a Bluetooth Classic radio', + ); + }); + + test('legacy scan permissions do not require location hardware', () { + expect( + RegExp( + r'', + ).hasMatch(manifest), + isTrue, + reason: + 'BLE scans on older Android need permission, not location hardware', + ); + }); + + test('legacy Bluetooth permissions are capped at maxSdkVersion 30', () { + for (final String permission in [ + 'android.permission.BLUETOOTH', + 'android.permission.BLUETOOTH_ADMIN', + ]) { + expect( + usesPermissionWithBoth( + manifest, + 'android:name="$permission"', + 'android:maxSdkVersion="30"', + ), + isTrue, + reason: '$permission is required only through Android 11', + ); + } + }); + test('legacy ACCESS_FINE_LOCATION is capped at maxSdkVersion 30', () { expect( usesPermissionWithBoth( @@ -96,5 +140,26 @@ void main() { reason: 'location handling is Android ≤ 11 only (IF-5)', ); }); + + test('legacy ACCESS_COARSE_LOCATION is capped at maxSdkVersion 28', () { + expect( + usesPermissionWithBoth( + manifest, + 'ACCESS_COARSE_LOCATION', + 'android:maxSdkVersion="28"', + ), + isTrue, + reason: 'Android 9 and lower need the legacy coarse declaration', + ); + }); + + test('the failing Impeller/Vulkan renderer path is disabled', () { + expect( + RegExp( + r'', + ).hasMatch(manifest), + isTrue, + ); + }); }); } diff --git a/app/test/widget/blockly_examples_view_test.dart b/app/test/widget/blockly_examples_view_test.dart index e4e2e3b..ef3f7a3 100644 --- a/app/test/widget/blockly_examples_view_test.dart +++ b/app/test/widget/blockly_examples_view_test.dart @@ -98,6 +98,7 @@ Future _pump( WidgetTester tester, { required bool empty, Size size = const Size(1024, 768), + BlocksExamplePreviewer? previewer, }) async { final RecordingConnection connection = RecordingConnection( initial: ConnState.ready, @@ -108,7 +109,9 @@ Future _pump( connection: connection, extra: [ _fakeWorkspace(), - _fakeExamplePreviewer(), + previewer == null + ? _fakeExamplePreviewer() + : blocksExamplePreviewerProvider.overrideWithValue(previewer), _fakeExampleCatalog(), ], size: size, @@ -178,10 +181,20 @@ void main() { expect(find.byKey(_createCopyKey), findsOneWidget); expect(find.byKey(_replaceKey), findsNothing); expect(find.text('Generated Python'), findsOneWidget); - expect(find.textContaining("print('Hello, PyBLE!')"), findsOneWidget); + expect( + find.text('Choose an action below to generate Python.'), + findsOneWidget, + ); + expect(find.text('Generating Python…'), findsNothing); + expect( + find.textContaining("print('Hello, PyBLE!')"), + findsNothing, + reason: 'opening and browsing must not invoke the platform previewer', + ); await tester.tap(find.byKey(_previewKey)); await tester.pumpAndSettle(); + expect(find.textContaining("print('Hello, PyBLE!')"), findsWidgets); final BlocksDocument after = container.read(blocksDocumentProvider); expect(after.retainedWorkspaceJson, before.retainedWorkspaceJson); @@ -209,7 +222,12 @@ void main() { await tester.tap(find.byKey(_emptyExamplesButtonKey)); await tester.pumpAndSettle(); - expect(find.textContaining("print('Hello, PyBLE!')"), findsOneWidget); + expect(find.textContaining("print('Hello, PyBLE!')"), findsNothing); + await tester.tap(find.byKey(_previewKey)); + await tester.pumpAndSettle(); + expect(find.textContaining("print('Hello, PyBLE!')"), findsWidgets); + await tester.tap(find.text('Close')); + await tester.pumpAndSettle(); Finder previewLiveRegions() => find.descendant( of: find.byKey(_catalogKey), @@ -410,6 +428,10 @@ void main() { find.textContaining('GPIO numbers vary by board'), findsOneWidget, ); + expect( + find.text('Enter every required GPIO to enable generation.'), + findsOneWidget, + ); expect(find.textContaining('wiring'), findsWidgets); expect(find.widgetWithText(TextField, 'LED GPIO'), findsOneWidget); expect( @@ -433,6 +455,219 @@ void main() { }, ); + testWidgets( + 'GPIO editing does not invoke the platform previewer until an action', + (WidgetTester tester) async { + int previewCalls = 0; + await _pump( + tester, + empty: true, + previewer: (String workspaceJson) async { + previewCalls += 1; + return BlocksExamplePreview( + source: '# explicit preview\n', + workspaceJson: workspaceJson, + ); + }, + ); + tester.widget(find.byKey(_examplesButtonKey)).onPressed!(); + await tester.pumpAndSettle(); + expect( + previewCalls, + 0, + reason: 'opening the native chooser must not invoke the WebView', + ); + await tester.tap( + find.byKey( + const ValueKey('blocksExampleCard-count-repeatedly'), + ), + ); + await tester.pumpAndSettle(); + expect( + previewCalls, + 0, + reason: 'selecting a non-GPIO example must remain preview-idle', + ); + await tester.tap( + find.byKey(const ValueKey('blocksExampleCard-blink-led')), + ); + await tester.pumpAndSettle(); + expect( + previewCalls, + 0, + reason: 'selecting an example must not invoke the WebView', + ); + + await tester.enterText(_fieldWithLabel('LED GPIO'), '-1'); + await tester.pumpAndSettle(); + expect( + previewCalls, + 0, + reason: 'validating an invalid GPIO must remain native-only', + ); + await tester.enterText(_fieldWithLabel('LED GPIO'), ''); + await tester.pumpAndSettle(); + expect( + previewCalls, + 0, + reason: 'clearing a GPIO draft must remain native-only', + ); + await tester.enterText(_fieldWithLabel('LED GPIO'), '2'); + await tester.pumpAndSettle(); + expect( + previewCalls, + 0, + reason: + 'typing must not call into Android WebView while Gboard owns focus', + ); + + await tester.tap(find.byKey(_previewKey)); + await tester.pumpAndSettle(); + expect(previewCalls, 1); + }, + ); + + testWidgets( + 'tablet GPIO field keeps its input connection across keyboard layout', + (WidgetTester tester) async { + const double physicalKeyboardInset = 640; + addTearDown(tester.view.resetViewInsets); + await _pump(tester, empty: true); + tester.widget(find.byKey(_examplesButtonKey)).onPressed!(); + await tester.pumpAndSettle(); + await tester.tap( + find.byKey(const ValueKey('blocksExampleCard-blink-led')), + ); + await tester.pumpAndSettle(); + + Finder gpioField = _fieldWithLabel('LED GPIO'); + await tester.tap(gpioField); + await tester.pump(); + Finder editable = find.descendant( + of: gpioField, + matching: find.byType(EditableText), + ); + final TextField originalField = tester.widget(gpioField); + final Key? originalKey = originalField.key; + final TextEditingController? originalController = + originalField.controller; + final FocusNode originalFocus = tester + .widget(editable) + .focusNode; + expect(originalKey, isA()); + expect(originalController, isNotNull); + expect(originalFocus.hasFocus, isTrue); + expect(tester.testTextInput.isVisible, isTrue); + + tester.view.viewInsets = const FakeViewPadding( + bottom: physicalKeyboardInset, + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 220)); + + gpioField = _fieldWithLabel('LED GPIO'); + editable = find.descendant( + of: gpioField, + matching: find.byType(EditableText), + ); + final FocusNode rebuiltFocus = tester + .widget(editable) + .focusNode; + final TextField rebuiltField = tester.widget(gpioField); + expect(rebuiltField.key, same(originalKey)); + expect(rebuiltField.controller, same(originalController)); + expect( + rebuiltFocus, + same(originalFocus), + reason: 'responsive reparenting must retain the real text field', + ); + expect(rebuiltFocus.hasFocus, isTrue); + expect(tester.testTextInput.isVisible, isTrue); + + tester.testTextInput.enterText('1'); + await tester.pump(); + gpioField = _fieldWithLabel('LED GPIO'); + editable = find.descendant( + of: gpioField, + matching: find.byType(EditableText), + ); + final TextField afterFirstDigit = tester.widget(gpioField); + final FocusNode afterFirstDigitFocus = tester + .widget(editable) + .focusNode; + expect(afterFirstDigit.key, same(originalKey)); + expect(afterFirstDigit.controller, same(originalController)); + expect(afterFirstDigit.controller?.text, '1'); + expect(afterFirstDigitFocus, same(originalFocus)); + expect(afterFirstDigitFocus.hasFocus, isTrue); + expect(tester.testTextInput.isVisible, isTrue); + + tester.testTextInput.enterText('12'); + await tester.pump(); + gpioField = _fieldWithLabel('LED GPIO'); + editable = find.descendant( + of: gpioField, + matching: find.byType(EditableText), + ); + final TextField afterSecondDigit = tester.widget(gpioField); + final FocusNode afterSecondDigitFocus = tester + .widget(editable) + .focusNode; + expect(afterSecondDigit.key, same(originalKey)); + expect(afterSecondDigit.controller, same(originalController)); + expect(afterSecondDigit.controller?.text, '12'); + expect(afterSecondDigitFocus, same(originalFocus)); + expect(afterSecondDigitFocus.hasFocus, isTrue); + expect(tester.testTextInput.isVisible, isTrue); + + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: '1', + selection: TextSelection.collapsed(offset: 1), + ), + ); + await tester.pump(); + gpioField = _fieldWithLabel('LED GPIO'); + editable = find.descendant( + of: gpioField, + matching: find.byType(EditableText), + ); + final TextField afterDeletion = tester.widget(gpioField); + final FocusNode afterDeletionFocus = tester + .widget(editable) + .focusNode; + expect(afterDeletion.key, same(originalKey)); + expect(afterDeletion.controller, same(originalController)); + expect(afterDeletion.controller?.text, '1'); + expect(afterDeletionFocus, same(originalFocus)); + expect(afterDeletionFocus.hasFocus, isTrue); + expect(tester.testTextInput.isVisible, isTrue); + + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: '12', + selection: TextSelection.collapsed(offset: 2), + ), + ); + await tester.pump(); + gpioField = _fieldWithLabel('LED GPIO'); + editable = find.descendant( + of: gpioField, + matching: find.byType(EditableText), + ); + final TextField afterReentry = tester.widget(gpioField); + final FocusNode afterReentryFocus = tester + .widget(editable) + .focusNode; + expect(afterReentry.key, same(originalKey)); + expect(afterReentry.controller, same(originalController)); + expect(afterReentry.controller?.text, '12'); + expect(afterReentryFocus, same(originalFocus)); + expect(afterReentryFocus.hasFocus, isTrue); + expect(tester.testTextInput.isVisible, isTrue); + }, + ); + testWidgets('NeoPixel example stays generic and requires its data GPIO', ( WidgetTester tester, ) async { diff --git a/app/test/widget/blocks_view_test.dart b/app/test/widget/blocks_view_test.dart index 102ec3d..8eeabb9 100644 --- a/app/test/widget/blocks_view_test.dart +++ b/app/test/widget/blocks_view_test.dart @@ -220,6 +220,40 @@ void main() { ); }); + testWidgets('canonical Blockly empty object is loaded, not still loading', ( + WidgetTester tester, + ) async { + final RecordingConnection connection = RecordingConnection( + initial: ConnState.ready, + ); + await pumpSurface( + tester, + const BlocksView(), + connection: connection, + extra: [_fakeWorkspace()], + ); + containerOf(tester) + .read(blocksDocumentProvider.notifier) + .receiveBridgeMessage( + jsonEncode({ + 'version': kBlocksBridgeVersion, + 'type': 'snapshot', + 'revision': 1, + 'source': '', + 'workspace': {}, + }), + ); + await tester.pump(); + final AppLocalizations l10n = l10nOf(tester); + + expect(find.text(l10n.blocksExamplesEmptyTitle), findsOneWidget); + expect( + tester.widget(find.byKey(kBlocksPreviewButtonKey)).tooltip, + l10n.blocksEmptyHint, + ); + expect(find.bySemanticsLabel(l10n.blocksNotReady), findsNothing); + }); + testWidgets('previews the exact generated Python as selectable text', ( WidgetTester tester, ) async { diff --git a/docs/specifications/App/TDD.md b/docs/specifications/App/TDD.md index 97089f0..5572195 100644 --- a/docs/specifications/App/TDD.md +++ b/docs/specifications/App/TDD.md @@ -491,7 +491,7 @@ Implements the byte boundary; references [protocol.md §2](../protocol.md#2-ble- **Frozen permission / readiness contract (S3 A-04 · `[docs]` 2026-07-02).** - **Typed, mockable readiness seam (surfaced through `lib/pble` ONLY, FR-BLE-8):** `enum BleReadiness { ready, adapterOff, unauthorized, unsupported }` plus a `Stream` of transitions (a reasons stream) so the layer above can render a localized rationale *later*. `lib/ble` derives it from `FlutterBluePlus.adapterState` (`on → ready`, `off → adapterOff`, `unauthorized → unauthorized`, `unavailable → unsupported`); `lib/pble` re-exposes it as a neutral type — **no widget imports `lib/ble/`** and no `flutter_blue_plus` enum crosses the seam (CON-8). -- **Platform manifest keys (A-04 content, owned by app-ble-engineer; app-build-smith owns the surrounding skeleton):** iOS `ios/Runner/Info.plist` → `NSBluetoothAlwaysUsageDescription`. Android `android/app/src/main/AndroidManifest.xml` → `BLUETOOTH_SCAN` (`android:usesPermissionFlags="neverForLocation"`) + `BLUETOOTH_CONNECT`, and legacy `ACCESS_FINE_LOCATION` with `android:maxSdkVersion="30"` (Android ≤ 11 only). The `flutter_blue_plus_android` plugin manifest is intentionally blank, so the app MUST declare these itself; `startScan(androidUsesFineLocation: false)` since the scan is service-UUID-filtered and derives no location. +- **Platform manifest keys (A-04 content, owned by app-ble-engineer; app-build-smith owns the surrounding skeleton):** iOS `ios/Runner/Info.plist` → `NSBluetoothAlwaysUsageDescription`. Android `android/app/src/main/AndroidManifest.xml` → required `android.hardware.bluetooth_le`, with `android.hardware.bluetooth` and `android.hardware.location` optional (to override hardware implicitly required by legacy permissions); `BLUETOOTH_SCAN` (`android:usesPermissionFlags="neverForLocation"`) + `BLUETOOTH_CONNECT`; legacy `BLUETOOTH` + `BLUETOOTH_ADMIN` + `ACCESS_FINE_LOCATION`, each with `android:maxSdkVersion="30"`; and `ACCESS_COARSE_LOCATION` with `android:maxSdkVersion="28"`. The `flutter_blue_plus_android` plugin manifest is intentionally blank, so the app MUST declare these itself; `startScan(androidUsesFineLocation: false)` since the scan is service-UUID-filtered and derives no location. - **Dependency decision — NO new dependency (prefer-zero, BLD-2):** the pinned `flutter_blue_plus` (1.36.8) already (a) exposes `adapterState` covering on/off/`unauthorized`/`unavailable`, and (b) requests the Android 12+ runtime BLE permissions at `startScan`; iOS surfaces authorization denial as `adapterState == unauthorized` off the Info.plist string. `permission_handler` is **not** required and MUST NOT be added. - **No user-facing strings at S3:** A-04 ships the seam + manifest keys only; the localized rationale UI and its ARB entries are **A-22/S5**. S3 therefore adds no ARB keys and the locale-parity gate stays trivially green. @@ -652,6 +652,48 @@ lazy and fakeable; Dart bridge decoding/controller/action behavior is covered without a platform view, while the real restore → generate → Save → Run flow is exercised on both iPadOS and Android. +GPIO field changes update only native validation/button state; they never call +the WebView generator while the software keyboard owns focus. Role-scoped +`GlobalKey` and `TextEditingController` instances remain stable across the +keyboard-inset layout branch and every validation `setState`; staged digits, +delete, and re-entry retain the same `EditableText` focus/input connection. +Preview, Create +copy, and Replace workspace use the existing `_ensurePreview` boundary to run +the production generator explicitly. The idle source surface prompts for an +explicit action and uses generating copy only while that future is pending. The +authored local host posts one +versioned readiness message only after `window.pybleBlocks` exists; a +page-generation-aware Dart gate then configures it once. Exact same-generation +duplicates are swallowed before the document bridge. The gate has distinct +attempting/configured states: configuration success commits readiness, while a +failure re-arms it and a duplicate received during the attempt is queued for +one retry. Dart passes the positive document-host epoch into `configureHost`; +every later snapshot/error/example-catalog envelope echoes it. The channel +ignores a well-formed stale epoch, while a missing/malformed current envelope +is contained as a visible host error. An allowed main-frame +reload starts a new document-host epoch and re-arms both readiness and the +bounded first-snapshot watchdog. The first page load awaits completion of +JavaScript-mode, channel, and navigation-delegate setup in that order; those +Pigeon operations are never launched concurrently against a lazily created +native WebView. Its error-owned future is installed during `initState`, checks +mount state between phases, and cannot continue setup/load after disposal. +Because `onWebResourceError` has no reliable same-URL navigation epoch, it does +not report against the mutable host; asset-load rejection and the current +generation watchdog are the bounded failure boundaries. The exact bundled +asset is then loaded once; there is no sleep, +polling, or automatic reload. Android physical-device tests run under a +dedicated application-ID suffix, so Flutter test teardown cannot uninstall the +normal launcher package. The normal package is always built from +`lib/main.dart`; every documented Android command names `production` or +`integration` explicitly. The Android CI device job also compiles a production +release APK after host tests, catching stale dev-plugin registration without +shipping the integration-test plugin. +Blockly's own serializer emits `{}` for a canonical workspace with no blocks or +variables. Native semantic-empty detection accepts that exact object as empty, +as well as the expanded empty-block-list form, so a loaded blank workspace gets +the empty-workspace actions instead of a false loading label. Null, malformed, +variable-only, and non-empty graph payloads remain non-empty or unknown. + ADR-0017's source-first/sidecar-last pair action supersedes only the source-only Save/Run sequence in this baseline paragraph. Fresh-snapshot acknowledgement, request correlation, the action lock, and the final `runFile` @@ -1269,7 +1311,7 @@ Shared by run-control (b) and the console indicator (c). This is seam wiring (li ## 13. Platform design -— *(satisfies NFR-COMPAT-1..3, FR-UI-1..7, CON-5.)* +— *(satisfies NFR-COMPAT-1..4, FR-UI-1..7, CON-5.)* ### 13.1 Feature parity @@ -1439,15 +1481,19 @@ The hosted integration job uses an isolated Gradle user home whose `gradle.properties` bounds the build JVM to a 3 GiB heap, 1 GiB metaspace, and 256 MiB code cache, disables the persistent Gradle daemon, permits one Gradle worker, and keeps Kotlin compilation in that same process. It compiles the -sole integration entrypoint once as an x86_64 debug APK before starting the -AVD, verifies that artifact, and stops Gradle. The device phase invokes -`flutter drive --use-application-binary` against that exact APK, so no Android -assembly or compiler process overlaps the running emulator. Prebuild and -device test are separately time-bounded and write directly to retained -diagnostic logs, so a failed timeout cannot be hidden behind a still-open -shell pipeline. This keeps compiler and emulator peak allocations sequential -on the smallest supported hosted runner while preserving the -single-entrypoint, single-binary integration contract. +sole integration entrypoint once with the isolated `integration` flavor as the +x86_64 `app-integration-debug.apk` before starting the AVD, verifies that +artifact, and stops Gradle. The device phase invokes +`flutter drive --flavor integration --use-application-binary` against that +exact APK, so no Android assembly or compiler process overlaps the running +emulator. Before creating the AVD, the same job separately builds +`app-production-release.apk` from `lib/main.dart` and verifies its normal +`dev.pyble.pyble` package ID. Prebuild and device test are separately +time-bounded and write directly to retained diagnostic logs, so a failed +timeout cannot be hidden behind a still-open shell pipeline. This keeps +compiler and emulator peak allocations sequential on the smallest supported +hosted runner while preserving the single-entrypoint, single-binary device-test +contract and a separately compiled production artifact. The Blockly suite verifies the offline asset, JavaScript channel, restore/recreation, source generation, examples, sidecar reopen, bounded Python @@ -1463,6 +1509,15 @@ fake board and on-device smoke per chip (run/stop, multi-file upload, dropped-link resume, observe-anywhere console) — NFR-REL-1..4, NFR-PERF-* (HIL ceilings frozen later, [OI-4](specs.md)). +The Android build also pins `io.flutter.embedding.android.EnableImpeller=false` +while the Flutter 3.44 Impeller/Vulkan path produces a blank frame on the +validated Lenovo TB-J616X/MediaTek Android 12 tablet. Host tests assert the +manifest switch; the physical-device gate cold-launches the normally installed +APK (without a CLI renderer flag), captures a non-blank PyBLE frame, grants the +normal runtime BLE permissions, connects to a real classic ESP32 agent, and +exercises the real Blockly host plus chooser GPIO entry. Re-evaluate and remove +the opt-out only after that same matrix passes on a later Flutter engine. + ### 15.6 Import-boundary & no-leak gates A static check (custom lint / dependency rule) fails the build if any widget imports `lib/ble/` (NFR-MAINT-1, CON-8). The no-leak gate ([CLAUDE.md](../../../CLAUDE.md), [AGENTS.md](../../../AGENTS.md)) runs over app source — zero proprietary tokens (CON-6, FR-PBLE-15, BLD-8). An SPDX-header lint enforces `SPDX-License-Identifier: MIT` on every source file (BLD-8). @@ -1515,7 +1570,7 @@ Design element → satisfied requirement IDs. Each `FR-*`/`NFR-*`/`CON-*`/`DAT-* | Persistence ([§4.10](#410-libdata--persistence-drift), [§9](#9-persistence-design-libdata)) | lib/data | FR-PROJ-1..7, DAT-1..8, IF-3, NFR-OFF-1..3, CON-4 | | Localization ([§4.11](#411-liblocalization--i18n), [§12](#12-localization-design-liblocalization)) | lib/localization | FR-I18N-1..5, NFR-A11Y-1/2/4, BLD-5, CON-9 | | About, runtime metadata, and offline notices entry ([§4.12](#412-libapp--about-and-open-source-information), [§13.2](#132-responsive-layout)) | lib/app | FR-ABOUT-1..8, IF-6, NFR-OFF-1..3, NFR-A11Y-3, CON-5/8/9, SEC-3/5 | -| Platform & responsive layout ([§13](#13-platform-design)) | (app-wide) | FR-UI-1..7, NFR-COMPAT-1..3, NFR-USE-2/4, CON-5 | +| Platform & responsive layout ([§13](#13-platform-design)) | (app-wide) | FR-UI-1..7, NFR-COMPAT-1..4, NFR-USE-2/4, CON-5 | | Run control & data flow ([§3.2](#32-data-flow-example--run-a-file)–[§3.4](#34-data-flow-example--file-mirroring-download)) | lib/pble + UI | FR-RUN-1..5, FR-CONN-2 | | Error handling & mapping ([§14](#14-error-handling--mapping)) | lib/pble + UI | FR-PBLE-13, FR-ERR-*, FR-FILES-3, NFR-USE-3, NFR-REL-4 | | Reliability (resume, CRC, preserve-on-drop) ([§8.4](#84-file-transfer-state-machine), [§9.2](#92-migrations--hydration)) | lib/pble, lib/data | NFR-REL-1..4, NFR-PERF-1/2, FR-PROJ-6 | diff --git a/docs/specifications/App/specs.md b/docs/specifications/App/specs.md index 6cc8df4..99dcb45 100644 --- a/docs/specifications/App/specs.md +++ b/docs/specifications/App/specs.md @@ -81,7 +81,7 @@ Requirement voice: MUST / SHOULD / MAY. Each line: **ID** — statement — *(so - **FR-BLE-3** — The adapter MUST expose a byte-stream interface: an inbound `Stream>` from TX notifications and a `write(bytes)` to RX (Write / Write-Without-Response); it MUST NOT interpret frame contents. MUST (*source: app.md §2; verify: unit; story: A-02*) - **FR-BLE-4** — The adapter MUST detect link loss and auto-reattempt connection, exposing connection-state transitions to the layer above; it MUST reconnect a saved board by remembered identifier. MUST (*source: PRD §8.1, §13.1; verify: unit/integration; story: A-03*) - **FR-BLE-5** — The adapter MUST stop scanning before initiating a connection. MUST (*source: PRD §9.6; verify: unit; story: A-02*) -- **FR-BLE-6** — The adapter MUST request and handle platform BLE permissions and adapter-off state, surfacing them upward with enough detail for a localized rationale (iOS `NSBluetoothAlwaysUsageDescription`; Android 12+ `BLUETOOTH_SCAN`/`BLUETOOTH_CONNECT`, plus location handling on older Android). MUST (*source: PRD §9.6, §13.6, app.md §5; verify: integration; story: A-04*) +- **FR-BLE-6** — The adapter MUST request and handle platform BLE permissions and adapter-off state, surfacing them upward with enough detail for a localized rationale (iOS `NSBluetoothAlwaysUsageDescription`; Android 12+ `BLUETOOTH_SCAN`/`BLUETOOTH_CONNECT`; Android 11 and lower `BLUETOOTH`/`BLUETOOTH_ADMIN` capped at API 30 plus location handling; `ACCESS_COARSE_LOCATION` capped at API 28). Because BLE is the sole primary transport, the Android package MUST declare `android.hardware.bluetooth_le` as required. It MUST declare `android.hardware.bluetooth` and `android.hardware.location` as not required so legacy permissions do not filter otherwise-compatible BLE hardware. MUST (*source: PRD §9.6, §13.6, app.md §5; verify: integration; story: A-04*) - **FR-BLE-7** — The adapter MUST remain a thin, mockable seam so scan/connect/reconnect are testable with a mocked transport and behaviour differences between iOS and Android are isolable. MUST (*source: PRD §23 (BLE risk), app.md §7; verify: unit; story: A-01/A-02/A-03*) - **FR-BLE-8** — No UI widget MUST import `lib/ble/`; the adapter is reachable only through `lib/pble/`. MUST (*source: PRD §6.1, §16.1, app.md §1; verify: unit (import-boundary lint); story: A-02*) @@ -198,6 +198,47 @@ The client implements the PBLE/1 wire contract; it references [protocol.md](../p ### 4.10 Blocks & Plots — FR-BLOCKS / FR-PLOTS - **FR-BLOCKS-1** — The app MUST provide a Blockly block editor (WebView, `lib/blocks/`) that generates an inspectable, board-neutral MicroPython subset with **no board-specific defaults**. The current numeric-GPIO and NeoPixel subset is initially validated on ESP32-family firmware; broader runtime availability MUST NOT be implied. MUST (*source: PRD §9.8, §16.1; verify: integration; story: A-31*) +- **FR-BLOCKS-1A — Android input and test isolation (FROZEN · A-31 · `[docs]` + 2026-08-07).** Native GPIO fields in the example chooser MUST retain focus + and accept ordinary software-keyboard input on supported Android tablets. + Responsive keyboard-inset changes and every per-keystroke validation rebuild + MUST retain the same role-scoped field/controller/input client, including + staged multi-digit entry, deletion, and re-entry. + Editing or validating a GPIO draft MUST NOT invoke the Blockly platform view; + production generation occurs only after an explicit Preview, Create copy, or + Replace workspace action. The bundled host MUST request configuration with a + versioned JavaScript-channel readiness event emitted once per page generation + and only after its authored API exists. An allowed main-frame reload MUST + start a fresh host epoch, readiness gate, and bounded watchdog; an exact + duplicate readiness event in one generation is swallowed and MUST NOT enter + the document bridge. Readiness is committed only after host configuration + succeeds; a failed or in-flight attempt MUST NOT consume the real readiness + event. Every post-configuration bridge envelope MUST echo the positive Dart + host epoch. A valid message from another epoch is ignored; a missing or + malformed epoch fails visibly. Native WebView controller setup MUST execute + and finish + sequentially—JavaScript mode, JavaScript channel, then navigation delegate— + before the single bundled-asset load begins. The startup future MUST own its + error handler immediately and MUST stop between phases after disposal. A + main-frame resource error with no assignable page epoch MUST rely on the + owned asset-load rejection or generation watchdog rather than be attributed + to a mutable host. Startup MUST NOT use a delay, + poll, or automatic reload; it remains bounded by the existing watchdog and + MUST fail visibly instead of polling without limit. Android device-test + builds MUST use an application ID distinct from the normal + `dev.pyble.pyble` launcher so test installation and cleanup cannot replace or + uninstall the user-facing app. Android development, build, and run commands + MUST name either the `production` or `integration` flavor; contributor + guidance MUST NOT leave the application ID implicit. + The native semantic-empty check MUST accept Blockly's canonical empty + workspace serialization (`{}`) as empty, while null, malformed, + variable-only, and non-empty graphs remain non-empty or unknown. A + successfully loaded canonical empty workspace MUST show the empty-workspace + actions and MUST NOT be labelled as still loading. + CI MUST compile the `production` release APK after host tests so a dev-only + integration plugin can neither break nor enter the production artifact. + MUST (*source: PRD §13.6, §16.1; verify: widget/Android physical-device; + story: A-31/X-11*) - **FR-BLOCKS-2** — Generated code MUST be inspectable as plain `.py` and MUST flow through the same upload/run path as the text editor (§8.2). MUST (*source: PRD §9.8; verify: integration; story: A-31*) - **FR-BLOCKS-3** — The app MUST allow running or saving the generated code through the `Connection` API. MUST (*source: PRD §9.8; verify: integration; story: A-31*) - **FR-BLOCKS-4** — The block bridge MUST bind to `Connection`/neutral types @@ -403,13 +444,20 @@ contains exactly `hello-pyble`, `count-repeatedly`, `blink-led`, `blink-neopixel`, `read-button`, `button-controls-led`, and `reusable-function` in that order. Each fixture is ordinary Blockly serialization plus stable technical metadata and ARB keys. -The catalog does not duplicate generated Python. The app restores a deep clone -and invokes the production generator for the selectable, read-only source -preview. Non-hardware examples generate immediately. A GPIO fixture contains +The catalog does not duplicate generated Python. Opening, browsing, selecting, +or editing a GPIO draft does not invoke the platform view or production +generator. Only an explicit **Preview**, **Create copy**, or **Replace +workspace** action restores a deep clone and invokes the production generator +for the selectable, read-only source preview. Before that action, the preview +surface MUST state that generation awaits an action; it MUST show a loading +state only while generation is actually in progress. A GPIO fixture contains disconnected constructor sockets and role bindings only; the user must provide each finite, non-negative integral GPIO before source is previewed or copied, and values for separate roles must be pairwise distinct. A repeated role value produces a localized field error; this does not claim physical board validity. +While a required GPIO is absent or invalid, the source surface describes that +entering every required GPIO enables generation; it does not promise automatic +generation. Materialization connects ordinary number blocks in the clone and leaves no role placeholder or metadata in the active workspace. No board/chip default, named onboard component, remembered pin, or claimed safe value is supplied. @@ -634,6 +682,15 @@ finished. - **NFR-COMPAT-1** — iPadOS and Android tablet MUST ship at feature parity at every milestone; neither platform may lag the other for a released capability. MUST (*source: PRD §13.6, §15.3; verify: integration; story: X-11*) - **NFR-COMPAT-2** — BLE permissions MUST be handled per platform (iOS `NSBluetoothAlwaysUsageDescription`; Android 12+ `BLUETOOTH_SCAN`/`BLUETOOTH_CONNECT` + older-Android location). MUST (*source: PRD §13.6, app.md §5; verify: integration; story: A-04*) - **NFR-COMPAT-3** — The app MUST NOT depend on USB serial or Wi-Fi onboarding as a runtime path; BLE is the only v1.0 transport. MUST (*source: PRD §13.6, §1A.3; verify: unit; story: A-02*) +- **NFR-COMPAT-4 — Android renderer fallback (FROZEN · X-11 · `[docs]` + 2026-08-07).** Until the pinned Flutter engine renders correctly through + Impeller/Vulkan on the validated Lenovo TB-J616X/MediaTek Android 12 device, + the Android manifest MUST disable Impeller so Flutter uses its supported + legacy OpenGL renderer. A real-device cold-launch check MUST show the first + application frame and the BLE connect surface; a blank foreground activity + fails the Android gate. This temporary opt-out MUST be re-evaluated whenever + Flutter is upgraded because Flutter has deprecated permanent opt-out. + MUST (*source: PRD §13.6, §15.3; verify: unit/build/manual; story: X-11*) ### 5.6 Accessibility & Localization — NFR-A11Y diff --git a/tests/publication/test_ci_contract.py b/tests/publication/test_ci_contract.py index a7dd79e..e468364 100644 --- a/tests/publication/test_ci_contract.py +++ b/tests/publication/test_ci_contract.py @@ -107,7 +107,7 @@ def test_android_avd_reclaims_runner_disk_for_its_required_storage(self) -> None android, ) - def test_android_integration_builds_one_application_bundle(self) -> None: + def test_android_integration_uses_one_isolated_application_bundle(self) -> None: workflow = (REPO_ROOT / ".github" / "workflows" / "ci.yml").read_text( encoding="utf-8" ) @@ -121,13 +121,14 @@ def test_android_integration_builds_one_application_bundle(self) -> None: self.assertIn("timeout --signal=TERM --kill-after=30s 12m", android) self.assertIn("--no-pub", android) self.assertIn("flutter drive \\", android) + self.assertIn("--flavor integration", android) self.assertIn( "--driver test_driver/integration_test.dart", android, ) self.assertIn( "--use-application-binary " - "build/app/outputs/flutter-apk/app-debug.apk", + "build/app/outputs/flutter-apk/app-integration-debug.apk", android, ) self.assertNotIn(" flutter test \\", android) @@ -256,10 +257,17 @@ def test_android_integration_prebuilds_with_bounded_runner_resources( integration, ) self.assertLess(android.index("sleep 20"), integration) - self.assertEqual(1, android.count("flutter build apk")) + self.assertEqual(2, android.count("flutter build apk")) + self.assertIn( + "flutter build apk --release --flavor production " + "--target lib/main.dart", + android, + ) + self.assertIn("package: name='dev.pyble.pyble'", android) self.assertIn( "flutter build apk \\\n" " --debug \\\n" + " --flavor integration \\\n" " --no-pub \\\n" " --target-platform android-x64 \\\n" " --target integration_test/android_smoke_test.dart", @@ -270,7 +278,8 @@ def test_android_integration_prebuilds_with_bounded_runner_resources( android, ) self.assertIn( - "test -s build/app/outputs/flutter-apk/app-debug.apk", + "test -s " + "build/app/outputs/flutter-apk/app-integration-debug.apk", android, ) self.assertIn("./android/gradlew --stop", android) @@ -280,7 +289,7 @@ def test_android_integration_prebuilds_with_bounded_runner_resources( ) self.assertIn( "--use-application-binary " - "build/app/outputs/flutter-apk/app-debug.apk", + "build/app/outputs/flutter-apk/app-integration-debug.apk", android, ) self.assertNotIn( diff --git a/tools/test_blockly_assets.mjs b/tools/test_blockly_assets.mjs index 0256717..f28dd3c 100644 --- a/tools/test_blockly_assets.mjs +++ b/tools/test_blockly_assets.mjs @@ -127,16 +127,30 @@ try { path.join(repoRoot, "app/assets/blockly/index.html"), ); await page.goto(indexUrl.href, { waitUntil: "load" }); - const hostConfiguration = await page.evaluate((messages) => { + const hostEpoch = 101; + const invalidHostEpoch = await page.evaluate((messages) => { + try { + window.pybleBlocks.configureHost(messages, 0); + return "accepted"; + } catch (error) { + return String(error); + } + }, localizedHostMessages); + if (!invalidHostEpoch.includes("host epoch")) { + throw new Error( + `Blockly accepted an invalid Dart host epoch: ${invalidHostEpoch}`, + ); + } + const hostConfiguration = await page.evaluate(({ messages, epoch }) => { const configurable = typeof window.pybleBlocks?.configureHost === "function"; return { configurable, accepted: configurable - ? window.pybleBlocks.configureHost(messages) + ? window.pybleBlocks.configureHost(messages, epoch) : false, }; - }, localizedHostMessages); + }, { messages: localizedHostMessages, epoch: hostEpoch }); if (!hostConfiguration.configurable || hostConfiguration.accepted !== true) { throw new Error( `localized Blockly host configuration failed: ${JSON.stringify(hostConfiguration)}`, @@ -145,6 +159,17 @@ try { await page.waitForFunction(() => window.__pybleMessages.some((message) => message.type === "snapshot"), ); + const initialSnapshot = await page.evaluate(() => + window.__pybleMessages.find((message) => message.type === "snapshot"), + ); + if ( + !initialSnapshot || + JSON.stringify(initialSnapshot.workspace) !== "{}" + ) { + throw new Error( + `Blockly's canonical empty serialization changed: ${JSON.stringify(initialSnapshot)}`, + ); + } async function assertWorkspaceGeometry(width, height) { await page.setViewport({ width, height, deviceScaleFactor: 1 }); @@ -624,6 +649,7 @@ try { version: 1, type: "openExamples", exampleId: "blink-led", + hostEpoch, }, }); @@ -1957,16 +1983,19 @@ try { }); await coldStartPage.goto(indexUrl.href, { waitUntil: "load" }); const retainedRevision = 4096; + const coldStartHostEpoch = 202; const coldStartConfiguration = await coldStartPage.evaluate( - ({ messages, retainedWorkspaceJson, priorRevision }) => ({ + ({ messages, epoch, retainedWorkspaceJson, priorRevision }) => ({ accepted: window.pybleBlocks.configureHost( messages, + epoch, retainedWorkspaceJson, priorRevision, ), }), { messages: localizedHostMessages, + epoch: coldStartHostEpoch, retainedWorkspaceJson: JSON.stringify(gpioWorkspace), priorRevision: retainedRevision, }, @@ -1994,21 +2023,31 @@ try { `atomic retained-workspace startup was rejected: ${JSON.stringify(coldStartConfiguration)}`, ); } - if (coldStart.workspaceCount !== 1 || coldStart.messages.length !== 1) { + if (coldStart.workspaceCount !== 1 || coldStart.messages.length !== 2) { throw new Error( - `atomic retained-workspace startup must initialize and publish exactly once: ${JSON.stringify(coldStart)}`, + `atomic retained-workspace startup must publish one readiness event then one snapshot: ${JSON.stringify(coldStart)}`, ); } - const firstColdStartMessage = coldStart.messages[0]; + const [coldStartReady, firstColdStartSnapshot] = coldStart.messages; if ( - firstColdStartMessage.type !== "snapshot" || - firstColdStartMessage.source !== expectedGpioSource || - !Number.isSafeInteger(firstColdStartMessage.revision) || - firstColdStartMessage.revision <= retainedRevision || - !JSON.stringify(firstColdStartMessage.workspace).includes("pyble_gpio_pin") + coldStartReady.type !== "hostReady" || + coldStartReady.version !== 1 || + Object.keys(coldStartReady).length !== 2 ) { throw new Error( - `the first atomic cold-start snapshot was not the retained GPIO program: ${JSON.stringify(firstColdStartMessage)}`, + `atomic retained-workspace startup did not publish exactly one versioned readiness event first: ${JSON.stringify(coldStart.messages)}`, + ); + } + if ( + firstColdStartSnapshot.type !== "snapshot" || + firstColdStartSnapshot.hostEpoch !== coldStartHostEpoch || + firstColdStartSnapshot.source !== expectedGpioSource || + !Number.isSafeInteger(firstColdStartSnapshot.revision) || + firstColdStartSnapshot.revision <= retainedRevision || + !JSON.stringify(firstColdStartSnapshot.workspace).includes("pyble_gpio_pin") + ) { + throw new Error( + `the first atomic cold-start snapshot was not the retained GPIO program: ${JSON.stringify(firstColdStartSnapshot)}`, ); } @@ -2204,6 +2243,24 @@ try { ), ); + const bridgeEpochs = await page.evaluate(() => window.__pybleMessages); + const readyMessages = bridgeEpochs.filter( + (message) => message.type === "hostReady", + ); + const postConfigMessages = bridgeEpochs.filter( + (message) => message.type !== "hostReady", + ); + if ( + readyMessages.length !== 1 || + Object.keys(readyMessages[0]).length !== 2 || + postConfigMessages.length === 0 || + postConfigMessages.some((message) => message.hostEpoch !== hostEpoch) + ) { + throw new Error( + `Blockly bridge traffic was not bound to host epoch ${hostEpoch}: ${JSON.stringify(bridgeEpochs)}`, + ); + } + const external = requests.filter((request) => { const url = new URL(request); return url.protocol !== "file:" && url.protocol !== "data:"; From 80866011002bd1f5f976fcddd8c7058b1bdebb5b Mon Sep 17 00:00:00 2001 From: Viwat Vchirawongkwin Date: Fri, 7 Aug 2026 16:20:13 +0700 Subject: [PATCH 2/4] [green] Harden Android Blockly runtime Signed-off-by: Viwat Vchirawongkwin --- .github/workflows/ci.yml | 15 +- CONTRIBUTING.md | 21 ++ app/android/app/build.gradle.kts | 14 +- .../app/src/integration/AndroidManifest.xml | 6 + app/android/app/src/main/AndroidManifest.xml | 21 +- app/assets/blockly/pyble_blockly.js | 16 +- app/lib/blocks/blockly_webview.dart | 357 +++++++++++++++--- app/lib/blocks/blocks_document.dart | 1 + app/lib/blocks/blocks_examples.dart | 3 + app/lib/blocks/blocks_examples_view.dart | 23 +- app/lib/localization/arb/app_en.arb | 6 +- .../localization/gen/app_localizations.dart | 8 +- .../gen/app_localizations_en.dart | 6 +- 13 files changed, 417 insertions(+), 80 deletions(-) create mode 100644 app/android/app/src/integration/AndroidManifest.xml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db66ec1..fd3e630 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -347,6 +347,15 @@ jobs: "ANDROID_NDK_PATH=$ANDROID_NDK_PATH" \ "ANDROID_NDK_LATEST_HOME=$ANDROID_NDK_LATEST_HOME" \ >> "$GITHUB_ENV" + - name: Production release APK build (no integration-test plugin) + shell: bash + run: | + set -euo pipefail + flutter build apk --release --flavor production --target lib/main.dart + PYBLE_AAPT="$PYBLE_ANDROID_SDK_ROOT/build-tools/36.0.0/aapt" + PYBLE_APK="build/app/outputs/flutter-apk/app-production-release.apk" + "$PYBLE_AAPT" dump badging "$PYBLE_APK" \ + | grep -F "package: name='dev.pyble.pyble'" - name: Create API 34 AOSP x86_64 tablet AVD shell: bash run: | @@ -387,6 +396,7 @@ jobs: timeout --signal=TERM --kill-after=30s 18m \ flutter build apk \ --debug \ + --flavor integration \ --no-pub \ --target-platform android-x64 \ --target integration_test/android_smoke_test.dart \ @@ -400,7 +410,7 @@ jobs: if [ "$PREBUILD_STATUS" -ne 0 ]; then exit "$PREBUILD_STATUS" fi - test -s build/app/outputs/flutter-apk/app-debug.apk + test -s build/app/outputs/flutter-apk/app-integration-debug.apk - name: Boot headless emulator (KVM + software GPU) shell: bash run: | @@ -564,7 +574,8 @@ jobs: --no-pub \ --driver test_driver/integration_test.dart \ --target integration_test/android_smoke_test.dart \ - --use-application-binary build/app/outputs/flutter-apk/app-debug.apk \ + --flavor integration \ + --use-application-binary build/app/outputs/flutter-apk/app-integration-debug.apk \ --timeout 660 \ -d "$PYBLE_ANDROID_SERIAL" \ >"$PYBLE_ANDROID_LOG_DIR/flutter-drive.log" 2>&1 \ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9b347e8..64f99c9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -86,6 +86,27 @@ flutter test --tags golden Use a recent stable Flutter SDK matching the version pinned by CI. +Use the normal package directly on iOS: + +```sh +flutter run -d +``` + +Always name an Android flavor. `production` is the normal launcher, while +`integration` uses the disposable `dev.pyble.pyble.integrationtest` package: + +```sh +flutter run --flavor production --target lib/main.dart -d +flutter build apk --release --flavor production --target lib/main.dart +flutter drive --flavor integration \ + --driver test_driver/integration_test.dart \ + --target integration_test/android_smoke_test.dart \ + -d +``` + +The current local production-flavor release build uses Gradle's debug signing +configuration for device testing. It is not a distributable beta artifact. + ### Firmware Host-side protocol and release tests do not require hardware: diff --git a/app/android/app/build.gradle.kts b/app/android/app/build.gradle.kts index 0f3bbb5..49eff2a 100644 --- a/app/android/app/build.gradle.kts +++ b/app/android/app/build.gradle.kts @@ -25,10 +25,22 @@ android { versionName = flutter.versionName } + flavorDimensions += "purpose" + productFlavors { + create("production") { + dimension = "purpose" + } + create("integration") { + dimension = "purpose" + applicationIdSuffix = ".integrationtest" + } + } + buildTypes { release { // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. + // Signing with debug keys for now, so + // `flutter run --release --flavor production` works. signingConfig = signingConfigs.getByName("debug") } } diff --git a/app/android/app/src/integration/AndroidManifest.xml b/app/android/app/src/integration/AndroidManifest.xml new file mode 100644 index 0000000..de706df --- /dev/null +++ b/app/android/app/src/integration/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml index 385fea2..1b81016 100644 --- a/app/android/app/src/main/AndroidManifest.xml +++ b/app/android/app/src/main/AndroidManifest.xml @@ -4,15 +4,32 @@ The flutter_blue_plus_android plugin manifest is intentionally blank, so the app declares these itself. neverForLocation: the scan is filtered to the PyBLE service UUID and derives no location (startScan is called with - androidUsesFineLocation: false). ACCESS_FINE_LOCATION is legacy-only, - capped at Android 11 (API 30). --> + androidUsesFineLocation: false). The app is BLE-first, so BLE hardware + is required. Classic Bluetooth and location hardware remain optional: + those permissions are only older-Android BLE scan prerequisites. + Bluetooth/Fine-Location declarations are legacy-only and capped at + Android 11 (API 30); Coarse Location is capped at Android 9 (API 28), + matching the pinned adapter contract. --> + + + + + + + + maxRevision + ) { + throw new TypeError("Dart host epoch must be a positive safe integer."); + } if (!messages || typeof messages !== "object" || Array.isArray(messages)) { throw new TypeError("Blockly host messages must be an object."); } @@ -1049,6 +1059,7 @@ registerGpioBlocks(); registerNeopixelBlocks(); registerTimeBlocks(); + hostEpoch = dartHostEpoch; pendingRestore = initialRestore; initialise(); return true; @@ -1060,6 +1071,7 @@ restore, snapshot: publishSnapshot, }); + postMessage({ version: bridgeVersion, type: "hostReady" }); function resizeWorkspace() { if (!workspace) { diff --git a/app/lib/blocks/blockly_webview.dart b/app/lib/blocks/blockly_webview.dart index c3a305d..a59a6bd 100644 --- a/app/lib/blocks/blockly_webview.dart +++ b/app/lib/blocks/blockly_webview.dart @@ -24,6 +24,165 @@ const String _assetPath = 'assets/blockly/index.html'; const String _channelName = 'PybleBlocks'; const Duration _firstSnapshotTimeout = Duration(seconds: 15); +/// Routing decision for one JavaScript-channel message during host startup. +enum BlocklyHostChannelDisposition { initialise, forward, ignore, reject } + +/// Admits the authored host's versioned readiness event once per page. +/// +/// All other channel traffic remains available to the document controller. +/// Requiring the exact two-field envelope prevents a malformed message from +/// closing the startup gate before the JavaScript API exists. +class BlocklyHostStartupGate { + int _pageGeneration = 0; + int _hostEpoch = 0; + int _attemptingGeneration = 0; + int _configuredGeneration = 0; + int _queuedReadinessGeneration = 0; + + int get generation => _pageGeneration; + + void beginPage({required int hostEpoch}) { + if (hostEpoch < 1) { + throw ArgumentError.value(hostEpoch, 'hostEpoch', 'must be positive'); + } + _pageGeneration += 1; + _hostEpoch = hostEpoch; + _attemptingGeneration = 0; + _configuredGeneration = 0; + _queuedReadinessGeneration = 0; + } + + bool isCurrentGeneration(int generation) => + generation == _pageGeneration && generation > 0; + + BlocklyHostChannelDisposition classify(String message) { + try { + final Object? decoded = jsonDecode(message); + if (decoded is Map && + decoded.length == 2 && + decoded['version'] == kBlocksBridgeVersion && + decoded['type'] == 'hostReady') { + if (_pageGeneration == 0 || _configuredGeneration == _pageGeneration) { + return BlocklyHostChannelDisposition.ignore; + } + if (_attemptingGeneration == _pageGeneration) { + _queuedReadinessGeneration = _pageGeneration; + return BlocklyHostChannelDisposition.ignore; + } + _attemptingGeneration = _pageGeneration; + return BlocklyHostChannelDisposition.initialise; + } + + if (decoded is! Map) { + return BlocklyHostChannelDisposition.reject; + } + final Object? messageHostEpoch = decoded['hostEpoch']; + if (messageHostEpoch is! int || messageHostEpoch < 1) { + return BlocklyHostChannelDisposition.reject; + } + if (_pageGeneration == 0 || messageHostEpoch != _hostEpoch) { + return BlocklyHostChannelDisposition.ignore; + } + return BlocklyHostChannelDisposition.forward; + } on FormatException { + return BlocklyHostChannelDisposition.reject; + } + } + + /// Commits one configuration attempt and requests a serialized retry when a + /// readiness duplicate arrived while the failed attempt was in flight. + bool finishInitialisation(int generation, {required bool succeeded}) { + if (!isCurrentGeneration(generation) || + _attemptingGeneration != generation) { + return false; + } + _attemptingGeneration = 0; + if (succeeded) { + _configuredGeneration = generation; + _queuedReadinessGeneration = 0; + return false; + } + if (_queuedReadinessGeneration == generation) { + _queuedReadinessGeneration = 0; + _attemptingGeneration = generation; + return true; + } + return false; + } +} + +/// Keeps readiness control traffic out of the document bridge. +@visibleForTesting +void dispatchBlocklyHostChannelMessage({ + required BlocklyHostStartupGate gate, + required String message, + required VoidCallback initialise, + required ValueChanged forward, + required VoidCallback reject, +}) { + switch (gate.classify(message)) { + case BlocklyHostChannelDisposition.initialise: + initialise(); + return; + case BlocklyHostChannelDisposition.forward: + forward(message); + return; + case BlocklyHostChannelDisposition.ignore: + return; + case BlocklyHostChannelDisposition.reject: + reject(); + return; + } +} + +/// Serializes native setup against the controller's lazily created WebView. +@visibleForTesting +Future configureBlocklyControllerSequentially({ + required bool Function() isCancelled, + required Future Function() enableJavaScript, + required Future Function() installJavaScriptChannel, + required Future Function() installNavigationDelegate, +}) async { + if (isCancelled()) return; + await enableJavaScript(); + if (isCancelled()) return; + await installJavaScriptChannel(); + if (isCancelled()) return; + await installNavigationDelegate(); +} + +/// Loads the bundled page only after every asynchronous controller hook exists. +Future loadBlocklyAssetAfterControllerSetup({ + required Future setup, + required Future Function() waitUntilLoadAllowed, + required bool Function() isCancelled, + required Future Function() load, +}) async { + await setup; + if (isCancelled()) return; + await waitUntilLoadAllowed(); + if (isCancelled()) return; + await load(); +} + +/// Owns startup errors immediately while deferring provider mutation until a +/// descendant can safely report after its first Flutter frame. +@visibleForTesting +Future runBlocklyStartupGuarded({ + required Future Function() startup, + required Future Function() waitUntilFailureCanBeReported, + required bool Function() isCancelled, + required ValueChanged reportFailure, +}) async { + try { + await startup(); + } catch (error) { + await waitUntilFailureCanBeReported(); + if (isCancelled()) return; + reportFailure(error); + } +} + /// Whether [value] is the exact local main-frame location admitted by A-31. /// /// Relative scripts/media are subresources and do not navigate the main frame. @@ -48,6 +207,11 @@ bool isAllowedBlocklyNavigation(String value) { 'https://appassets.androidplatform.net/assets/assets/blockly/index.html'; } +/// Whether a main-frame start is an authored Blockly document generation. +@visibleForTesting +bool isBlocklyAssetDocumentNavigation(String value) => + value != 'about:blank' && isAllowedBlocklyNavigation(value); + /// Stale retained revisions are not startup success: the restore handshake must /// still publish a newer snapshot before the watchdog can stop. @visibleForTesting @@ -133,76 +297,126 @@ class BlocklyWebView extends ConsumerStatefulWidget { class _BlocklyWebViewState extends ConsumerState { late final WebViewController _controller; + late final Completer _startupLoadAllowed; + late final Future _startupFuture; late final BlocksDocumentController _documentController; - late final int _hostId; + late int _hostId; + final BlocklyHostStartupGate _startupGate = BlocklyHostStartupGate(); Timer? _firstMessageTimer; + bool _assetPageStarted = false; + bool _startupCancelled = false; @override void initState() { super.initState(); _documentController = ref.read(blocksDocumentProvider.notifier); - _controller = WebViewController() - ..setJavaScriptMode(JavaScriptMode.unrestricted) - ..addJavaScriptChannel( + _controller = WebViewController(); + _startupLoadAllowed = Completer(); + _hostId = _beginDocumentHost(); + final int bootstrapHostId = _hostId; + final Future controllerSetup = configureBlocklyControllerSequentially( + isCancelled: () => _startupCancelled, + enableJavaScript: () => + _controller.setJavaScriptMode(JavaScriptMode.unrestricted), + installJavaScriptChannel: () => _controller.addJavaScriptChannel( _channelName, onMessageReceived: (JavaScriptMessage message) { if (!mounted) return; - final BlocksBridgeResult result = _documentController - .receiveBridgeMessage(message.message, hostId: _hostId); - if (shouldCancelBlocksStartupWatchdog(result)) { - _firstMessageTimer?.cancel(); - } + dispatchBlocklyHostChannelMessage( + gate: _startupGate, + message: message.message, + initialise: () { + final int hostId = _hostId; + final int generation = _startupGate.generation; + unawaited(_initialiseHost(hostId, generation)); + }, + forward: (String bridgeMessage) { + final int hostId = _hostId; + final BlocksBridgeResult result = _documentController + .receiveBridgeMessage(bridgeMessage, hostId: hostId); + if (shouldCancelBlocksStartupWatchdog(result)) { + _firstMessageTimer?.cancel(); + } + }, + reject: () { + final int hostId = _hostId; + _firstMessageTimer?.cancel(); + _documentController.reportHostError( + 'Blockly bridge message has an invalid host epoch', + hostId: hostId, + ); + }, + ); }, - ) - ..setNavigationDelegate( + ), + installNavigationDelegate: () => _controller.setNavigationDelegate( NavigationDelegate( - onNavigationRequest: (NavigationRequest request) { - return isAllowedBlocklyNavigation(request.url) - ? NavigationDecision.navigate - : NavigationDecision.prevent; - }, - onPageFinished: (String url) { - if (!mounted || - url == 'about:blank' || - !isAllowedBlocklyNavigation(url)) { - return; - } - unawaited(_initialiseHost()); - }, - onWebResourceError: (WebResourceError error) { - if (!mounted) return; - if (error.isForMainFrame == false) return; - _firstMessageTimer?.cancel(); - _documentController.reportHostError( - error.description, - hostId: _hostId, - ); - }, + onPageStarted: _onPageStarted, + onNavigationRequest: (NavigationRequest request) => + isAllowedBlocklyNavigation(request.url) + ? NavigationDecision.navigate + : NavigationDecision.prevent, ), - ); - _hostId = _documentController.beginHost( - previewExample: _previewExample, - requestSnapshot: _requestFreshSnapshot, + ), ); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - _documentController.markHostLoading(_hostId); - _firstMessageTimer = Timer(_firstSnapshotTimeout, () { - if (!mounted) return; + bool startupIsCancelled() => + _startupCancelled || !mounted || bootstrapHostId != _hostId; + _startupFuture = runBlocklyStartupGuarded( + startup: () => loadBlocklyAssetAfterControllerSetup( + setup: controllerSetup, + waitUntilLoadAllowed: () => _startupLoadAllowed.future, + isCancelled: startupIsCancelled, + load: () => _controller.loadFlutterAsset(_assetPath), + ), + waitUntilFailureCanBeReported: () => _startupLoadAllowed.future, + isCancelled: startupIsCancelled, + reportFailure: (Object error) { + _firstMessageTimer?.cancel(); _documentController.reportHostError( - AppLocalizations.of(context).blocksStartupTimeout, - hostId: _hostId, + error.toString(), + hostId: bootstrapHostId, ); - }); - unawaited( - _controller.loadFlutterAsset(_assetPath).catchError((Object error) { - _firstMessageTimer?.cancel(); - if (!mounted) return; - _documentController.reportHostError( - error.toString(), - hostId: _hostId, - ); - }), + }, + ); + unawaited(_startupFuture); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + if (!_startupLoadAllowed.isCompleted) _startupLoadAllowed.complete(); + return; + } + _documentController.markHostLoading(_hostId); + _armFirstSnapshotWatchdog(); + if (!_startupLoadAllowed.isCompleted) _startupLoadAllowed.complete(); + }); + } + + int _beginDocumentHost() => _documentController.beginHost( + previewExample: _previewExample, + requestSnapshot: _requestFreshSnapshot, + ); + + void _onPageStarted(String url) { + if (!mounted || !isBlocklyAssetDocumentNavigation(url)) return; + if (_assetPageStarted) { + _hostId = _beginDocumentHost(); + } else { + _assetPageStarted = true; + } + _startupGate.beginPage(hostEpoch: _hostId); + _documentController.markHostLoading(_hostId); + _armFirstSnapshotWatchdog(); + } + + void _armFirstSnapshotWatchdog() { + _firstMessageTimer?.cancel(); + final int hostId = _hostId; + final int generation = _startupGate.generation; + _firstMessageTimer = Timer(_firstSnapshotTimeout, () { + if (!mounted || hostId != _hostId) return; + if (_startupGate.generation != generation) return; + _documentController.reportHostError( + AppLocalizations.of(context).blocksStartupTimeout, + hostId: hostId, ); }); } @@ -218,8 +432,12 @@ class _BlocklyWebViewState extends ConsumerState { return decodeBlocksExamplePreviewResult(result); } - Future _initialiseHost() async { - if (!mounted) return; + Future _initialiseHost(int hostId, int generation) async { + if (!mounted || + hostId != _hostId || + !_startupGate.isCurrentGeneration(generation)) { + return; + } final BlocksDocument document = ref.read(blocksDocumentProvider); final String? retainedWorkspaceJson = document.retainedWorkspaceJson; final int? retainedWorkspaceRevision = document.retainedWorkspaceRevision; @@ -242,20 +460,39 @@ class _BlocklyWebViewState extends ConsumerState { '$retainedWorkspaceRevision'; final Object configured = await _controller.runJavaScriptReturningResult( 'window.pybleBlocks.configureHost(' - '$messages$retainedArguments);', + '$messages, $hostId$retainedArguments);', ); + if (!mounted || + hostId != _hostId || + !_startupGate.isCurrentGeneration(generation)) { + return; + } if (!_javascriptTrue(configured)) { throw const FormatException('Blockly rejected localized host messages'); } + _startupGate.finishInitialisation(generation, succeeded: true); } catch (error) { - _firstMessageTimer?.cancel(); - if (!mounted) return; - _documentController.reportHostError(error.toString(), hostId: _hostId); + if (!mounted || + hostId != _hostId || + !_startupGate.isCurrentGeneration(generation)) { + return; + } + final bool retry = _startupGate.finishInitialisation( + generation, + succeeded: false, + ); + if (retry) { + unawaited(_initialiseHost(hostId, generation)); + return; + } + _documentController.reportHostError(error.toString(), hostId: hostId); } } @override void dispose() { + _startupCancelled = true; + if (!_startupLoadAllowed.isCompleted) _startupLoadAllowed.complete(); _firstMessageTimer?.cancel(); _documentController.endHost(_hostId); super.dispose(); diff --git a/app/lib/blocks/blocks_document.dart b/app/lib/blocks/blocks_document.dart index 103a471..a94058d 100644 --- a/app/lib/blocks/blocks_document.dart +++ b/app/lib/blocks/blocks_document.dart @@ -364,6 +364,7 @@ class BlocksDocumentController extends Notifier { /// Makes retained source non-actionable until [hostId] publishes a snapshot. void markHostLoading(int hostId) { if (_activeHostId != hostId) return; + _activeHostReady = false; state = state.copyWith( status: BlocksStatus.loading, error: null, diff --git a/app/lib/blocks/blocks_examples.dart b/app/lib/blocks/blocks_examples.dart index 8c78d3b..59a6e0d 100644 --- a/app/lib/blocks/blocks_examples.dart +++ b/app/lib/blocks/blocks_examples.dart @@ -475,6 +475,9 @@ bool isSemanticallyEmptyBlocksWorkspace(String? workspaceJson) { try { final Object? decoded = jsonDecode(workspaceJson); if (decoded is! Map) return false; + // Blockly omits every empty serializer section and emits `{}` for a + // canonical workspace with no blocks, variables, or other state. + if (decoded.isEmpty) return true; final Object? rawBlocks = decoded['blocks']; if (rawBlocks is! Map) return false; final Object? blocks = rawBlocks['blocks']; diff --git a/app/lib/blocks/blocks_examples_view.dart b/app/lib/blocks/blocks_examples_view.dart index f864384..87d6e91 100644 --- a/app/lib/blocks/blocks_examples_view.dart +++ b/app/lib/blocks/blocks_examples_view.dart @@ -4,8 +4,6 @@ /// Native, localized chooser for the bundled beginner Blockly examples. library; -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -160,6 +158,7 @@ class _BlocksExamplesDialogState extends State<_BlocksExamplesDialog> { late BlocksExampleTemplate _selected; final Map _gpioControllers = {}; + final Map _gpioFieldKeys = {}; BlocksExamplePreview? _preview; Object? _previewError; bool _generating = false; @@ -173,11 +172,6 @@ class _BlocksExamplesDialogState extends State<_BlocksExamplesDialog> { ? widget.catalog.byId(initialId) : widget.catalog.examples.first; _resetGpioControllers(); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && !_selected.requiresGpio) { - unawaited(_generatePreview()); - } - }); } @override @@ -203,6 +197,16 @@ class _BlocksExamplesDialogState extends State<_BlocksExamplesDialog> { ), ), ); + _gpioFieldKeys + ..clear() + ..addEntries( + _selected.gpioRoles.map( + (BlocksExampleGpioRole role) => MapEntry( + role.role, + GlobalKey(debugLabel: 'blocks-example-${role.role}-gpio'), + ), + ), + ); } Map? get _gpioValues { @@ -253,7 +257,6 @@ class _BlocksExamplesDialogState extends State<_BlocksExamplesDialog> { _generating = false; _resetGpioControllers(); }); - if (!example.requiresGpio) unawaited(_generatePreview()); } void _onGpioChanged() { @@ -263,7 +266,6 @@ class _BlocksExamplesDialogState extends State<_BlocksExamplesDialog> { _previewError = null; _generating = false; }); - if (_gpioValues != null) unawaited(_generatePreview()); } Future _generatePreview() async { @@ -555,6 +557,7 @@ class _BlocksExamplesDialogState extends State<_BlocksExamplesDialog> { (BlocksExampleGpioRole role) => Padding( padding: const EdgeInsets.only(bottom: SignalSpacing.md), child: TextField( + key: _gpioFieldKeys[role.role], controller: _gpioControllers[role.role], keyboardType: TextInputType.number, textInputAction: TextInputAction.next, @@ -659,7 +662,7 @@ class _BlocksExamplesDialogState extends State<_BlocksExamplesDialog> { if (preview == null) { return _PreviewPlaceholder( icon: Icons.code, - message: l10n.blocksExamplesPreviewLoading, + message: l10n.blocksExamplesPreviewIdle, ); } return Semantics( diff --git a/app/lib/localization/arb/app_en.arb b/app/lib/localization/arb/app_en.arb index b5f37db..0e130ee 100644 --- a/app/lib/localization/arb/app_en.arb +++ b/app/lib/localization/arb/app_en.arb @@ -551,10 +551,14 @@ } } }, - "blocksExamplesPreviewWaiting": "Generated Python appears after every required GPIO is entered.", + "blocksExamplesPreviewWaiting": "Enter every required GPIO to enable generation.", "@blocksExamplesPreviewWaiting": { "description": "Placeholder in the example source pane while a GPIO role is missing or invalid." }, + "blocksExamplesPreviewIdle": "Choose an action below to generate Python.", + "@blocksExamplesPreviewIdle": { + "description": "Truthful idle placeholder before Preview, Create copy, or Replace workspace explicitly starts generation." + }, "blocksExamplesPreviewLoading": "Generating Python…", "@blocksExamplesPreviewLoading": { "description": "Progress text while the production Blockly generator prepares an example preview." diff --git a/app/lib/localization/gen/app_localizations.dart b/app/lib/localization/gen/app_localizations.dart index 91e97ed..a3e1d6a 100644 --- a/app/lib/localization/gen/app_localizations.dart +++ b/app/lib/localization/gen/app_localizations.dart @@ -751,9 +751,15 @@ abstract class AppLocalizations { /// Placeholder in the example source pane while a GPIO role is missing or invalid. /// /// In en, this message translates to: - /// **'Generated Python appears after every required GPIO is entered.'** + /// **'Enter every required GPIO to enable generation.'** String get blocksExamplesPreviewWaiting; + /// Truthful idle placeholder before Preview, Create copy, or Replace workspace explicitly starts generation. + /// + /// In en, this message translates to: + /// **'Choose an action below to generate Python.'** + String get blocksExamplesPreviewIdle; + /// Progress text while the production Blockly generator prepares an example preview. /// /// In en, this message translates to: diff --git a/app/lib/localization/gen/app_localizations_en.dart b/app/lib/localization/gen/app_localizations_en.dart index 46142e7..744caa9 100644 --- a/app/lib/localization/gen/app_localizations_en.dart +++ b/app/lib/localization/gen/app_localizations_en.dart @@ -394,7 +394,11 @@ class AppLocalizationsEn extends AppLocalizations { @override String get blocksExamplesPreviewWaiting => - 'Generated Python appears after every required GPIO is entered.'; + 'Enter every required GPIO to enable generation.'; + + @override + String get blocksExamplesPreviewIdle => + 'Choose an action below to generate Python.'; @override String get blocksExamplesPreviewLoading => 'Generating Python…'; From 6760d3d48e5d3bbf78c35017f6b1636f62a2f971 Mon Sep 17 00:00:00 2001 From: Viwat Vchirawongkwin Date: Fri, 7 Aug 2026 16:41:34 +0700 Subject: [PATCH 3/4] [red] Guard IME-occluded preview actions Signed-off-by: Viwat Vchirawongkwin --- tests/publication/test_ci_contract.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/tests/publication/test_ci_contract.py b/tests/publication/test_ci_contract.py index e468364..9978e08 100644 --- a/tests/publication/test_ci_contract.py +++ b/tests/publication/test_ci_contract.py @@ -332,11 +332,31 @@ def test_android_integration_uses_full_aosp_without_gms(self) -> None: android, ) - def test_android_blockly_smoke_reveals_ime_occluded_action(self) -> None: + def test_android_blockly_smoke_reveals_ime_occluded_actions(self) -> None: suite = ( REPO_ROOT / "app" / "integration_test" / "blockly_webview_suite.dart" ).read_text(encoding="utf-8") + tap_visible_start = suite.index("Future _tapVisible(") + tap_visible_end = suite.index("\n}\n", tap_visible_start) + tap_visible = suite[tap_visible_start:tap_visible_end] + preview_snippets = ( + "FocusManager.instance.primaryFocus?.unfocus();", + "SystemChannels.textInput.invokeMethod('TextInput.hide');", + "await tester.ensureVisible(finder);", + "expect(finder.hitTestable(), findsOneWidget);", + "await tester.tap(finder);", + ) + preview_positions = [] + for snippet in preview_snippets: + self.assertIn( + snippet, + tap_visible, + f"missing Android preview interaction contract: {snippet}", + ) + preview_positions.append(tap_visible.index(snippet)) + self.assertEqual(preview_positions, sorted(preview_positions)) + enter_gpio = suite.index( "await tester.enterText(" "find.widgetWithText(TextField, 'LED GPIO'), '17');" From 1e256b01c8545640ba63c7f01fa3fdaf09a31a18 Mon Sep 17 00:00:00 2001 From: Viwat Vchirawongkwin Date: Fri, 7 Aug 2026 16:43:59 +0700 Subject: [PATCH 4/4] [green] Stabilize Android preview interaction Signed-off-by: Viwat Vchirawongkwin --- app/integration_test/blockly_webview_suite.dart | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/integration_test/blockly_webview_suite.dart b/app/integration_test/blockly_webview_suite.dart index ab1efd7..10629da 100644 --- a/app/integration_test/blockly_webview_suite.dart +++ b/app/integration_test/blockly_webview_suite.dart @@ -283,8 +283,15 @@ Future _pumpUntil( } Future _tapVisible(WidgetTester tester, Finder finder) async { + // A real Android IME can keep the footer below the resized render surface + // even after ensureVisible scrolls its RenderBox. Commit the field edit and + // restore the full viewport before resolving the action's tap position. + FocusManager.instance.primaryFocus?.unfocus(); + await SystemChannels.textInput.invokeMethod('TextInput.hide'); + await tester.pumpAndSettle(); await tester.ensureVisible(finder); await tester.pumpAndSettle(); + expect(finder.hitTestable(), findsOneWidget); await tester.tap(finder); }