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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,4 @@ app.*.map.json
/android/app/release
/secrets/
/android/build/reports/problems/problems-report.html
/android/.kotlin/
11 changes: 7 additions & 4 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import java.io.File
import java.util.Properties
import org.jetbrains.kotlin.gradle.dsl.JvmTarget

plugins {
id("com.android.application")
Expand Down Expand Up @@ -68,10 +69,6 @@ android {
targetCompatibility = JavaVersion.VERSION_17
}

kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}

defaultConfig {
applicationId = "com.androidircx.flutter"
minSdk = flutter.minSdkVersion
Expand Down Expand Up @@ -105,6 +102,12 @@ android {
}
}

kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_17
}
}

flutter {
source = "../.."
}
4 changes: 4 additions & 0 deletions android/gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,7 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m
android.useAndroidX=true
android.suppressUnsupportedCompileSdk=37.0
kotlin.incremental=false
# Use the external org.jetbrains.kotlin.android plugin, not AGP's built-in Kotlin.
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false
2 changes: 1 addition & 1 deletion android/gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip
4 changes: 2 additions & 2 deletions android/settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ pluginManagement {

plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
id("com.android.application") version "9.0.1" apply false
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
}

include(":app")
91 changes: 91 additions & 0 deletions lib/core/diagnostics/crash_report.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import 'dart:convert';

/// A single captured crash / uncaught-error record, already sanitized of any
/// credentials. Stored locally and, only if the user chooses, emailed as a
/// plaintext report. Nothing is ever sent automatically.
class CrashReport {
const CrashReport({
required this.timestamp,
required this.fatal,
required this.source,
required this.message,
required this.stack,
this.platform,
});

/// When the error was captured (UTC ISO-8601).
final DateTime timestamp;

/// Whether the error was fatal (uncaught) rather than a handled report.
final bool fatal;

/// Where it came from, e.g. `FlutterError.onError`, `PlatformDispatcher`,
/// `zone`, or a feature name.
final String source;

/// Sanitized error message.
final String message;

/// Sanitized stack trace (may be empty).
final String stack;

/// Optional platform description (e.g. `android`).
final String? platform;

Map<String, dynamic> toJson() => {
'timestamp': timestamp.toUtc().toIso8601String(),
'fatal': fatal,
'source': source,
'message': message,
'stack': stack,
if (platform != null) 'platform': platform,
};

static CrashReport fromJson(Map<String, dynamic> json) => CrashReport(
timestamp:
DateTime.tryParse(json['timestamp'] as String? ?? '')?.toUtc() ??
DateTime.fromMillisecondsSinceEpoch(0, isUtc: true),
fatal: json['fatal'] as bool? ?? true,
source: json['source'] as String? ?? 'unknown',
message: json['message'] as String? ?? '',
stack: json['stack'] as String? ?? '',
platform: json['platform'] as String?,
);

String encode() => jsonEncode(toJson());

static CrashReport? decode(String raw) {
try {
final decoded = jsonDecode(raw);
if (decoded is Map<String, dynamic>) {
return fromJson(decoded);
}
} catch (_) {
// Ignore malformed entries.
}
return null;
}

/// Human-readable plaintext body for the crash email / on-screen preview.
String toPlainText() {
final buffer = StringBuffer()
..writeln('AndroidIRCX crash report')
..writeln('Time: ${timestamp.toUtc().toIso8601String()}')
..writeln('Fatal: $fatal')
..writeln('Source: $source');
if (platform != null) {
buffer.writeln('Platform: $platform');
}
buffer
..writeln()
..writeln('Message:')
..writeln(message);
if (stack.trim().isNotEmpty) {
buffer
..writeln()
..writeln('Stack:')
..writeln(stack);
}
return buffer.toString();
}
}
49 changes: 49 additions & 0 deletions lib/core/diagnostics/crash_report_sanitizer.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/// Redacts credentials and other secrets from crash text before it is stored
/// or leaves the device. Ported from the React Native `ErrorReportingService`
/// sanitizer: IRC auth commands, generic password/token key-values, PEM blocks
/// and long opaque tokens are replaced with `[redacted]`.
class CrashReportSanitizer {
const CrashReportSanitizer();

static const String redaction = '[redacted]';

/// IRC/auth verbs whose trailing argument is a secret (case-insensitive).
/// The verb is kept, the value is redacted.
static final RegExp _authCommand = RegExp(
r'\b(PASS|AUTHENTICATE|OPER|IDENTIFY|NICKSERV|NS)\b[ \t]+(?![:#&])(\S+)',
caseSensitive: false,
);

/// `password: xxx`, `token=xxx`, `secret "xxx"`, `apikey => xxx`, etc.
static final RegExp _keyValue = RegExp(
r'\b(password|passwd|pass|token|secret|sasl|oauth|apikey|api_key|authorization|bearer|cert|key)\b'
r'''[ \t]*[:=]{1,2}[ \t]*["'`]?([^\s"'`,;)]+)''',
caseSensitive: false,
);

/// PEM private-key / certificate blocks.
static final RegExp _pemBlock = RegExp(
r'-----BEGIN [A-Z ]+-----[\s\S]*?-----END [A-Z ]+-----',
);

/// Long opaque tokens (base64/hex-ish runs of 40+ chars).
static final RegExp _longToken = RegExp(r'\b[A-Za-z0-9+/=_-]{40,}\b');

String sanitize(String input) {
if (input.isEmpty) {
return input;
}
var out = input;
out = out.replaceAll(_pemBlock, redaction);
out = out.replaceAllMapped(
_authCommand,
(m) => '${m.group(1)} $redaction',
);
out = out.replaceAllMapped(
_keyValue,
(m) => '${m.group(1)}=$redaction',
);
out = out.replaceAll(_longToken, redaction);
return out;
}
}
134 changes: 134 additions & 0 deletions lib/core/diagnostics/crash_reporter.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import 'dart:async';

