diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 2bb997b..3b37123 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -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
@@ -32,4 +32,4 @@ Please include:
## License
-By contributing, you agree that your contributions will be licensed under the GPL-3.0.
\ No newline at end of file
+By contributing, you agree that your contributions will be licensed under the GPL-3.0.
diff --git a/README.md b/README.md
index f09f4e8..243f79c 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 51af3d4..ca15fd6 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -5,7 +5,6 @@
-
requestNotifications();
Future hasNotifications();
- Future requestCamera();
- Future hasCamera();
/// Opens the OS app-settings page (used after a permanent denial).
Future openSettingsPage();
@@ -38,14 +36,6 @@ class PermissionHandlerAppPermissions implements AppPermissions {
Future hasNotifications() async =>
(await Permission.notification.status).isGranted;
- @override
- Future requestCamera() async =>
- _map(await Permission.camera.request());
-
- @override
- Future hasCamera() async =>
- (await Permission.camera.status).isGranted;
-
@override
Future openSettingsPage() async {
await openAppSettings();
diff --git a/lib/core/security/device_unlock_session.dart b/lib/core/security/device_unlock_session.dart
new file mode 100644
index 0000000..678ff95
--- /dev/null
+++ b/lib/core/security/device_unlock_session.dart
@@ -0,0 +1,74 @@
+import 'dart:async';
+
+import 'package:local_auth/local_auth.dart';
+
+typedef DeviceUnlockPrompt = Future 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? _inFlight;
+
+ Future authenticate({required String reason}) {
+ final lastSuccessAt = _lastSuccessAt;
+ final now = _now();
+ if (lastSuccessAt != null &&
+ now.difference(lastSuccessAt).abs() <= _reuseWindow) {
+ return Future.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 _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;
+ }
+ }
+}
diff --git a/lib/core/security/local_auth_history_unlock.dart b/lib/core/security/local_auth_history_unlock.dart
index a20b20a..d341c46 100644
--- a/lib/core/security/local_auth_history_unlock.dart
+++ b/lib/core/security/local_auth_history_unlock.dart
@@ -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 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;
- }
+ };
}
}
diff --git a/lib/features/chat/application/chat_session_controller.dart b/lib/features/chat/application/chat_session_controller.dart
index e75a1f5..9d3b1ad 100644
--- a/lib/features/chat/application/chat_session_controller.dart
+++ b/lib/features/chat/application/chat_session_controller.dart
@@ -736,8 +736,9 @@ class ChatSessionController extends ChangeNotifier {
if (repository != null) {
_autoModeEntries = await repository.remove(entry);
} else {
- _autoModeEntries =
- _autoModeEntries.where((e) => e.key != entry.key).toList();
+ _autoModeEntries = _autoModeEntries
+ .where((e) => e.key != entry.key)
+ .toList();
}
notifyListeners();
}
@@ -758,7 +759,8 @@ class ChatSessionController extends ChangeNotifier {
final ownModes =
_channelUserModes[tabId]?[currentNick.trim().toLowerCase()] ??
const {};
- final hasOp = ownModes.contains('o') ||
+ final hasOp =
+ ownModes.contains('o') ||
ownModes.contains('q') ||
ownModes.contains('a');
final hasHalfOp = ownModes.contains('h');
@@ -786,9 +788,7 @@ class ChatSessionController extends ChangeNotifier {
UserListType.autoVoice => hasOp || hasHalfOp,
};
if (canApply) {
- unawaited(
- _ircService.sendRaw('MODE $channel +${type.modeChar} $nick'),
- );
+ unawaited(_ircService.sendRaw('MODE $channel +${type.modeChar} $nick'));
return;
}
}
@@ -2671,10 +2671,18 @@ class ChatSessionController extends ChangeNotifier {
await _ircService.disconnect(rest.isEmpty ? null : rest);
return;
case 'autovoice':
- await _handleAutoModeCommand(UserListType.autoVoice, rest, remove: false);
+ await _handleAutoModeCommand(
+ UserListType.autoVoice,
+ rest,
+ remove: false,
+ );
return;
case 'unautovoice':
- await _handleAutoModeCommand(UserListType.autoVoice, rest, remove: true);
+ await _handleAutoModeCommand(
+ UserListType.autoVoice,
+ rest,
+ remove: true,
+ );
return;
case 'autoop':
await _handleAutoModeCommand(UserListType.autoOp, rest, remove: false);
@@ -2728,13 +2736,18 @@ class ChatSessionController extends ChangeNotifier {
}
final mask = tokens.first;
final channels = tokens.length > 1
- ? tokens[1].split(',').map((c) => c.trim()).where((c) => c.isNotEmpty).toList()
+ ? tokens[1]
+ .split(',')
+ .map((c) => c.trim())
+ .where((c) => c.isNotEmpty)
+ .toList()
: const [];
if (remove) {
- final normalized = UserListEntry(type: type, mask: mask)
- .normalizedMask
- .toLowerCase();
+ final normalized = UserListEntry(
+ type: type,
+ mask: mask,
+ ).normalizedMask.toLowerCase();
final matches = _autoModeEntries
.where(
(entry) =>
@@ -2812,8 +2825,9 @@ class ChatSessionController extends ChangeNotifier {
kind: IrcMessageKind.system,
);
} else {
- final names =
- _commandService.commands.map((command) => '/${command.name}').join(' ');
+ final names = _commandService.commands
+ .map((command) => '/${command.name}')
+ .join(' ');
_appendMessage(
tabId: activeTab.id,
sender: '*',
@@ -2851,7 +2865,9 @@ class ChatSessionController extends ChangeNotifier {
_appendMessage(
tabId: activeTab.id,
sender: '*',
- content: alreadyIgnored ? 'Already ignoring $mask' : 'Now ignoring $mask',
+ content: alreadyIgnored
+ ? 'Already ignoring $mask'
+ : 'Now ignoring $mask',
kind: IrcMessageKind.system,
);
}
@@ -2911,8 +2927,9 @@ class ChatSessionController extends ChangeNotifier {
return;
}
- final echoEnabled =
- _ircService.enabledCapabilities.contains('echo-message');
+ final echoEnabled = _ircService.enabledCapabilities.contains(
+ 'echo-message',
+ );
for (final tab in channelTabs) {
if (asAction) {
await _ircService.sendAction(target: tab.name, text: text);
@@ -3014,8 +3031,9 @@ class ChatSessionController extends ChangeNotifier {
}
byHost.putIfAbsent(host, () => []).add(bare);
}
- final clones = byHost.entries.where((entry) => entry.value.length > 1).toList()
- ..sort((a, b) => a.key.compareTo(b.key));
+ final clones =
+ byHost.entries.where((entry) => entry.value.length > 1).toList()
+ ..sort((a, b) => a.key.compareTo(b.key));
if (clones.isEmpty) {
_appendMessage(
@@ -3062,18 +3080,18 @@ class ChatSessionController extends ChangeNotifier {
return false;
}
for (final mask in _ignoreMasks) {
- if (_matchesIgnoreMask(mask, prefix: frame.prefix, nick: frame.senderNick)) {
+ if (_matchesIgnoreMask(
+ mask,
+ prefix: frame.prefix,
+ nick: frame.senderNick,
+ )) {
return true;
}
}
return false;
}
- static bool _matchesIgnoreMask(
- String mask, {
- String? prefix,
- String? nick,
- }) {
+ static bool _matchesIgnoreMask(String mask, {String? prefix, String? nick}) {
final hasHostMask = mask.contains('!') || mask.contains('@');
final target = hasHostMask ? (prefix ?? nick) : (nick ?? prefix);
if (target == null || target.isEmpty) {
@@ -3101,7 +3119,9 @@ class ChatSessionController extends ChangeNotifier {
void _handleFilterCommand(String rest) {
final trimmed = rest.trim();
// `-g` (global) is accepted for RN parity; filtering here is per-session.
- final text = trimmed.startsWith('-g') ? trimmed.substring(2).trim() : trimmed;
+ final text = trimmed.startsWith('-g')
+ ? trimmed.substring(2).trim()
+ : trimmed;
if (text.isEmpty) {
_appendMessage(
tabId: activeTab.id,
@@ -3144,8 +3164,9 @@ class ChatSessionController extends ChangeNotifier {
_appendMessage(
tabId: activeTab.id,
sender: '*',
- content:
- removed ? 'No longer filtering "$text"' : 'Not filtering "$text"',
+ content: removed
+ ? 'No longer filtering "$text"'
+ : 'Not filtering "$text"',
kind: IrcMessageKind.system,
);
}
@@ -3252,7 +3273,10 @@ class ChatSessionController extends ChangeNotifier {
final delayMs = int.tryParse(parts[1]);
final repetitions = int.tryParse(parts[2]);
final command = parts.skip(3).join(' ');
- if (delayMs == null || delayMs <= 0 || repetitions == null || repetitions < 0) {
+ if (delayMs == null ||
+ delayMs <= 0 ||
+ repetitions == null ||
+ repetitions < 0) {
_appendMessage(
tabId: activeTab.id,
sender: 'error',
@@ -3265,19 +3289,18 @@ class ChatSessionController extends ChangeNotifier {
_commandTimers.remove(name)?.cancel();
var remaining = repetitions;
- _commandTimers[name] = Timer.periodic(
- Duration(milliseconds: delayMs),
- (timer) {
- unawaited(handleComposerSubmit(command));
- if (repetitions != 0) {
- remaining--;
- if (remaining <= 0) {
- timer.cancel();
- _commandTimers.remove(name);
- }
+ _commandTimers[name] = Timer.periodic(Duration(milliseconds: delayMs), (
+ timer,
+ ) {
+ unawaited(handleComposerSubmit(command));
+ if (repetitions != 0) {
+ remaining--;
+ if (remaining <= 0) {
+ timer.cancel();
+ _commandTimers.remove(name);
}
- },
- );
+ }
+ });
_appendMessage(
tabId: activeTab.id,
sender: '*',
@@ -3933,9 +3956,9 @@ class ChatSessionController extends ChangeNotifier {
kind: IrcMessageKind.event,
);
case 'PONG':
- final token = (frame.trailing ??
- (frame.params.isEmpty ? '' : frame.params.last))
- .trim();
+ final token =
+ (frame.trailing ?? (frame.params.isEmpty ? '' : frame.params.last))
+ .trim();
if (token.startsWith('LAG')) {
final sentMs = int.tryParse(token.substring(3));
if (sentMs != null) {
@@ -4368,6 +4391,7 @@ class ChatSessionController extends ChangeNotifier {
_serverSupport = _serverSupport.mergeFrame(frame);
_nickPrefixChars = _serverSupport.nickPrefixSymbols;
_channelPrefixChars = _serverSupport.channelTypes;
+ _dedupeEquivalentTabs();
final tokens = isupportTokensFromFrame(frame);
final supportText = tokens.join(' ');
@@ -5123,14 +5147,17 @@ class ChatSessionController extends ChangeNotifier {
}
ChatTab _ensureChannelTab(String channel) {
- final existing = _findTab(_channelTabId(network.id, channel));
+ final normalizedChannel = channel.trim();
+ final existing =
+ _findTab(_channelTabId(network.id, normalizedChannel)) ??
+ _findEquivalentTab(ChatTabType.channel, normalizedChannel);
if (existing != null) {
return existing;
}
final tab = ChatTab(
- id: _channelTabId(network.id, channel),
- name: channel,
+ id: _channelTabId(network.id, normalizedChannel),
+ name: normalizedChannel,
type: ChatTabType.channel,
networkId: network.id,
);
@@ -5142,14 +5169,17 @@ class ChatSessionController extends ChangeNotifier {
}
ChatTab _ensureQueryTab(String nick) {
- final existing = _findTab(_queryTabId(network.id, nick));
+ final normalizedNick = nick.trim();
+ final existing =
+ _findTab(_queryTabId(network.id, normalizedNick)) ??
+ _findEquivalentTab(ChatTabType.query, normalizedNick);
if (existing != null) {
return existing;
}
final tab = ChatTab(
- id: _queryTabId(network.id, nick),
- name: nick,
+ id: _queryTabId(network.id, normalizedNick),
+ name: normalizedNick,
type: ChatTabType.query,
networkId: network.id,
);
@@ -5335,6 +5365,159 @@ class ChatSessionController extends ChangeNotifier {
return null;
}
+ ChatTab? _findEquivalentTab(ChatTabType type, String name) {
+ final key = _ircCasefold(name);
+ for (final tab in _tabs) {
+ if (tab.type == type && _ircCasefold(tab.name) == key) {
+ return tab;
+ }
+ }
+ return null;
+ }
+
+ void _dedupeEquivalentTabs() {
+ final canonicalByKey = {};
+ final aliases = {};
+ final nextTabs = [];
+
+ for (final tab in _tabs) {
+ if (tab.type != ChatTabType.channel && tab.type != ChatTabType.query) {
+ nextTabs.add(tab);
+ continue;
+ }
+
+ final key = '${tab.type.name}:${_ircCasefold(tab.name)}';
+ final canonical = canonicalByKey[key];
+ if (canonical == null) {
+ canonicalByKey[key] = tab;
+ nextTabs.add(tab);
+ continue;
+ }
+
+ aliases[tab.id] = canonical.id;
+ final canonicalIndex = nextTabs.indexWhere(
+ (item) => item.id == canonical.id,
+ );
+ if (canonicalIndex != -1) {
+ nextTabs[canonicalIndex] = canonical.copyWith(
+ hasActivity: canonical.hasActivity || tab.hasActivity,
+ unreadCount: canonical.unreadCount + tab.unreadCount,
+ isEncrypted: canonical.isEncrypted || tab.isEncrypted,
+ );
+ canonicalByKey[key] = nextTabs[canonicalIndex];
+ }
+ }
+
+ if (aliases.isEmpty) {
+ return;
+ }
+
+ _tabs = nextTabs;
+ _activeTabId = aliases[_activeTabId] ?? _activeTabId;
+ _rekeyMessages(aliases);
+ _rekeySetMap(_channelUsers, aliases);
+ _rekeyStringMap(_channelTopics, aliases);
+ _rekeyStringMap(_channelModes, aliases);
+ _rekeyNestedSetMap(_channelUserModes, aliases);
+ _rekeyDateTimeMap(_readMarkers, aliases);
+ _rekeyNestedSetMap(_messageReactions, aliases);
+ _rekeySetMap(_typingUsersByTab, aliases);
+ }
+
+ String _ircCasefold(String value) {
+ final lower = value.trim().toLowerCase();
+ return switch (_serverSupport.caseMapping.toLowerCase()) {
+ 'ascii' => lower,
+ 'strict-rfc1459' =>
+ lower.replaceAll('[', '{').replaceAll(']', '}').replaceAll(r'\', '|'),
+ _ =>
+ lower
+ .replaceAll('[', '{')
+ .replaceAll(']', '}')
+ .replaceAll(r'\', '|')
+ .replaceAll('^', '~'),
+ };
+ }
+
+ void _rekeyMessages(Map aliases) {
+ final next = >{};
+ for (final entry in _messages.entries) {
+ final tabId = aliases[entry.key] ?? entry.key;
+ final messages = next.putIfAbsent(tabId, () => []);
+ messages.addAll(
+ entry.value.map(
+ (message) =>
+ message.tabId == tabId ? message : message.copyWith(tabId: tabId),
+ ),
+ );
+ }
+ _messages
+ ..clear()
+ ..addAll(next);
+ }
+
+ void _rekeySetMap(Map> map, Map aliases) {
+ final next = >{};
+ for (final entry in map.entries) {
+ next
+ .putIfAbsent(aliases[entry.key] ?? entry.key, () => {})
+ .addAll(entry.value);
+ }
+ map
+ ..clear()
+ ..addAll(next);
+ }
+
+ void _rekeyStringMap(Map map, Map aliases) {
+ final next = {};
+ for (final entry in map.entries) {
+ final tabId = aliases[entry.key] ?? entry.key;
+ final existing = next[tabId];
+ next[tabId] = existing == null || existing.trim().isEmpty
+ ? entry.value
+ : existing;
+ }
+ map
+ ..clear()
+ ..addAll(next);
+ }
+
+ void _rekeyNestedSetMap(
+ Map>> map,
+ Map aliases,
+ ) {
+ final next = >>{};
+ for (final entry in map.entries) {
+ final tabId = aliases[entry.key] ?? entry.key;
+ final nested = next.putIfAbsent(tabId, () => >{});
+ for (final nestedEntry in entry.value.entries) {
+ nested
+ .putIfAbsent(nestedEntry.key, () => {})
+ .addAll(nestedEntry.value);
+ }
+ }
+ map
+ ..clear()
+ ..addAll(next);
+ }
+
+ void _rekeyDateTimeMap(
+ Map map,
+ Map aliases,
+ ) {
+ final next = {};
+ for (final entry in map.entries) {
+ final tabId = aliases[entry.key] ?? entry.key;
+ final existing = next[tabId];
+ next[tabId] = existing == null || entry.value.isAfter(existing)
+ ? entry.value
+ : existing;
+ }
+ map
+ ..clear()
+ ..addAll(next);
+ }
+
IrcMessage? _appendMessage({
required String tabId,
required String sender,
@@ -5381,9 +5564,7 @@ class ChatSessionController extends ChangeNotifier {
final appended = list.last;
final repository = _historyRepository;
if (repository != null) {
- unawaited(
- repository.append(networkId: network.id, message: appended),
- );
+ unawaited(repository.append(networkId: network.id, message: appended));
}
return appended;
}
@@ -5636,6 +5817,7 @@ class ChatSessionController extends ChangeNotifier {
_messages
..clear()
..addAll(snapshot.messagesByTab);
+ _dedupeEquivalentTabs();
for (final tab in _tabs) {
_messages.putIfAbsent(tab.id, () => []);
@@ -5703,8 +5885,9 @@ class ChatSessionController extends ChangeNotifier {
}
final current = _messages[tabId];
- final anchor =
- (current == null || current.isEmpty) ? null : current.first.id;
+ final anchor = (current == null || current.isEmpty)
+ ? null
+ : current.first.id;
final older = await repository.loadTabHistory(
networkId: network.id,
tabId: tabId,
@@ -6498,7 +6681,8 @@ class ChatSessionController extends ChangeNotifier {
String _serverTabId(String networkId) => 'server::$networkId';
String _noticeTabId(String networkId) => 'notice::$networkId';
String _channelTabId(String networkId, String name) =>
- 'channel::$networkId::$name';
-String _queryTabId(String networkId, String nick) => 'query::$networkId::$nick';
+ 'channel::$networkId::${name.trim().toLowerCase()}';
+String _queryTabId(String networkId, String nick) =>
+ 'query::$networkId::${nick.trim().toLowerCase()}';
String _dccTabId(String networkId, String sessionId) =>
'dcc::$networkId::$sessionId';
diff --git a/lib/features/security/presentation/app_lock_gate.dart b/lib/features/security/presentation/app_lock_gate.dart
index bcfa1a2..4d4fc68 100644
--- a/lib/features/security/presentation/app_lock_gate.dart
+++ b/lib/features/security/presentation/app_lock_gate.dart
@@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
-import 'package:local_auth/local_auth.dart';
+import 'package:androidircx/core/security/device_unlock_session.dart';
/// Returns true if the user successfully authenticated.
typedef AppUnlockCallback = Future Function();
@@ -25,10 +25,10 @@ class AppLockGate extends StatefulWidget {
State createState() => _AppLockGateState();
}
-class _AppLockGateState extends State
- with WidgetsBindingObserver {
+class _AppLockGateState extends State with WidgetsBindingObserver {
bool _unlocked = false;
bool _prompting = false;
+ bool _hasMountedChild = false;
@override
void initState() {
@@ -38,6 +38,7 @@ class _AppLockGateState extends State
WidgetsBinding.instance.addPostFrameCallback((_) => _attemptUnlock());
} else {
_unlocked = true;
+ _hasMountedChild = true;
}
}
@@ -46,11 +47,13 @@ class _AppLockGateState extends State
super.didUpdateWidget(oldWidget);
if (!widget.enabled) {
_unlocked = true;
+ _hasMountedChild = true;
} else if (!oldWidget.enabled && widget.enabled) {
// Just enabled at runtime: the user is already in the app (and confirmed
// authentication in Settings), so stay unlocked now. The lock engages on
// the next time the app is backgrounded and resumed.
_unlocked = true;
+ _hasMountedChild = true;
}
}
@@ -68,6 +71,7 @@ class _AppLockGateState extends State
if (state == AppLifecycleState.paused ||
state == AppLifecycleState.hidden) {
if (mounted) {
+ DeviceUnlockSession.instance.invalidate();
setState(() => _unlocked = false);
}
} else if (state == AppLifecycleState.resumed && !_unlocked) {
@@ -76,21 +80,9 @@ class _AppLockGateState extends State
}
Future _defaultUnlock() async {
- try {
- final auth = LocalAuthentication();
- final supported =
- await auth.isDeviceSupported() || await auth.canCheckBiometrics;
- if (!supported) {
- return false;
- }
- return await auth.authenticate(
- localizedReason: 'Unlock AndroidIRCX',
- biometricOnly: false,
- persistAcrossBackgrounding: true,
- );
- } catch (_) {
- return false;
- }
+ return DeviceUnlockSession.instance.authenticate(
+ reason: 'Unlock AndroidIRCX',
+ );
}
Future _attemptUnlock() async {
@@ -101,27 +93,60 @@ class _AppLockGateState extends State
final unlocked = await (widget.unlock ?? _defaultUnlock)();
_prompting = false;
if (mounted && unlocked) {
- setState(() => _unlocked = true);
+ setState(() {
+ _unlocked = true;
+ _hasMountedChild = true;
+ });
}
}
@override
Widget build(BuildContext context) {
- if (!widget.enabled || _unlocked) {
- return widget.child;
+ final locked = widget.enabled && !_unlocked;
+ if (!_hasMountedChild && locked) {
+ return const _AppLockScreen();
}
+ return Stack(
+ fit: StackFit.expand,
+ children: [
+ ExcludeSemantics(
+ excluding: locked,
+ child: IgnorePointer(
+ ignoring: locked,
+ child: TickerMode(enabled: !locked, child: widget.child),
+ ),
+ ),
+ if (locked) const Positioned.fill(child: _AppLockScreen()),
+ ],
+ );
+ }
+}
+
+class _AppLockScreen extends StatelessWidget {
+ const _AppLockScreen();
+
+ @override
+ Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
- Icon(Icons.lock_outline, size: 56, color: theme.colorScheme.primary),
+ Icon(
+ Icons.lock_outline,
+ size: 56,
+ color: theme.colorScheme.primary,
+ ),
const SizedBox(height: 16),
Text('AndroidIRCX is locked', style: theme.textTheme.titleMedium),
const SizedBox(height: 16),
FilledButton.icon(
- onPressed: _attemptUnlock,
+ onPressed: () {
+ final state = context
+ .findAncestorStateOfType<_AppLockGateState>();
+ state?._attemptUnlock();
+ },
icon: const Icon(Icons.fingerprint),
label: const Text('Unlock'),
),
diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart
index 62c5c52..d616847 100644
--- a/lib/features/settings/presentation/settings_screen.dart
+++ b/lib/features/settings/presentation/settings_screen.dart
@@ -41,7 +41,7 @@ class SettingsScreen extends StatefulWidget {
/// for tests; defaults to a biometric/PIN prompt.
final Future Function()? appLockAuthenticator;
- /// Runtime OS permissions (notifications, camera). Overridable for tests;
+ /// Runtime OS permissions. Overridable for tests;
/// defaults to the `permission_handler` backed implementation.
final AppPermissions? permissions;
@@ -60,7 +60,6 @@ class _SettingsScreenState extends State {
AppSettings _settings = const AppSettings();
bool _isLoading = true;
bool _didResolveController = false;
- bool _cameraGranted = false;
AppPermissions get _permissions =>
widget.permissions ?? const PermissionHandlerAppPermissions();
@@ -546,21 +545,6 @@ class _SettingsScreenState extends State {
_SettingsSection(
title: 'Permissions',
children: [
- ListTile(
- key: const Key('settings-permission-camera'),
- leading: const Icon(Icons.photo_camera_outlined),
- title: const Text('Camera access'),
- subtitle: Text(
- _cameraGranted
- ? 'Granted — you can capture photos and video.'
- : 'Needed to capture photos/video for media and DCC.',
- ),
- trailing: _cameraGranted
- ? const Icon(Icons.check_circle_outline)
- : const Text('Grant'),
- onTap: _cameraGranted ? null : _requestCameraPermission,
- ),
- const Divider(height: 1),
SwitchListTile(
key: const Key('settings-analytics-consent'),
secondary: const Icon(Icons.insights_outlined),
@@ -949,19 +933,15 @@ class _SettingsScreenState extends State {
}
/// Reconciles permission-gated settings on entry: notifications can only be
- /// on while the OS permission is granted, and refreshes the camera status.
+ /// on while the OS permission is granted.
Future _refreshPermissionStatuses() async {
final hasNotifications = await _permissions.hasNotifications();
- final hasCamera = await _permissions.hasCamera();
if (!mounted) {
return;
}
if (_settings.notificationsEnabled && !hasNotifications) {
await _saveSettings(_settings.copyWith(notificationsEnabled: false));
}
- if (mounted && hasCamera != _cameraGranted) {
- setState(() => _cameraGranted = hasCamera);
- }
}
Future _toggleNotifications(bool value) async {
@@ -1001,33 +981,6 @@ class _SettingsScreenState extends State {
);
}
- Future _requestCameraPermission() async {
- final result = await _permissions.requestCamera();
- if (!mounted) {
- return;
- }
- if (result == AppPermissionResult.granted) {
- setState(() => _cameraGranted = true);
- return;
- }
- final permanentlyDenied = result == AppPermissionResult.permanentlyDenied;
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(
- content: Text(
- permanentlyDenied
- ? 'Camera is blocked. Enable it in system settings.'
- : 'Camera permission denied.',
- ),
- action: permanentlyDenied
- ? SnackBarAction(
- label: 'Settings',
- onPressed: () => unawaited(_permissions.openSettingsPage()),
- )
- : null,
- ),
- );
- }
-
Future _toggleAppLock(bool value) async {
// Turning off is immediate. Turning on first confirms the user can actually
// authenticate, so enabling it can never lock them out of the app.
diff --git a/pubspec.yaml b/pubspec.yaml
index 35dc346..dc1eebb 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
-version: 1.0.4+6
+version: 1.0.5+8
environment:
sdk: ^3.11.1
diff --git a/test/app_lock_gate_test.dart b/test/app_lock_gate_test.dart
index 9faf8cb..ba065e4 100644
--- a/test/app_lock_gate_test.dart
+++ b/test/app_lock_gate_test.dart
@@ -37,6 +37,26 @@ class _HostState extends State<_Host> {
}
}
+class _Probe extends StatefulWidget {
+ const _Probe({required this.onDisposed});
+
+ final VoidCallback onDisposed;
+
+ @override
+ State<_Probe> createState() => _ProbeState();
+}
+
+class _ProbeState extends State<_Probe> {
+ @override
+ void dispose() {
+ widget.onDisposed();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) => const Text('home content');
+}
+
void main() {
testWidgets('enabling app lock at runtime does not immediately lock', (
tester,
@@ -80,4 +100,35 @@ void main() {
expect(find.text('AndroidIRCX is locked'), findsOneWidget);
expect(find.text('home content'), findsNothing);
});
+
+ testWidgets('re-locking keeps the child session tree mounted', (
+ tester,
+ ) async {
+ var unlockCalls = 0;
+ var disposed = false;
+ await tester.pumpWidget(
+ MaterialApp(
+ home: AppLockGate(
+ enabled: true,
+ unlock: () async {
+ unlockCalls++;
+ return true;
+ },
+ child: _Probe(onDisposed: () => disposed = true),
+ ),
+ ),
+ );
+ await tester.pumpAndSettle();
+
+ expect(unlockCalls, 1);
+ expect(find.text('home content'), findsOneWidget);
+
+ final state = tester.state(find.byType(AppLockGate)) as dynamic;
+ state.didChangeAppLifecycleState(AppLifecycleState.paused);
+ await tester.pump();
+
+ expect(find.text('AndroidIRCX is locked'), findsOneWidget);
+ expect(disposed, isFalse);
+ expect(unlockCalls, 1);
+ });
}
diff --git a/test/chat_session_controller_test.dart b/test/chat_session_controller_test.dart
index 8a6ed1e..2a285fb 100644
--- a/test/chat_session_controller_test.dart
+++ b/test/chat_session_controller_test.dart
@@ -382,6 +382,36 @@ void main() {
});
});
+ test('joining the same channel with different case reuses one tab', () async {
+ const network = NetworkConfig(
+ id: 'dbase',
+ name: 'DBase',
+ host: 'irc.example.test',
+ port: 6697,
+ nickname: 'AndroidIRCX',
+ );
+ final controller = ChatSessionController(
+ network: network,
+ ircService: IrcService(),
+ );
+
+ await controller.joinChannel(
+ const JoinChannelRequest(channel: '#AndroidIRCX'),
+ );
+ await controller.joinChannel(
+ const JoinChannelRequest(channel: '#androidircx'),
+ );
+
+ final channelTabs = controller.tabs
+ .where((tab) => tab.type == ChatTabType.channel)
+ .toList(growable: false);
+ expect(channelTabs, hasLength(1));
+ expect(channelTabs.single.name, '#AndroidIRCX');
+ expect(controller.activeTabId, channelTabs.single.id);
+
+ controller.dispose();
+ });
+
test(
'app lifecycle pause flushes message history without reconnecting',
() async {
@@ -2747,8 +2777,9 @@ void main() {
transport.emit(':b!u@h PRIVMSG #two :hi');
await Future.delayed(Duration.zero);
- final serverTab =
- controller.tabs.firstWhere((tab) => tab.type == ChatTabType.server);
+ final serverTab = controller.tabs.firstWhere(
+ (tab) => tab.type == ChatTabType.server,
+ );
controller.selectTab(serverTab.id);
final startId = controller.activeTabId;
@@ -2779,8 +2810,9 @@ void main() {
transport.emit(':server 001 AndroidIRCX :Welcome');
await Future.delayed(Duration.zero);
await controller.measureLag();
- final pingLine =
- transport.sentLines.lastWhere((line) => line.startsWith('PING :LAG'));
+ final pingLine = transport.sentLines.lastWhere(
+ (line) => line.startsWith('PING :LAG'),
+ );
final token = pingLine.substring('PING :'.length);
transport.emit(':server PONG server :$token');
await Future.delayed(Duration.zero);
@@ -2844,10 +2876,13 @@ void main() {
transport.emit(':server 323 AndroidIRCX :End of /LIST');
await Future.delayed(Duration.zero);
- expect(controller.channelListing.map((entry) => entry.name),
- containsAll(['#dbase', '#flutter']));
- final dbase =
- controller.channelListing.firstWhere((entry) => entry.name == '#dbase');
+ expect(
+ controller.channelListing.map((entry) => entry.name),
+ containsAll(['#dbase', '#flutter']),
+ );
+ final dbase = controller.channelListing.firstWhere(
+ (entry) => entry.name == '#dbase',
+ );
expect(dbase.userCount, 42);
expect(dbase.topic, 'Main channel');
expect(controller.channelListInProgress, isFalse);
@@ -2929,10 +2964,7 @@ void main() {
isEmpty,
);
// The message is still delivered to the query tab, just not notified.
- expect(
- controller.tabs.any((tab) => tab.name == 'alice'),
- isTrue,
- );
+ expect(controller.tabs.any((tab) => tab.name == 'alice'), isTrue);
await sub.cancel();
controller.dispose();
@@ -3444,9 +3476,7 @@ void main() {
await controller.start();
Future emitCtcp(String body) async {
- transport.emit(
- ':alice!user@example PRIVMSG AndroidIRCX :$body',
- );
+ transport.emit(':alice!user@example PRIVMSG AndroidIRCX :$body');
await Future.delayed(Duration.zero);
await Future.delayed(Duration.zero);
}
@@ -3460,10 +3490,7 @@ void main() {
// Every CTCP reply must go out as a NOTICE (never PRIVMSG) so it cannot
// trigger a reply loop, and PING must echo the requester's token back.
- expect(
- transport.sentLines,
- contains('NOTICE alice :PING 999'),
- );
+ expect(transport.sentLines, contains('NOTICE alice :PING 999'));
expect(
transport.sentLines,
contains(
@@ -3488,8 +3515,7 @@ void main() {
);
expect(
transport.sentLines.any(
- (line) => line.startsWith('NOTICE alice :TIME ') &&
- line.endsWith(''),
+ (line) => line.startsWith('NOTICE alice :TIME ') && line.endsWith(''),
),
isTrue,
);
@@ -3521,7 +3547,9 @@ void main() {
await controller.handleComposerSubmit('/echo hello world');
expect(
- controller.activeMessages.any((message) => message.content == 'hello world'),
+ controller.activeMessages.any(
+ (message) => message.content == 'hello world',
+ ),
isTrue,
);
// /echo must never hit the wire.
@@ -3589,14 +3617,8 @@ void main() {
expect(transport.sentLines, contains('PRIVMSG #room :hello all'));
expect(transport.sentLines, contains('PRIVMSG #other :hello all'));
- expect(
- transport.sentLines,
- contains('PRIVMSG #room :ACTION waves'),
- );
- expect(
- transport.sentLines,
- contains('PRIVMSG #other :ACTION waves'),
- );
+ expect(transport.sentLines, contains('PRIVMSG #room :ACTION waves'));
+ expect(transport.sentLines, contains('PRIVMSG #other :ACTION waves'));
controller.dispose();
});
@@ -3628,8 +3650,9 @@ void main() {
final roomTab = controller.tabs.firstWhere((tab) => tab.name == '#room');
controller.selectTab(roomTab.id);
- final contents =
- controller.activeMessages.map((message) => message.content).toList();
+ final contents = controller.activeMessages
+ .map((message) => message.content)
+ .toList();
expect(contents, contains('before'));
expect(contents, isNot(contains('after')));
expect(contents, contains('hi'));
@@ -3671,8 +3694,9 @@ void main() {
final roomTab = controller.tabs.firstWhere((tab) => tab.name == '#room');
controller.selectTab(roomTab.id);
- final contents =
- controller.activeMessages.map((message) => message.content).toList();
+ final contents = controller.activeMessages
+ .map((message) => message.content)
+ .toList();
expect(contents, isNot(contains('spam')));
expect(contents, contains('welcome'));
@@ -3731,8 +3755,9 @@ void main() {
await Future.delayed(Duration.zero);
await controller.handleComposerSubmit('/clones #room');
- final contents =
- controller.activeMessages.map((message) => message.content).join('\n');
+ final contents = controller.activeMessages
+ .map((message) => message.content)
+ .join('\n');
expect(contents, contains('Clones detected in #room'));
expect(contents, contains('shared.example'));
expect(contents.contains('bob') && contents.contains('carol'), isTrue);
@@ -3768,54 +3793,69 @@ void main() {
controller.dispose();
});
- test('performs channel user actions through existing command paths', () async {
- final transport = _FakeTransport();
- final service = IrcService(transportConnector: (_) async => transport);
- final controller = ChatSessionController(
- network: const NetworkConfig(
- id: 'dbase',
- name: 'DBase',
- host: 'irc.example.test',
- port: 6697,
- nickname: 'AndroidIRCX',
- altNickname: 'AndroidIRCX_',
- ),
- ircService: service,
- );
+ test(
+ 'performs channel user actions through existing command paths',
+ () async {
+ final transport = _FakeTransport();
+ final service = IrcService(transportConnector: (_) async => transport);
+ final controller = ChatSessionController(
+ network: const NetworkConfig(
+ id: 'dbase',
+ name: 'DBase',
+ host: 'irc.example.test',
+ port: 6697,
+ nickname: 'AndroidIRCX',
+ altNickname: 'AndroidIRCX_',
+ ),
+ ircService: service,
+ );
- await controller.start();
- transport.emit(':alice!user@example PRIVMSG #room :hi');
- await Future.delayed(Duration.zero);
- final roomTab = controller.tabs.firstWhere((tab) => tab.name == '#room');
- controller.selectTab(roomTab.id);
+ await controller.start();
+ transport.emit(':alice!user@example PRIVMSG #room :hi');
+ await Future.delayed(Duration.zero);
+ final roomTab = controller.tabs.firstWhere((tab) => tab.name == '#room');
+ controller.selectTab(roomTab.id);
- await controller.performChannelUserAction('alice', ChannelUserAction.op);
- await controller.performChannelUserAction('alice', ChannelUserAction.voice);
- await controller.performChannelUserAction('@alice', ChannelUserAction.kick);
- await controller.performChannelUserAction('alice', ChannelUserAction.ban);
- await controller.performChannelUserAction('alice', ChannelUserAction.whois);
- await controller.performChannelUserAction('alice', ChannelUserAction.query);
+ await controller.performChannelUserAction('alice', ChannelUserAction.op);
+ await controller.performChannelUserAction(
+ 'alice',
+ ChannelUserAction.voice,
+ );
+ await controller.performChannelUserAction(
+ '@alice',
+ ChannelUserAction.kick,
+ );
+ await controller.performChannelUserAction('alice', ChannelUserAction.ban);
+ await controller.performChannelUserAction(
+ 'alice',
+ ChannelUserAction.whois,
+ );
+ await controller.performChannelUserAction(
+ 'alice',
+ ChannelUserAction.query,
+ );
- expect(transport.sentLines, contains('MODE #room +o alice'));
- expect(transport.sentLines, contains('MODE #room +v alice'));
- expect(
- transport.sentLines.any((line) => line.startsWith('KICK #room alice')),
- isTrue,
- );
- expect(transport.sentLines, contains('MODE #room +b alice'));
- expect(
- transport.sentLines.any((line) => line.startsWith('WHOIS alice')),
- isTrue,
- );
- expect(
- controller.tabs.any(
- (tab) => tab.type.name == 'query' && tab.name == 'alice',
- ),
- isTrue,
- );
+ expect(transport.sentLines, contains('MODE #room +o alice'));
+ expect(transport.sentLines, contains('MODE #room +v alice'));
+ expect(
+ transport.sentLines.any((line) => line.startsWith('KICK #room alice')),
+ isTrue,
+ );
+ expect(transport.sentLines, contains('MODE #room +b alice'));
+ expect(
+ transport.sentLines.any((line) => line.startsWith('WHOIS alice')),
+ isTrue,
+ );
+ expect(
+ controller.tabs.any(
+ (tab) => tab.type.name == 'query' && tab.name == 'alice',
+ ),
+ isTrue,
+ );
- controller.dispose();
- });
+ controller.dispose();
+ },
+ );
test('announces bouncer compatibility on registration', () async {
final transport = _FakeTransport();
@@ -3874,8 +3914,9 @@ void main() {
final roomTab = controller.tabs.firstWhere((tab) => tab.name == '#room');
controller.selectTab(roomTab.id);
- final contents =
- controller.activeMessages.map((message) => message.content).toList();
+ final contents = controller.activeMessages
+ .map((message) => message.content)
+ .toList();
expect(contents, contains('hello spam word'));
expect(contents, isNot(contains('another spam here')));
expect(contents, contains('clean message'));
@@ -3977,10 +4018,7 @@ void main() {
networkId: 'dbase',
tabId: roomTab.id,
);
- expect(
- stored.any((message) => message.content == 'hello history'),
- isTrue,
- );
+ expect(stored.any((message) => message.content == 'hello history'), isTrue);
controller.dispose();
});
@@ -4027,8 +4065,9 @@ void main() {
final roomTab = controller2.tabs.firstWhere((tab) => tab.name == '#room');
controller2.selectTab(roomTab.id);
- final contents =
- controller2.activeMessages.map((message) => message.content).toList();
+ final contents = controller2.activeMessages
+ .map((message) => message.content)
+ .toList();
expect(contents, contains('first message'));
expect(contents, contains('second message'));
diff --git a/test/device_unlock_session_test.dart b/test/device_unlock_session_test.dart
new file mode 100644
index 0000000..82074f8
--- /dev/null
+++ b/test/device_unlock_session_test.dart
@@ -0,0 +1,61 @@
+import 'dart:async';
+
+import 'package:androidircx/core/security/device_unlock_session.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+ test('reuses a recent successful unlock', () async {
+ var now = DateTime(2026, 8, 24, 15);
+ var calls = 0;
+ final session = DeviceUnlockSession(
+ reuseWindow: const Duration(minutes: 1),
+ now: () => now,
+ prompt: (_) async {
+ calls++;
+ return true;
+ },
+ );
+
+ expect(await session.authenticate(reason: 'Unlock app'), isTrue);
+ expect(await session.authenticate(reason: 'Unlock history'), isTrue);
+ expect(calls, 1);
+
+ now = now.add(const Duration(minutes: 2));
+ expect(await session.authenticate(reason: 'Unlock history'), isTrue);
+ expect(calls, 2);
+ });
+
+ test('coalesces concurrent unlock requests', () async {
+ var calls = 0;
+ final prompt = Completer();
+ final session = DeviceUnlockSession(
+ prompt: (_) {
+ calls++;
+ return prompt.future;
+ },
+ );
+
+ final first = session.authenticate(reason: 'Unlock app');
+ final second = session.authenticate(reason: 'Unlock history');
+ expect(calls, 1);
+
+ prompt.complete(true);
+ expect(await first, isTrue);
+ expect(await second, isTrue);
+ });
+
+ test('invalidate clears the cached unlock', () async {
+ var calls = 0;
+ final session = DeviceUnlockSession(
+ prompt: (_) async {
+ calls++;
+ return true;
+ },
+ );
+
+ expect(await session.authenticate(reason: 'Unlock app'), isTrue);
+ session.invalidate();
+ expect(await session.authenticate(reason: 'Unlock app'), isTrue);
+ expect(calls, 2);
+ });
+}
diff --git a/test/notification_permission_settings_test.dart b/test/notification_permission_settings_test.dart
index 340e5db..277707a 100644
--- a/test/notification_permission_settings_test.dart
+++ b/test/notification_permission_settings_test.dart
@@ -9,23 +9,16 @@ import 'package:shared_preferences/shared_preferences.dart';
class _FakePermissions implements AppPermissions {
_FakePermissions({
this.notifResult = AppPermissionResult.granted,
- this.camResult = AppPermissionResult.granted,
this.hasNotif = false,
});
AppPermissionResult notifResult;
- AppPermissionResult camResult;
bool hasNotif;
- bool hasCam = false;
int notifRequests = 0;
- int camRequests = 0;
@override
Future hasNotifications() async => hasNotif;
- @override
- Future hasCamera() async => hasCam;
-
@override
Future requestNotifications() async {
notifRequests++;
@@ -33,13 +26,6 @@ class _FakePermissions implements AppPermissions {
return notifResult;
}
- @override
- Future requestCamera() async {
- camRequests++;
- if (camResult == AppPermissionResult.granted) hasCam = true;
- return camResult;
- }
-
@override
Future openSettingsPage() async {}
}
@@ -138,28 +124,4 @@ void main() {
expect(saved.analyticsConsent, isTrue);
});
- testWidgets('granting camera permission shows granted state', (tester) async {
- final perms = _FakePermissions(camResult: AppPermissionResult.granted);
- await tester.pumpWidget(
- MaterialApp(home: SettingsScreen(permissions: perms)),
- );
- await tester.pump();
- await tester.pump(const Duration(milliseconds: 100));
- await tester.scrollUntilVisible(
- find.byKey(const Key('settings-permission-camera')),
- 250,
- scrollable: find.byType(Scrollable).first,
- );
- await tester.ensureVisible(
- find.byKey(const Key('settings-permission-camera')),
- );
- await tester.pumpAndSettle();
-
- await tester.tap(find.byKey(const Key('settings-permission-camera')));
- await tester.pumpAndSettle();
-
- expect(perms.camRequests, 1);
- expect(find.text('Granted — you can capture photos and video.'),
- findsOneWidget);
- });
}
diff --git a/test/onboarding_permission_test.dart b/test/onboarding_permission_test.dart
index fc9981b..75e257b 100644
--- a/test/onboarding_permission_test.dart
+++ b/test/onboarding_permission_test.dart
@@ -19,11 +19,7 @@ class _FakePermissions implements AppPermissions {
@override
Future hasNotifications() async => false;
- @override
- Future requestCamera() async =>
- AppPermissionResult.granted;
- @override
- Future hasCamera() async => false;
+
@override
Future openSettingsPage() async {}
}