mirror of
https://github.com/block/buzz.git
synced 2026-11-05 00:41:28 +00:00
With accessibility semantics active, opening and leaving channels
retained disposed composer overlay states through Flutter’s semantics
traversal map. Those states kept departed route and message trees alive.
This upgrades the pinned Flutter SDK from 3.41.7 to 3.47.7, which
includes [the upstream
fix](3c96406cc4).
Includes SDK compatibility and formatter changes, preserves CocoaPods
integration, and gives create-channel radio lists a Material ink surface
required by the newer framework. Widget regressions verify both the
framework portal and the production channel/composer lifecycle: departed
routes, composer elements, and portal states are collectible while their
application’s semantics owner remains alive.
Related issue: no matching open issue or PR found.
### Validation
- [Current-head CI
passed](https://github.com/block/buzz/actions/runs/39085320351): all
mobile test shards, checks, Android APK, iOS release build, and native
simulator tests.
- Framework regression fails on Flutter 3.41.7 with three retained
states and passes on 3.47.7.
- Signed native Debug iOS Simulator build and paired-community
navigation replay: after 18 channel visits and forced GC, retained
disposed portal keys fell from 18 to 0 and route instances from 19 to 1.
- Review follow-up: all 365 focused channel/overlay tests, analyzer, and
complete mobile pre-push checks passed.
- Full local CI passed before the test-only review follow-up, including
Rust and desktop validation, frontend builds, analyzer checks, 3,499
mobile tests (four skipped), and the separate unconfigured-push test.
The replay used iOS 26.5 Simulator with accessibility semantics enabled,
CPU profiling disabled, and Debug/JIT builds. The before app was a
preinstalled emoji candidate; the after app uses this branch. Object
retention and the focused regression establish the fix; absolute heap
differences are not a controlled release-memory measurement. Android
native execution was not tested.
### Create-channel compatibility
The radio groups retain their layout after switching their container to
Material.
| Before (3.41.7) | After (3.47.7) |
| --- | --- |
| <img
src="https://raw.githubusercontent.com/block/buzz/42525e1bd9d9f264d0142c34ec61dbb6937ef758/pr-8419--01-before.png"
width="280" alt="Original create-channel radio groups"> | <img
src="https://raw.githubusercontent.com/block/buzz/42525e1bd9d9f264d0142c34ec61dbb6937ef758/pr-8419--02-after.png"
width="280" alt="Updated create-channel radio groups"> |
---------
Signed-off-by: Tom Brow <tomb@block.xyz>
159 lines
5.2 KiB
Dart
159 lines
5.2 KiB
Dart
import 'package:flutter/foundation.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:local_auth/local_auth.dart';
|
|
|
|
/// Coarse outcomes safe to use for control flow without retaining OS details.
|
|
enum DeviceAuthResult { success, cancelled, unavailable, lockedOut, failed }
|
|
|
|
abstract interface class SensitiveActionAuthorizer {
|
|
Future<DeviceAuthResult> authorizeIdentityAction({
|
|
required bool biometricOnly,
|
|
});
|
|
|
|
Future<DeviceAuthResult> authorizeBiometricProtection();
|
|
|
|
Future<List<BiometricType>> enrolledBiometrics();
|
|
}
|
|
|
|
class LocalSensitiveActionAuthorizer implements SensitiveActionAuthorizer {
|
|
LocalSensitiveActionAuthorizer([LocalAuthentication? authentication])
|
|
: _authentication = authentication ?? LocalAuthentication();
|
|
|
|
final LocalAuthentication _authentication;
|
|
|
|
@override
|
|
Future<DeviceAuthResult> authorizeIdentityAction({
|
|
required bool biometricOnly,
|
|
}) => _authorize(
|
|
localizedReason: 'Confirm sending your Buzz identity to desktop',
|
|
biometricOnly: biometricOnly,
|
|
);
|
|
|
|
@override
|
|
Future<List<BiometricType>> enrolledBiometrics() async {
|
|
try {
|
|
return await _authentication.getAvailableBiometrics();
|
|
} catch (_) {
|
|
return const [];
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<DeviceAuthResult> authorizeBiometricProtection() async {
|
|
try {
|
|
final availableBiometrics = await _authentication
|
|
.getAvailableBiometrics();
|
|
if (availableBiometrics.isEmpty) return DeviceAuthResult.unavailable;
|
|
return await _authorize(
|
|
localizedReason: 'Enable biometrics for secure actions',
|
|
biometricOnly: true,
|
|
);
|
|
} on LocalAuthException catch (error) {
|
|
return _resultFor(error);
|
|
} catch (_) {
|
|
return DeviceAuthResult.failed;
|
|
}
|
|
}
|
|
|
|
Future<DeviceAuthResult> _authorize({
|
|
required String localizedReason,
|
|
required bool biometricOnly,
|
|
}) async {
|
|
try {
|
|
final supported = await _authentication.isDeviceSupported();
|
|
if (!supported) return DeviceAuthResult.unavailable;
|
|
final authenticated = await _authentication.authenticate(
|
|
localizedReason: localizedReason,
|
|
biometricOnly: biometricOnly,
|
|
sensitiveTransaction: true,
|
|
// iOS may briefly background the app while presenting Face ID. Keep
|
|
// this authorization alive across that system transition instead of
|
|
// returning a cancellation that forces the user to tap and retry.
|
|
persistAcrossBackgrounding: true,
|
|
);
|
|
return authenticated ? DeviceAuthResult.success : DeviceAuthResult.failed;
|
|
} on LocalAuthException catch (error) {
|
|
return _resultFor(error);
|
|
} catch (_) {
|
|
return DeviceAuthResult.failed;
|
|
}
|
|
}
|
|
|
|
static DeviceAuthResult _resultFor(LocalAuthException error) =>
|
|
switch (error.code) {
|
|
LocalAuthExceptionCode.userCanceled ||
|
|
LocalAuthExceptionCode.systemCanceled ||
|
|
LocalAuthExceptionCode.timeout => DeviceAuthResult.cancelled,
|
|
LocalAuthExceptionCode.temporaryLockout ||
|
|
LocalAuthExceptionCode.biometricLockout => DeviceAuthResult.lockedOut,
|
|
LocalAuthExceptionCode.noCredentialsSet ||
|
|
LocalAuthExceptionCode.noBiometricsEnrolled ||
|
|
LocalAuthExceptionCode.noBiometricHardware ||
|
|
LocalAuthExceptionCode.biometricHardwareTemporarilyUnavailable ||
|
|
LocalAuthExceptionCode.uiUnavailable => DeviceAuthResult.unavailable,
|
|
_ => DeviceAuthResult.failed,
|
|
};
|
|
}
|
|
|
|
final sensitiveActionAuthorizerProvider = Provider<SensitiveActionAuthorizer>((
|
|
ref,
|
|
) {
|
|
return LocalSensitiveActionAuthorizer();
|
|
});
|
|
|
|
final enrolledBiometricsProvider = FutureProvider<List<BiometricType>>((ref) {
|
|
return ref.watch(sensitiveActionAuthorizerProvider).enrolledBiometrics();
|
|
});
|
|
|
|
String biometricProtectionLabel(
|
|
TargetPlatform platform,
|
|
Iterable<BiometricType> enrolledBiometrics,
|
|
) {
|
|
if (platform == TargetPlatform.iOS) {
|
|
if (enrolledBiometrics.contains(BiometricType.face)) return 'Use Face ID';
|
|
if (enrolledBiometrics.contains(BiometricType.fingerprint)) {
|
|
return 'Use Touch ID';
|
|
}
|
|
}
|
|
return 'Use biometrics';
|
|
}
|
|
|
|
class SensitiveActionAuthorizationSession {
|
|
SensitiveActionAuthorizationSession(this._authorizer);
|
|
|
|
final SensitiveActionAuthorizer _authorizer;
|
|
Future<DeviceAuthResult>? _authorizationInFlight;
|
|
bool? _inFlightBiometricOnly;
|
|
|
|
Future<DeviceAuthResult> authorize({required bool biometricOnly}) async {
|
|
final inFlight = _authorizationInFlight;
|
|
if (inFlight != null) {
|
|
if (_inFlightBiometricOnly == biometricOnly) return inFlight;
|
|
await inFlight;
|
|
return authorize(biometricOnly: biometricOnly);
|
|
}
|
|
|
|
final authorization = _authorizer.authorizeIdentityAction(
|
|
biometricOnly: biometricOnly,
|
|
);
|
|
_authorizationInFlight = authorization;
|
|
_inFlightBiometricOnly = biometricOnly;
|
|
try {
|
|
final result = await authorization;
|
|
return result;
|
|
} finally {
|
|
if (identical(_authorizationInFlight, authorization)) {
|
|
_authorizationInFlight = null;
|
|
_inFlightBiometricOnly = null;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
final sensitiveActionAuthorizationSessionProvider =
|
|
Provider<SensitiveActionAuthorizationSession>((ref) {
|
|
return SensitiveActionAuthorizationSession(
|
|
ref.watch(sensitiveActionAuthorizerProvider),
|
|
);
|
|
});
|