import 'package:androidircx/core/diagnostics/crash_report.dart';
import 'package:androidircx/core/diagnostics/crash_report_sanitizer.dart';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';

/// Captures uncaught errors, sanitizes them, and keeps the most recent few on
/// device so the user can review and — only if they choose — email a plaintext
/// report. No automatic/network reporting and no analytics SDK: the report is
/// sent solely through the user's own mail client via a `mailto:` link.
class CrashReporter {
CrashReporter({
CrashReportSanitizer sanitizer = const CrashReportSanitizer(),
Future<SharedPreferences> Function()? prefsLoader,
String? platformName,
DateTime Function()? clock,
this.contactEmail = defaultContactEmail,
this.maxStored = 5,
}) : _sanitizer = sanitizer,
_prefsLoader = prefsLoader ?? SharedPreferences.getInstance,
_platformName = platformName ?? _defaultPlatformName(),
_clock = clock ?? DateTime.now;

static const String defaultContactEmail = 'contact@androidircx.com';
static const String emailSubject = 'AndroidIRCX Crash Report';
static const String _storageKey = 'androidircx.crashReports';

final CrashReportSanitizer _sanitizer;
final Future<SharedPreferences> Function() _prefsLoader;
final String? _platformName;
final DateTime Function() _clock;

/// Address the crash email is pre-addressed to.
final String contactEmail;

/// Maximum number of reports retained on device.
final int maxStored;

static String? _defaultPlatformName() {
// Avoid dart:io so this stays testable; use the platform embedder name.
final name = defaultTargetPlatform.name;
return name.isEmpty ? null : name;
}

/// Records an error, persisting a sanitized [CrashReport]. Never throws.
Future<CrashReport?> record(
Object error,
StackTrace? stack, {
bool fatal = true,
String source = 'unknown',
}) async {
try {
final report = CrashReport(
timestamp: _clock().toUtc(),
fatal: fatal,
source: source,
message: _sanitizer.sanitize(error.toString()),
stack: _sanitizer.sanitize(stack?.toString() ?? ''),
platform: _platformName,
);
final prefs = await _prefsLoader();
final existing = prefs.getStringList(_storageKey) ?? <String>[];
final updated = <String>[report.encode(), ...existing];
if (updated.length > maxStored) {
updated.removeRange(maxStored, updated.length);
}
await prefs.setStringList(_storageKey, updated);
return report;
} catch (_) {
// Diagnostics must never worsen a crash.
return null;
}
}

/// Loads the retained reports, newest first.
Future<List<CrashReport>> loadReports() async {
try {
final prefs = await _prefsLoader();
final raw = prefs.getStringList(_storageKey) ?? <String>[];
return raw
.map(CrashReport.decode)
.whereType<CrashReport>()
.toList(growable: false);
} catch (_) {
return const <CrashReport>[];
}
}

/// Clears all retained reports.
Future<void> clear() async {
try {
final prefs = await _prefsLoader();
await prefs.remove(_storageKey);
} catch (_) {
// Best effort.
}
}

/// Builds a `mailto:` URI pre-addressed to [contactEmail] with the report as
/// the plaintext body. Spaces are percent-encoded (not `+`) for broad mail
/// client compatibility.
Uri buildMailtoUri(CrashReport report) {
final subject = Uri.encodeComponent(emailSubject);
final body = Uri.encodeComponent(report.toPlainText());
return Uri.parse('mailto:$contactEmail?subject=$subject&body=$body');
}

/// Installs global handlers so uncaught framework and platform errors are
/// recorded. Existing handlers are preserved and still invoked.
void install() {
final priorFlutterHandler = FlutterError.onError;
FlutterError.onError = (details) {
unawaited(
record(
details.exception,
details.stack,
source: 'FlutterError.onError',
),
);
if (priorFlutterHandler != null) {
priorFlutterHandler(details);
} else {
FlutterError.presentError(details);
}
};

final priorPlatformHandler = PlatformDispatcher.instance.onError;
PlatformDispatcher.instance.onError = (error, stack) {
unawaited(record(error, stack, source: 'PlatformDispatcher'));
return priorPlatformHandler?.call(error, stack) ?? false;
};
}
}
Loading
Loading