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 CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ Thanks for your interest in contributing to AndroidIRCX.

## Code Style

- Use TypeScript for new code.
- Prefer small, focused services and components.
- Use Dart and Flutter for new code.
- Prefer small, focused services, controllers, repositories, and widgets.
- Keep changes scoped and avoid unrelated refactors.

## Reporting Bugs
Expand All @@ -32,4 +32,4 @@ Please include:

## License

By contributing, you agree that your contributions will be licensed under the GPL-3.0.
By contributing, you agree that your contributions will be licensed under the GPL-3.0.
37 changes: 15 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,39 +20,32 @@ existing React Native application.

## Current Status

This repository is an active rewrite in progress.
This repository is an active Flutter rewrite in release hardening.

Implemented in the current Flutter app:

- network list and add/edit flow
- basic IRC socket connection lifecycle
- server, channel, and query tabs
- basic message sending and receiving
- local persistence for networks, tabs, message history, and a small settings set
- reconnect banner and basic connection status UX
- network list, server directory, add/edit flow, identity profiles, and in-chat network switching
- TCP/TLS IRC transport, IRCv3 WebSocket transport, reconnect/backoff, and foreground-service integration
- IRC parser, ISUPPORT, numerics, CAP 302, SASL PLAIN/SCRAM-SHA-256/EXTERNAL, CTCP, and IRCv3 message features
- server, channel, query, notice, and DCC tabs with command suggestions and rich channel user actions
- encrypted local message history with retention/search/export support
- DCC CHAT/SEND, safe file picking, transfer progress, media cards, previews, downloads, camera/gallery capture, and in-app audio/video playback
- onboarding, consent/privacy screens, app lock, notification/display/writing settings, themes, backup/restore, ignore lists, auto-away/highlights/sounds, auto-rejoin, diagnostics, and crash reporting

Not implemented yet in full parity:
Still outside the default-client release scope:

- CAP/SASL full flow
- multi-network orchestration parity
- advanced numerics and command coverage
- notifications/background behavior
- encryption
- DCC/media
- monetization and security platform features

The feature list below describes the target AndroidIRCx parity scope, not the current Flutter
implementation status.
- E2EE, ads/IAP, and scripting are later product/security decisions
- WebRTC calling is not planned
- upload/share endpoints are deferred until a concrete product endpoint exists

## 🔐 Security

- **TLS/SSL** -- full encrypted connection support
- **SASL** -- PLAIN, SCRAM-SHA-256, EXTERNAL (client certificates)
- **E2E Encryption** -- libsodium XChaCha20-Poly1305 with context-bound AAD
- **Secure Storage** -- device Keychain for secrets (AsyncStorage fallback with warning)
- **Encrypted History** -- local message bodies encrypted with AES-256-GCM
- **Secure Storage** -- platform-backed storage for server passwords, SASL secrets, channel keys, and certificates
- **App Lock** -- PIN and biometric with auto-lock on background/launch
- **Kill Switch** -- emergency disconnect and optional data wipe
- **Play Integrity** -- Google Play Integrity verification
- **Crash Reports** -- sanitized on-device reports with secret redaction


## 🤝 Contributing
Expand Down
1 change: 0 additions & 1 deletion android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.CAMERA" />

