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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions TODOS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`):
Expand All @@ -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).
Expand Down
65 changes: 0 additions & 65 deletions packages/avatar_renderer/lib/src/avatar_body_pose.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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<void> applySkinnedHumanoidProceduralMotion(
thermion.ThermionAsset asset,
Expand All @@ -203,8 +143,3 @@ Future<void> applySkinnedHumanoidProceduralMotion(
loop: true,
);
}

@Deprecated('Use applySkinnedHumanoidProceduralMotion')
Future<void> applyRelaxedArmPose(thermion.ThermionAsset asset) async {
await applySkinnedHumanoidProceduralMotion(asset);
}
31 changes: 4 additions & 27 deletions packages/avatar_renderer/test/avatar_body_pose_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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', () {
Expand Down Expand Up @@ -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(
Expand All @@ -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', () {
Expand All @@ -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);
});
});
}
99 changes: 0 additions & 99 deletions packages/sharing/lib/src/ndef_encounter.dart
Original file line number Diff line number Diff line change
@@ -1,19 +1,13 @@
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;

/// Largest NDEF record payload considered, in bytes. Encounter links are
/// 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.
Expand Down Expand Up @@ -86,19 +80,6 @@ String _utf16(List<int> 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<NdefRecordData> 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.
Expand All @@ -109,83 +90,3 @@ String? amiroLinkTextFromNdef(List<NdefRecordData> records) {
}
return null;
}

/// NFC boundary: tag records in, validated encounter out. Throws
/// [SharingPayloadException] for anything that isn't a valid card.
EncounterQr decodeEncounterFromNdef(List<NdefRecordData> 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<NfcRead> ingestNfcReads(
Stream<List<NdefRecordData>> source, {
DateTime Function()? clock,
Duration duplicateWindow = const Duration(seconds: 3),
}) {
final now = clock ?? DateTime.now;
final lastSeen = <String, DateTime>{};

void emit(EventSink<NfcRead> 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<List<NdefRecordData>, 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')),
);
},
),
);
}
Loading
Loading