diff --git a/TODOS.md b/TODOS.md index 3144521..1f4af7d 100644 --- a/TODOS.md +++ b/TODOS.md @@ -99,10 +99,10 @@ PR4 decisions (`sharing`: `encodeEncounterUri` / `decodeEncounterUri`, `ContactC - Links over 2000 characters are refused on encode and decode. Encode throws rather than silently dropping the contact card. A malformed contact card on receive is dropped without rejecting the identity. - The legacy `amiro://share` link is untouched; the scanner should try encounter first, then legacy, until the app is migrated. -PR5 decisions (`sharing`: `NdefRecordData`, `decodeEncounterFromNdef`, `ingestNfcReads`, library only): +PR5 decisions (`sharing`: `NdefRecordData`, NDEF text/URI decode + `amiroLinkTextFromNdef`, library only): - The boundary is plugin-independent bytes in, validated `EncounterQr` out. The `nfc` package should convert its platform records to `NdefRecordData` when wired. - **Existing `nfc` reader bugs the adapter fixes (not yet migrated):** `ManagerNfcReader` reads only the first record (an Android Application Record first would hide the card) and decodes text with `String.fromCharCodes` (Latin-1, and it ignores the UTF-16 flag). The adapter scans all records, decodes strict UTF-8/UTF-16, and skips URI records with abbreviation prefixes. -- `ingestNfcReads`: reads become `NfcEncounterRead` or `NfcReadRejected`; a hardware dropout (stream error) becomes a rejection instead of an exception and the stream survives; identical reads within a 3 s window are reported once (continuous contact refreshes the window). +- ~~`ingestNfcReads`~~ removed (2026-10-23): never wired in the app; receive screen handles one read per session. - Remaining before NFC works end to end: wire `ManagerNfcReader` into this adapter and have the emulator write the encounter link (`encodeNdefTextPayload`), both in the app-wiring PRs. PR6 decisions (Identity Card, `app/lib/sharing/`): @@ -114,7 +114,7 @@ PR6 decisions (Identity Card, `app/lib/sharing/`): PR7-9 decisions (shipped together as one PR; taken autonomously while the owner was away, so worth a review): - **Cutover.** The Share screen now emits `amiro://encounter` for both QR and NFC. Every receive path (QR scan, NFC receive, deep link) resolves through one `resolveScannedText` -> `ScanResult`, so encounter links open the comparison screen and **legacy `amiro://share` links from older builds still open the profile** (no passport entry, since they carry no collection). Android's manifest gained the `encounter` host (iOS already covers the whole scheme). -- **NFC.** `ManagerNfcReader` now uses the PR5 adapter (all records, strict UTF-8/UTF-16), fixing the first-record-only and Latin-1 bugs; `decodeNdefTextPayload` and its test were removed as superseded. `nfc` now depends on `sharing`. The native HCE tag (2 KB, UTF-8 text record) fits a 2000-char encounter link. **Not exercised on real hardware.** `ingestNfcReads` (dedupe/dropout) is not used by the app: the receive screen already handles one read per session and errors itself. +- **NFC.** `ManagerNfcReader` now uses the PR5 adapter (all records, strict UTF-8/UTF-16), fixing the first-record-only and Latin-1 bugs; `decodeNdefTextPayload` and its test were removed as superseded. `nfc` now depends on `sharing`. The native HCE tag (2 KB, UTF-8 text record) fits a 2000-char encounter link. **Not exercised on real hardware.** - **Encounter screen** is two-phase: showing a card writes nothing; "Save to passport" / "Update passport" commits via `EncounterProcessor.save`. Backing out changes nothing, including a known person's count. Your own card is `SelfEncounter`. The screen waits for the identity to load first so a cold-start deep link can't miss the own-card check. - **Only their avatar is 3D** on the comparison screen; yours is a text summary. Two live Filament views side by side is unverified on a device. - **Contact fallback.** If the contact card is what pushes the link past the QR size limit, `buildOwnEncounterUri` drops it and shares the identity alone rather than failing to share (the library's `encodeEncounterUri` still throws; only the app chooses to fall back). diff --git a/packages/avatar_renderer/lib/src/avatar_body_pose.dart b/packages/avatar_renderer/lib/src/avatar_body_pose.dart index 6012a69..2c07f8e 100644 --- a/packages/avatar_renderer/lib/src/avatar_body_pose.dart +++ b/packages/avatar_renderer/lib/src/avatar_body_pose.dart @@ -16,13 +16,6 @@ bool shouldApplySkinnedHumanoidProceduralMotion(String assetPath) { base.startsWith('shoes_'); } -/// Whether [assetPath] points at a skinned humanoid body that ships in bind -/// (T) pose with no embedded idle animation. -@Deprecated('Use shouldApplySkinnedHumanoidProceduralMotion') -bool shouldApplyRelaxedArmPose(String assetPath) { - return shouldApplySkinnedHumanoidProceduralMotion(assetPath); -} - /// Bone names on the Quaternius Superhero / Mixamo-compatible skeleton shipped /// as [body_superhero_male.glb]. const relaxedArmPoseBones = ['upperarm_l', 'upperarm_r']; @@ -49,29 +42,6 @@ Quaternion _boneLocalRotation(Vector3 axis, double radians) { /// vector_math 2.2+ — use operator `*` (mutable [Quaternion.multiply] removed). Quaternion _combineBoneRotations(Quaternion a, Quaternion b) => a * b; -/// Builds a single-frame skeletal hold that drops the upper arms from the glTF -/// bind (T) pose. Rotations are in [Space.Bone] — deltas applied on top of -/// each bone's rest local transform inside thermion's [addBoneAnimation]. -/// -/// Sign convention verified against `body_superhero_male.glb` on device (PR #35): -/// **left −π/2**, **right +π/2** about bone-local +Z — the opposite pairing -/// swings arms toward world +Y (overhead). -thermion.BoneAnimationData buildRelaxedArmPoseAnimation({ - double armDownRadians = math.pi / 2, -}) { - final frame = confidentFashionArmPoseFrame( - armDownRadians: armDownRadians, - ); - return thermion.BoneAnimationData( - relaxedArmPoseBones, - [ - [frame.left, frame.right], - ], - frameLengthInMs: 60000, - space: thermion.Space.Bone, - ); -} - /// Boutique idle arm hold: **only** the device-verified upper-arm Z twist. /// /// Extra X/Y deltas on `upperarm_*` stacked a second limb on front camera @@ -89,14 +59,6 @@ thermion.BoneAnimationData buildRelaxedArmPoseAnimation({ ); } -/// Arm-space deltas reused in every frame of the presentation loop. -@Deprecated('Use confidentFashionArmPoseFrame') -({thermion.Transform left, thermion.Transform right}) relaxedArmPoseFrame({ - double armDownRadians = math.pi / 2, -}) { - return confidentFashionArmPoseFrame(armDownRadians: armDownRadians); -} - /// Static contrapposto / open-chest offsets layered under breath and sway. thermion.SkeletonTransform confidentFashionTorsoFrame({ required double breath, @@ -165,28 +127,6 @@ thermion.BoneAnimationData buildSkinnedHumanoidPresentationAnimation({ ); } -/// Procedural "living statue" loop (idle bones only). Prefer -/// [buildSkinnedHumanoidPresentationAnimation] at runtime. -thermion.BoneAnimationData buildLivingStatueIdleAnimation({ - int numFrames = 120, - double loopDurationSeconds = 5.5, -}) { - final full = buildSkinnedHumanoidPresentationAnimation( - numFrames: numFrames, - loopDurationSeconds: loopDurationSeconds, - ); - return thermion.BoneAnimationData( - livingStatueIdleBones, - full.frameData - .map( - (frame) => frame.sublist(relaxedArmPoseBones.length), - ) - .toList(), - frameLengthInMs: full.frameLengthInMs, - space: thermion.Space.Bone, - ); -} - /// Applies relaxed arms plus looping idle motion to a loaded skinned asset. Future applySkinnedHumanoidProceduralMotion( thermion.ThermionAsset asset, @@ -203,8 +143,3 @@ Future applySkinnedHumanoidProceduralMotion( loop: true, ); } - -@Deprecated('Use applySkinnedHumanoidProceduralMotion') -Future applyRelaxedArmPose(thermion.ThermionAsset asset) async { - await applySkinnedHumanoidProceduralMotion(asset); -} diff --git a/packages/avatar_renderer/test/avatar_body_pose_test.dart b/packages/avatar_renderer/test/avatar_body_pose_test.dart index 2c3a1ac..ecefaae 100644 --- a/packages/avatar_renderer/test/avatar_body_pose_test.dart +++ b/packages/avatar_renderer/test/avatar_body_pose_test.dart @@ -2,7 +2,6 @@ import 'dart:math' as math; import 'package:avatar_renderer/src/avatar_body_pose.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:thermion_flutter/thermion_flutter.dart' as thermion; void main() { group('shouldApplySkinnedHumanoidProceduralMotion', () { @@ -49,24 +48,6 @@ void main() { }); }); - group('buildRelaxedArmPoseAnimation', () { - test('uses device-verified −Z/+Z twist pairing for left/right arms', () { - final animation = buildRelaxedArmPoseAnimation( - armDownRadians: math.pi / 2, - ); - - expect(animation.bones, relaxedArmPoseBones); - expect(animation.space, thermion.Space.Bone); - expect(animation.numFrames, 1); - - final frame = animation.frameData.single; - final left = frame[0].rotation; - final right = frame[1].rotation; - expect(left, isNot(equals(right))); - expect(left, equals(confidentFashionArmPoseFrame().left.rotation)); - }); - }); - group('buildSkinnedHumanoidPresentationAnimation', () { test('includes arm hold on every idle frame', () { final animation = buildSkinnedHumanoidPresentationAnimation( @@ -87,6 +68,10 @@ void main() { final hold = confidentFashionArmPoseFrame(armDownRadians: math.pi / 2); expect(hold.left.rotation.z, lessThan(0)); expect(hold.right.rotation.z, greaterThan(0)); + expect( + hold.left.rotation, + isNot(equals(hold.right.rotation)), + ); }); test('loop wrap has only a tiny discontinuity at frame 0', () { @@ -103,12 +88,4 @@ void main() { } }); }); - - group('buildLivingStatueIdleAnimation', () { - test('targets spine and pelvis only', () { - final animation = buildLivingStatueIdleAnimation(numFrames: 60); - expect(animation.bones, livingStatueIdleBones); - expect(animation.frameData.first.length, livingStatueIdleBones.length); - }); - }); } diff --git a/packages/sharing/lib/src/ndef_encounter.dart b/packages/sharing/lib/src/ndef_encounter.dart index 4d6d139..50fbaa7 100644 --- a/packages/sharing/lib/src/ndef_encounter.dart +++ b/packages/sharing/lib/src/ndef_encounter.dart @@ -1,10 +1,6 @@ -import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; -import 'amiro_sharing_payload.dart'; -import 'encounter_qr.dart'; - /// NDEF type-name-format for well-known records (text `T`, URI `U`). const ndefTnfWellKnown = 0x01; @@ -12,8 +8,6 @@ const ndefTnfWellKnown = 0x01; /// capped well below this; anything bigger is not an Amiro card. const maxNdefRecordBytes = 4096; -const _kEncounterPrefix = 'amiro://encounter'; - /// One NDEF record as raw bytes, independent of any NFC plugin. The `nfc` /// package converts its platform records into this; nothing here knows how /// they were read. @@ -86,19 +80,6 @@ String _utf16(List bytes) { ]); } -/// The first record that carries an `amiro://encounter` link, skipping -/// anything else on the tag (an Android Application Record, say). The first -/// such record decides: a bad one is not papered over by a later good one. -/// Throws [SharingPayloadException] (malformed) if there is none. -String encounterTextFromNdef(List records) { - for (final record in records) { - final text = ndefRecordText(record); - if (text != null && text.startsWith(_kEncounterPrefix)) return text; - } - throw const SharingPayloadException( - PayloadErrorKind.malformed, 'no Amiro encounter record on the tag'); -} - /// The first record on the tag whose text is any `amiro://` link, encounter /// or legacy share, or null. For callers that hand the text on to a resolver /// that understands both formats. @@ -109,83 +90,3 @@ String? amiroLinkTextFromNdef(List records) { } return null; } - -/// NFC boundary: tag records in, validated encounter out. Throws -/// [SharingPayloadException] for anything that isn't a valid card. -EncounterQr decodeEncounterFromNdef(List records) { - return decodeEncounterUri(encounterTextFromNdef(records)); -} - -/// What a tag read turned into. Downstream code sees only these two: never -/// raw bytes, partial data or an exception. -sealed class NfcRead { - const NfcRead(); -} - -final class NfcEncounterRead extends NfcRead { - final EncounterQr encounter; - - const NfcEncounterRead(this.encounter); -} - -final class NfcReadRejected extends NfcRead { - final SharingPayloadException error; - - const NfcReadRejected(this.error); -} - -/// Adapts a stream of raw tag reads into [NfcRead]s. -/// -/// - Every read is validated; invalid ones become [NfcReadRejected]. -/// - A hardware dropout (an error on [source]) becomes a rejection and the -/// stream keeps going, so a flaky connection can't crash or wedge callers. -/// - A tag held against the phone reads many times; an identical read -/// inside [duplicateWindow] of the previous one is dropped. Continuous -/// contact keeps refreshing the window, so it is reported once. -Stream ingestNfcReads( - Stream> source, { - DateTime Function()? clock, - Duration duplicateWindow = const Duration(seconds: 3), -}) { - final now = clock ?? DateTime.now; - final lastSeen = {}; - - void emit(EventSink sink, String key, NfcRead read) { - final at = now(); - lastSeen - .removeWhere((_, seenAt) => at.difference(seenAt) >= duplicateWindow); - final previous = lastSeen[key]; - lastSeen[key] = at; - if (previous == null) sink.add(read); - } - - return source.transform( - StreamTransformer, NfcRead>.fromHandlers( - handleData: (records, sink) { - String? key; - try { - final text = encounterTextFromNdef(records); - key = 'link:$text'; - emit(sink, key, NfcEncounterRead(decodeEncounterUri(text))); - } on SharingPayloadException catch (e) { - emit(sink, key ?? 'rejected:${e.message}', NfcReadRejected(e)); - } catch (_) { - emit( - sink, - 'rejected:unreadable', - const NfcReadRejected(SharingPayloadException( - PayloadErrorKind.malformed, 'the tag could not be read')), - ); - } - }, - handleError: (error, stackTrace, sink) { - emit( - sink, - 'dropout', - const NfcReadRejected(SharingPayloadException( - PayloadErrorKind.malformed, 'the NFC read was interrupted')), - ); - }, - ), - ); -} diff --git a/packages/sharing/test/ndef_encounter_test.dart b/packages/sharing/test/ndef_encounter_test.dart index c7caad8..0cf1b9f 100644 --- a/packages/sharing/test/ndef_encounter_test.dart +++ b/packages/sharing/test/ndef_encounter_test.dart @@ -1,4 +1,3 @@ -import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; @@ -157,63 +156,6 @@ void main() { }); }); - group('decodeEncounterFromNdef', () { - test('a text record round-trips payload and contact card', () { - final decoded = decodeEncounterFromNdef([_text(_link)]); - - expect(decoded.payload, _payload()); - expect(decoded.contact, const ContactCard(username: 'uday')); - }); - - test('a URI record round-trips too', () { - expect(decodeEncounterFromNdef([_uri(_link)]).payload, _payload()); - }); - - test('finds the encounter behind an Android Application Record', () { - expect( - decodeEncounterFromNdef([_androidAppRecord(), _text(_link)]).payload, - _payload()); - }); - - test('no records, or none that is an encounter, is malformed', () { - for (final records in [ - [], - [_androidAppRecord()], - [_text('just some text')], - [ - _text('amiro://share?d=abc') - ], // a legacy share link is not an encounter - ]) { - expect( - () => decodeEncounterFromNdef(records), - throwsA(isA() - .having((e) => e.kind, 'kind', PayloadErrorKind.malformed)), - ); - } - }); - - test( - 'an encounter record with a bad payload is rejected with the payload error', - () { - final future = - 'amiro://encounter?d=${base64Url.encode(utf8.encode('{"schemaVersion":2}')).replaceAll('=', '')}'; - - expect( - () => decodeEncounterFromNdef([_text(future)]), - throwsA(isA().having( - (e) => e.kind, 'kind', PayloadErrorKind.unsupportedVersion)), - ); - }); - - test('an invalid encounter record is not papered over by a later valid one', - () { - final bad = 'amiro://encounter?d=***'; - - expect(() => decodeEncounterFromNdef([_text(bad), _text(_link)]), - throwsA(isA())); - }); - }); - group('encodeNdefTextPayload', () { test('writes a status byte, the language code, then UTF-8 text', () { expect(encodeNdefTextPayload('hi'), @@ -221,100 +163,6 @@ void main() { }); }); - group('ingestNfcReads', () { - late StreamController> source; - late DateTime now; - late Stream reads; - late List seen; - - setUp(() { - source = StreamController>(); - now = DateTime.utc(2026, 9, 26, 10); - reads = ingestNfcReads(source.stream, - clock: () => now, duplicateWindow: const Duration(seconds: 3)); - seen = []; - reads.listen(seen.add); - }); - - tearDown(() => source.close()); - - Future tick() => Future.delayed(Duration.zero); - - test('a valid tag becomes an NfcEncounterRead', () async { - source.add([_text(_link)]); - await tick(); - - expect(seen.single, isA()); - expect((seen.single as NfcEncounterRead).encounter.payload, _payload()); - }); - - test('garbage becomes a rejection, never an exception', () async { - source.add([_text('hello')]); - await tick(); - - expect(seen.single, isA()); - }); - - test('a hardware dropout becomes a rejection and the stream keeps working', - () async { - source.addError(StateError('tag lost')); - await tick(); - now = now.add(const Duration(seconds: 10)); - source.add([_text(_link)]); - await tick(); - - expect(seen.first, isA()); - expect(seen.last, isA()); - expect(seen, hasLength(2)); - }); - - test('the same tag read repeatedly inside the window is reported once', - () async { - for (var i = 0; i < 4; i++) { - source.add([_text(_link)]); - now = now.add(const Duration(milliseconds: 400)); - await tick(); - } - - expect(seen, hasLength(1)); - }); - - test('the same tag again after the window is reported again', () async { - source.add([_text(_link)]); - await tick(); - now = now.add(const Duration(seconds: 4)); - source.add([_text(_link)]); - await tick(); - - expect(seen, hasLength(2)); - }); - - test('two different tags close together are both reported', () async { - source.add([_text(_link)]); - source.add([_text(encodeEncounterUri(_payload(name: 'Ada')))]); - await tick(); - - expect(seen, hasLength(2)); - }); - - test('repeated identical garbage inside the window is one rejection', - () async { - source.add([_text('hello')]); - source.add([_text('hello')]); - await tick(); - - expect(seen, hasLength(1)); - }); - - test('closing the source closes the stream', () async { - final done = Completer(); - ingestNfcReads(Stream>.empty()) - .listen(null, onDone: done.complete); - - await done.future; - }); - }); - group('amiroLinkTextFromNdef', () { test('finds an encounter link, a legacy share link, or nothing', () { expect(amiroLinkTextFromNdef([_text(_link)]), _link);