<application
android:label="@string/app_name"
Expand Down
10 changes: 0 additions & 10 deletions lib/core/platform/app_permissions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ enum AppPermissionResult { granted, denied, permanentlyDenied, restricted }
abstract class AppPermissions {
Future<AppPermissionResult> requestNotifications();
Future<bool> hasNotifications();
Future<AppPermissionResult> requestCamera();
Future<bool> hasCamera();

/// Opens the OS app-settings page (used after a permanent denial).
Future<void> openSettingsPage();
Expand Down Expand Up @@ -38,14 +36,6 @@ class PermissionHandlerAppPermissions implements AppPermissions {
Future<bool> hasNotifications() async =>
(await Permission.notification.status).isGranted;

@override
Future<AppPermissionResult> requestCamera() async =>
_map(await Permission.camera.request());

@override
Future<bool> hasCamera() async =>
(await Permission.camera.status).isGranted;

@override
Future<void> openSettingsPage() async {
await openAppSettings();
Expand Down
74 changes: 74 additions & 0 deletions lib/core/security/device_unlock_session.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import 'dart:async';

import 'package:local_auth/local_auth.dart';

typedef DeviceUnlockPrompt = Future<bool> Function(String reason);

/// Coalesces biometric/PIN prompts and allows a short reuse window after a
/// successful device unlock.
class DeviceUnlockSession {
DeviceUnlockSession({
DeviceUnlockPrompt? prompt,
Duration reuseWindow = const Duration(seconds: 30),
DateTime Function()? now,
}) : _prompt = prompt ?? _defaultPrompt,
_reuseWindow = reuseWindow,
_now = now ?? DateTime.now;

static final DeviceUnlockSession instance = DeviceUnlockSession();

final DeviceUnlockPrompt _prompt;
final Duration _reuseWindow;
final DateTime Function() _now;
DateTime? _lastSuccessAt;
Future<bool>? _inFlight;

Future<bool> authenticate({required String reason}) {
final lastSuccessAt = _lastSuccessAt;
final now = _now();
if (lastSuccessAt != null &&
now.difference(lastSuccessAt).abs() <= _reuseWindow) {
return Future<bool>.value(true);
}

final existing = _inFlight;
if (existing != null) {
return existing;
}

final next = _prompt(reason).then((unlocked) {
if (unlocked) {
_lastSuccessAt = _now();
}
return unlocked;
});
_inFlight = next;
return next.whenComplete(() {
if (identical(_inFlight, next)) {
_inFlight = null;
}
});
}

void invalidate() {
_lastSuccessAt = null;
}

static Future<bool> _defaultPrompt(String reason) async {
try {
final auth = LocalAuthentication();
final supported =
await auth.isDeviceSupported() || await auth.canCheckBiometrics;
if (!supported) {
return false;
}
return await auth.authenticate(
localizedReason: reason,
biometricOnly: false,
persistAcrossBackgrounding: true,
);
} catch (_) {
return false;
}
}
}
42 changes: 26 additions & 16 deletions lib/core/security/local_auth_history_unlock.dart
Original file line number Diff line number Diff line change
@@ -1,30 +1,40 @@
import 'package:androidircx/core/security/history_encryption_key_manager.dart';
import 'package:androidircx/core/security/device_unlock_session.dart';
import 'package:local_auth/local_auth.dart';

/// Biometric (+ device PIN/passphrase fallback) gate for the encrypted history
/// key, backed by `local_auth`.
class LocalAuthHistoryUnlockAuthenticator implements HistoryUnlockAuthenticator {
class LocalAuthHistoryUnlockAuthenticator
implements HistoryUnlockAuthenticator {
LocalAuthHistoryUnlockAuthenticator([LocalAuthentication? auth])
: _auth = auth ?? LocalAuthentication();
: _unlockSession = auth == null
? DeviceUnlockSession.instance
: DeviceUnlockSession(prompt: _promptFor(auth));

final LocalAuthentication _auth;
final DeviceUnlockSession _unlockSession;

@override
Future<bool> authenticate({required String reason}) async {
try {
final supported =
await _auth.isDeviceSupported() || await _auth.canCheckBiometrics;
if (!supported) {
return _unlockSession.authenticate(reason: reason);
}

static DeviceUnlockPrompt _promptFor(LocalAuthentication auth) {
return (reason) async {
try {
final supported =
await auth.isDeviceSupported() || await auth.canCheckBiometrics;
if (!supported) {
return false;
}
return await auth.authenticate(
localizedReason: reason,
// biometricOnly: false allows the device PIN/passphrase fallback.
biometricOnly: false,
persistAcrossBackgrounding: true,
);
} catch (_) {
return false;
}
return await _auth.authenticate(
localizedReason: reason,
// biometricOnly: false allows the device PIN/passphrase fallback.
biometricOnly: false,
persistAcrossBackgrounding: true,
);
} catch (_) {
return false;
}
};
}
}
Loading
Loading