lock_controller.dart 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  1. import 'dart:async';
  2. import 'package:flutter/foundation.dart';
  3. import '../../data/services/pin_service.dart';
  4. /// Enforces the app PIN.
  5. ///
  6. /// Deliberately simple: the PIN is checked only while the launcher process is
  7. /// starting. There is no re-lock on resume or screen-off. Setting or changing
  8. /// the PIN locks once immediately so the new code is confirmed in use.
  9. class LockController extends ChangeNotifier {
  10. LockController(this._pinService);
  11. final PinService _pinService;
  12. bool _initialized = false;
  13. bool _pinSet = false;
  14. bool _locked = false;
  15. String? _errorMessage;
  16. int _failedAttempts = 0;
  17. DateTime? _cooldownUntil;
  18. Timer? _cooldownTimer;
  19. bool _disposed = false;
  20. bool get initialized => _initialized;
  21. bool get pinSet => _pinSet;
  22. bool get isLocked => _locked;
  23. String? get errorMessage => _errorMessage;
  24. bool get isCoolingDown {
  25. final until = _cooldownUntil;
  26. return until != null && until.isAfter(DateTime.now());
  27. }
  28. int get cooldownSecondsRemaining {
  29. final until = _cooldownUntil;
  30. if (until == null) return 0;
  31. final remaining = until.difference(DateTime.now()).inSeconds;
  32. return remaining < 0 ? 0 : remaining;
  33. }
  34. Future<void> initialize() async {
  35. try {
  36. _pinSet = await _pinService.isPinSet();
  37. _locked = _pinSet;
  38. } catch (_) {
  39. // If secure storage cannot be read, do not fabricate a PIN. The security
  40. // section exposes the error so the issue is visible rather than silent.
  41. _pinSet = false;
  42. _locked = false;
  43. _errorMessage = 'Secure storage is unavailable on this device.';
  44. }
  45. _initialized = true;
  46. _notify();
  47. }
  48. Future<bool> unlock(String pin) async {
  49. if (!_pinSet) {
  50. _locked = false;
  51. _notify();
  52. return true;
  53. }
  54. if (isCoolingDown) {
  55. _errorMessage =
  56. 'Too many attempts. Try again in ${cooldownSecondsRemaining}s.';
  57. _notify();
  58. return false;
  59. }
  60. _errorMessage = null;
  61. bool valid;
  62. try {
  63. valid = await _pinService.verify(pin);
  64. } catch (_) {
  65. _errorMessage = 'Could not verify the PIN. Try again.';
  66. _notify();
  67. return false;
  68. }
  69. if (valid) {
  70. _locked = false;
  71. _failedAttempts = 0;
  72. _cooldownUntil = null;
  73. _errorMessage = null;
  74. _cooldownTimer?.cancel();
  75. _notify();
  76. return true;
  77. }
  78. _recordFailure();
  79. return false;
  80. }
  81. Future<String?> setPin(String pin) async {
  82. try {
  83. await _pinService.setPin(pin);
  84. } catch (_) {
  85. return 'Could not save the PIN.';
  86. }
  87. _pinSet = true;
  88. // The PIN is only requested on the next cold start by design. Setting or
  89. // changing it does not lock the current session.
  90. _failedAttempts = 0;
  91. _cooldownUntil = null;
  92. _cooldownTimer?.cancel();
  93. _errorMessage = null;
  94. _notify();
  95. return null;
  96. }
  97. Future<String?> changePin({
  98. required String currentPin,
  99. required String newPin,
  100. }) async {
  101. final verified = await _verifyCurrent(currentPin);
  102. if (!verified) {
  103. return _errorMessage ?? 'Current PIN is incorrect.';
  104. }
  105. return setPin(newPin);
  106. }
  107. Future<String?> removePin(String currentPin) async {
  108. final verified = await _verifyCurrent(currentPin);
  109. if (!verified) {
  110. return _errorMessage ?? 'Current PIN is incorrect.';
  111. }
  112. try {
  113. await _pinService.clearPin();
  114. } catch (_) {
  115. return 'Could not remove the PIN.';
  116. }
  117. _pinSet = false;
  118. _locked = false;
  119. _errorMessage = null;
  120. _failedAttempts = 0;
  121. _cooldownUntil = null;
  122. _cooldownTimer?.cancel();
  123. _notify();
  124. return null;
  125. }
  126. /// Verifies the current PIN without changing the lock state. Used by the
  127. /// change/remove flows in Settings.
  128. Future<bool> verifyPin(String pin) => _verifyCurrent(pin);
  129. Future<bool> _verifyCurrent(String pin) async {
  130. if (isCoolingDown) {
  131. _errorMessage =
  132. 'Too many attempts. Try again in ${cooldownSecondsRemaining}s.';
  133. _notify();
  134. return false;
  135. }
  136. try {
  137. final valid = await _pinService.verify(pin);
  138. if (!valid) {
  139. _recordFailure();
  140. return false;
  141. }
  142. _failedAttempts = 0;
  143. _errorMessage = null;
  144. _notify();
  145. return true;
  146. } catch (_) {
  147. _errorMessage = 'Could not verify the PIN.';
  148. _notify();
  149. return false;
  150. }
  151. }
  152. void _recordFailure() {
  153. _failedAttempts++;
  154. if (_failedAttempts >= 5) {
  155. _failedAttempts = 0;
  156. _cooldownUntil = DateTime.now().add(const Duration(seconds: 30));
  157. _cooldownTimer?.cancel();
  158. _cooldownTimer = Timer(const Duration(seconds: 30), () {
  159. _errorMessage = null;
  160. _notify();
  161. });
  162. _errorMessage = 'Too many attempts. Try again in 30s.';
  163. } else {
  164. _errorMessage = 'Incorrect PIN. Try again.';
  165. }
  166. _notify();
  167. }
  168. void clearError() {
  169. if (_errorMessage == null) return;
  170. _errorMessage = null;
  171. _notify();
  172. }
  173. void _notify() {
  174. if (!_disposed) notifyListeners();
  175. }
  176. @override
  177. void dispose() {
  178. _disposed = true;
  179. _cooldownTimer?.cancel();
  180. super.dispose();
  181. }
  182. }