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
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ The auto-opened promotion PRs are idempotent — only one is open per branch pai
- **No third-party APIs**: no direct Ethereum JSON-RPC calls (Infura, Alchemy, public nodes, etc.), no block explorer APIs (Etherscan, …), no price feeds, no analytics endpoints, no third-party SDKs that call out over the network.
- If a feature needs on-chain data (e.g. native ETH balance, transaction status, token balance), add a new endpoint to [`DFXswiss/api`](https://github.com/DFXswiss/api) and let the app call that endpoint. The API is the single gateway.
- All network calls must go through `AppStore.httpClient` with `buildUri(_host, …)` — `_host` resolves to the DFX API host via `ApiConfig`. Do not instantiate `http.Client`/`Dio`/`Web3Client` against other hosts.
- **One scoped exception — crash reporting.** Builds that inject `--dart-define=SENTRY_DSN=...` deliver crash reports to the company-operated crash-reporting service ([`lib/setup/error_handling/crash_reporting.dart`](lib/setup/error_handling/crash_reporting.dart)). This is first-party infrastructure telemetry, not a third-party service: without an injected DSN (all local and test builds) the SDK never starts and produces no network traffic, and the delivered data is limited to error events — no PII, no screenshots, no performance tracing, no session telemetry (the exact pinned option surface lives in `crash_reporting.dart`). Widening what is sent (breadcrumbs with request URLs, user context, attachments) is a review-blocking change, not a config tweak.

## API as Decision Authority — CRITICAL

Expand Down
8 changes: 7 additions & 1 deletion lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import 'package:realunit_wallet/screens/home/bloc/home_bloc.dart';
import 'package:realunit_wallet/screens/pin/bloc/auth/pin_auth_cubit.dart';
import 'package:realunit_wallet/screens/settings/bloc/settings_bloc.dart';
import 'package:realunit_wallet/setup/di.dart';
import 'package:realunit_wallet/setup/error_handling/crash_reporting.dart';
import 'package:realunit_wallet/setup/error_handling/error_handlers.dart';
import 'package:realunit_wallet/setup/lifecycle_initializer.dart';
import 'package:realunit_wallet/setup/routing/boot_navigation.dart';
Expand All @@ -19,10 +20,15 @@ Future<void> main() async {
final widgetsBinding = WidgetsFlutterBinding.ensureInitialized();

installErrorHandlers();
// Preserve before the first await: an async gap ahead of preserve() would
// let the native splash auto-dismiss and flash to blank.
if (kReleaseMode) FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding);
// After installErrorHandlers on purpose: the reporter chains the handlers
// installed above, while installErrorHandlers overwrites the async hook.
await initCrashReporting();

// only preserve splash screen for 3 seconds for release version
if (kReleaseMode) {
FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding);
await _initializeWithSplashDuration();
FlutterNativeSplash.remove();
} else {
Expand Down
75 changes: 75 additions & 0 deletions lib/setup/error_handling/crash_reporting.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import 'dart:developer' as developer;

import 'package:flutter/foundation.dart';
import 'package:sentry_flutter/sentry_flutter.dart';

/// The crash-reporting DSN, injected at build time via
/// `--dart-define=SENTRY_DSN=...`. Local and CI builds that do not inject a
/// DSN get the empty default, which keeps crash reporting fully disabled —
/// no SDK start, no network traffic. The DSN points at the company-operated
/// crash-reporting service; see CONTRIBUTING § API Access for why this is the
/// one sanctioned non-API endpoint.
const crashReportingDsn = String.fromEnvironment('SENTRY_DSN');

/// Reported as the Sentry environment. Only release builds carrying a DSN
/// report at all, so the default names the production environment; testnet
/// builds can override via `--dart-define=SENTRY_ENVIRONMENT=...`.
const crashReportingEnvironment = String.fromEnvironment(
'SENTRY_ENVIRONMENT',
defaultValue: 'production',
);

/// Matches the shape of [SentryFlutter.init] so tests can swap in a recording
/// fake without touching the native SDK.
typedef CrashReporterInit = Future<void> Function(FlutterOptionsConfiguration configuration);

/// Starts crash reporting when a DSN was injected at build time; a no-op
/// otherwise. Best-effort by design: a malformed DSN or a failing native
/// binding is logged and swallowed — reporting infrastructure must never keep
/// the wallet from starting.
///
/// Must run AFTER [installErrorHandlers]: that installer overwrites
/// `PlatformDispatcher.onError` without chaining, so in the reverse order the
/// SDK's async-error hook would be silently dropped. The SDK itself chains
/// both handlers it wraps — `FlutterError.onError` captures first and then
/// delegates to the handler installed before it; `PlatformDispatcher.onError`
/// delegates first and then captures.
///
/// @no-integration-test: the native SDK only starts in a build that injects a
/// DSN, which no test build does; the DSN gate, the option pinning and the
/// swallow-on-failure contract are covered by unit tests via the injectable
/// [init].
Future<void> initCrashReporting({
String dsn = crashReportingDsn,
CrashReporterInit init = SentryFlutter.init,
}) async {
if (dsn.isEmpty) return;
try {
await init((options) => configureCrashReporting(options, dsn: dsn));
} catch (error, stackTrace) {
developer.log(
'crash reporting init failed: $error',
name: 'WalletApp',
error: error,
stackTrace: stackTrace,
);
}
}

/// Applies the pinned option set. The guarantee is exactly this list — an
/// upstream default flip outside it is not caught here: no PII, no
/// screenshots, no performance tracing, no session telemetry. Native crash
/// handling and ANR detection stay on their SDK defaults deliberately; they
/// produce precisely the error events this reporter exists for.
/// (View-hierarchy attachment also stays off by SDK default; its option is
/// experimental and deliberately not referenced here.)
@visibleForTesting
void configureCrashReporting(SentryFlutterOptions options, {required String dsn}) {
options
..dsn = dsn
..environment = crashReportingEnvironment
..sendDefaultPii = false
..attachScreenshot = false
..enableAutoSessionTracking = false
..tracesSampleRate = null;
}
6 changes: 3 additions & 3 deletions lib/setup/error_handling/error_handlers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@ import 'package:realunit_wallet/setup/error_handling/realunit_error_view.dart';
/// engine's own reporting running instead of suppressing it (returning `true`
/// would claim the error as handled while our log is a no-op, i.e. total
/// silence). So this makes async errors *reachable where a developer is already
/// attached*; it adds no release-mode visibility on its own. Getting evidence
/// off a customer's device needs a crash reporter or a persisted log sink —
/// this handler body is the hook such a sink plugs into.
/// attached*; it adds no release-mode visibility on its own. Release-mode
/// evidence comes from the crash reporter (`crash_reporting.dart`), which is
/// initialized after this call and chains the handlers installed here.
///
/// @no-integration-test: the engine-side fallback reporting that runs after
/// [PlatformDispatcher.onError] returns false is embedder behaviour and is not
Expand Down
40 changes: 40 additions & 0 deletions pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -823,6 +823,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.5"
jni:
dependency: transitive
description:
name: jni
sha256: d2c361082d554d4593c3012e26f6b188f902acd291330f13d6427641a92b3da1
url: "https://pub.dev"
source: hosted
version: "0.14.2"
js:
dependency: transitive
description:
Expand Down Expand Up @@ -1087,6 +1095,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
package_info_plus:
dependency: transitive
description:
name: package_info_plus
sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20"
url: "https://pub.dev"
source: hosted
version: "9.0.1"
package_info_plus_platform_interface:
dependency: transitive
description:
name: package_info_plus_platform_interface
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
url: "https://pub.dev"
source: hosted
version: "3.2.1"
path:
dependency: "direct main"
description:
Expand Down Expand Up @@ -1271,6 +1295,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.0"
sentry:
dependency: transitive
description:
name: sentry
sha256: a84bf3a83b3ce1c89fce28b9f97659e3826df14a73a256952465aac2c5efdf5a
url: "https://pub.dev"
source: hosted
version: "9.25.0"
sentry_dart_plugin:
dependency: "direct dev"
description:
Expand All @@ -1279,6 +1311,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.4.0"
sentry_flutter:
dependency: "direct main"
description:
name: sentry_flutter
sha256: c85575266d91f57364e9b4cb522835156ec3c5581c78339dc7fae491cd0c6f76
url: "https://pub.dev"
source: hosted
version: "9.25.0"
shared_preferences:
dependency: "direct main"
description:
Expand Down
1 change: 1 addition & 0 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ dependencies:
path_provider: 2.1.5
pointycastle: ^3.9.1
qr_flutter: ^4.1.0
sentry_flutter: ^9.25.0
shared_preferences: ^2.5.2
sqlite3: ^3.3.0
url_launcher: ^6.3.1
Expand Down
57 changes: 57 additions & 0 deletions test/setup/error_handling/crash_reporting_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:realunit_wallet/setup/error_handling/crash_reporting.dart';

void main() {
group('initCrashReporting', () {
test('stays a no-op when no DSN is injected', () async {
var initCalls = 0;

await initCrashReporting(
dsn: '',
init: (_) async => initCalls++,
);

expect(initCalls, 0);
});

test('starts the reporter exactly once when a DSN is injected', () async {
var initCalls = 0;

await initCrashReporting(
dsn: 'https://key@reporting.invalid/1',
init: (_) async => initCalls++,
);

expect(initCalls, 1);
});

test('applies the pinned option set', () async {
final options = SentryFlutterOptions();
late FlutterOptionsConfiguration configuration;

await initCrashReporting(
dsn: 'https://key@reporting.invalid/1',
init: (config) async => configuration = config,
);
await configuration(options);

expect(options.dsn, 'https://key@reporting.invalid/1');
expect(options.environment, crashReportingEnvironment);
expect(options.sendDefaultPii, isFalse);
expect(options.attachScreenshot, isFalse);
expect(options.enableAutoSessionTracking, isFalse);
expect(options.tracesSampleRate, isNull);
});

test('swallows a failing reporter init instead of blocking startup', () async {
await expectLater(
initCrashReporting(
dsn: 'https://key@reporting.invalid/1',
init: (_) async => throw StateError('native binding unavailable'),
),
completes,
);
});
});
}
Loading