CH-J SM Mobilepengurusan pelayan melalui SSH
Menu
Sumber yang diterbitkan

CH-J SM Mobile

Layari direktori dan fail untuk keluaran aplikasi tertentu.

Muat turun ZIP sumber
CH-J / SM Mobile 175 fail
sm-mobile-20261016T185235Z-8e93ecc1b59a Ditambah: · Dibina:
app_controller.dart Ditambah: Tarikh tidak direkodkan 13.6 KB · 434 baris
Kod Muat turun fail
1import 'dart:async';
2import 'dart:convert';
3import 'package:flutter/material.dart';
4import 'package:flutter/services.dart';
5import 'package:local_auth/local_auth.dart';
6import '../services/blob_store.dart';
7import '../services/documents.dart';
8import '../services/legal.dart';
9import '../services/secure_store.dart';
10import '../services/sftp_service.dart';
11import '../services/ssh_service.dart';
12import '../services/vault.dart';
13import 'failure.dart';
15const idleChoices = [1, 2, 5, 10, 15, 30, 60];
17/// Maps a platform error from the biometric key store to a message key.
18String biometricFailure(PlatformException e) {
19 final text = '${e.code} ${e.message} ${e.details}'.toLowerCase();
20 if (text.contains('-128') ||
21 text.contains('cancel') ||
22 text.contains('negative')) {
23 return 'biometricCancelled';
24 }
25 if (text.contains('lockout') || text.contains('-8')) {
26 return 'biometricLockout';
27 }
28 if (text.contains('not enrolled') ||
29 text.contains('none_enrolled') ||
30 text.contains('no_hardware') ||
31 text.contains('-7') ||
32 text.contains('-6')) {
33 return 'biometricUnavailable';
34 }
35 if (text.contains('invalidated') || text.contains('permanently')) {
36 return 'biometricInvalid';
37 }
38 return 'biometricFailed';
41class AppController extends ChangeNotifier with WidgetsBindingObserver {
42 final SecureStore store;
43 final BlobStore blobs;
44 late final Vault vault = Vault(store, blobs: blobs);
45 late final Legal legal = Legal(store);
46 late final SshService ssh = SshService(vault);
47 bool ready = false, accepted = false, initialized = false, foreground = true;
48 bool biometric = false, lockBackground = true, authenticating = false;
49 bool systemDialog = false;
50 String? startupError;
51 Locale? locale;
52 ThemeMode theme = ThemeMode.system;
53 int idleMinutes = 5, monitorSeconds = 10;
55 /// Editor preferences: font size, word wrap, tabs or spaces, indent width.
56 Map<String, dynamic> editorPrefs = {};
57 DateTime _lastActivity = DateTime.now();
58 Timer? _timer;
59 void Function()? onLocked;
61 /// Version of the biometric key storage. Version 2 accepts only strong
62 /// biometrics (no device passcode fallback) and is invalidated by the
63 /// system when the enrolled biometrics change.
64 int biometricVersion = 2;
66 /// Whether the automatic biometric prompt was already shown for the
67 /// current locked period; a failed or cancelled prompt is not repeated
68 /// until the user asks for it or the app returns from the background.
69 bool biometricAttempted = false;
71 /// A localization key explaining why biometric unlock is not available
72 /// right now (shown on the lock screen), or null.
73 String? biometricNotice;
75 SftpService? _sftp;
76 Object? _sftpClient;
77 Future<SftpService>? _opening;
79 /// One SFTP session per SSH connection, shared by the file browser and
80 /// the editor so closing one page never interrupts the other.
81 Future<SftpService> sftp() async {
82 final client = ssh.client; // throws 'disconnected'
83 if (_sftp != null && identical(_sftpClient, client)) return _sftp!;
84 if (_opening != null && identical(_sftpClient, client)) return _opening!;
85 _sftpClient = client;
86 final opening = _opening = client.sftp().then((s) {
87 if (!identical(_sftpClient, client)) {
88 s.close();
89 throw const Failure('disconnected');
90 }
91 return _sftp = SftpService(s);
92 });
93 try {
94 return await opening;
95 } finally {
96 if (identical(_opening, opening)) _opening = null;
97 }
98 }
100 /// An SFTP session to the server of [profileId], or 'connectToSave' when
101 /// the app is connected elsewhere (or not at all).
102 Future<SftpService> sftpFor(String profileId) async {
103 if (!ssh.connected || ssh.profile?.id != profileId) {
104 throw const Failure('connectToSave');
105 }
106 return await sftp();
107 }
109 /// Files handed over by other apps, opened once the Vault is unlocked.
110 final pendingIncoming = <DeviceDocument>[];
111 StreamSubscription<List<DeviceDocument>>? _incoming;
113 /// The directory shown in the SFTP browser, offered for "save as".
114 String? remoteDirectory;
116 /// Files on the device (system document picker); replaceable in tests.
117 DocumentPlatform documents = MethodChannelDocuments();
119 /// Open editors register here; on lock their unsaved buffers are kept as
120 /// encrypted drafts.
121 final draftSources = <Draft? Function()>{};
123 /// The store holding the biometric-gated copy of the Vault data key;
124 /// replaceable in tests.
125 late SecureStore Function(String reason) biometricStore = (reason) =>
126 DeviceStore.biometric(reason, negative: biometricNegativeText);
128 /// Localized prompt texts, kept current by the lock screen.
129 String biometricReasonText = 'SM Mobile';
130 String biometricNegativeText = 'Cancel';
131 Future<bool> Function() biometricSupported = () =>
132 LocalAuthentication().isDeviceSupported();
133 AppController(this.store, {BlobStore? blobs})
134 : blobs = blobs ?? MemoryBlobStore();
135 Future<void> initialize() async {
136 WidgetsBinding.instance.addObserver(this);
137 try {
138 final prefs = jsonDecode(await store.read('preferences') ?? '{}');
139 if (['cs', 'de', 'en'].contains(prefs['language'])) {
140 locale = Locale(prefs['language']);
141 }
142 theme = ThemeMode.values.firstWhere(
143 (t) => t.name == prefs['theme'],
144 orElse: () => ThemeMode.system,
145 );
146 biometric = prefs['biometric'] == true;
147 biometricVersion = prefs['biometricVersion'] is int
148 ? prefs['biometricVersion']
149 : 1;
150 if (biometric && biometricVersion < 2) {
151 // Version 1 keys also opened with the device passcode. They are not
152 // used any more; biometric unlock is set up again after the next
153 // unlock with the master password.
154 biometricNotice = 'biometricUpgrade';
155 }
156 lockBackground = prefs['lockBackground'] != false;
157 idleMinutes = idleChoices.contains(prefs['idleMinutes'])
158 ? prefs['idleMinutes']
159 : 5;
160 monitorSeconds = [5, 10, 30, 60].contains(prefs['monitorSeconds'])
161 ? prefs['monitorSeconds']
162 : 10;
163 if (prefs['editor'] is Map) {
164 editorPrefs = Map<String, dynamic>.from(prefs['editor']);
165 }
166 accepted = await legal.load();
167 initialized = await vault.exists();
168 } catch (e) {
169 startupError = e is Failure ? e.code : 'storageError';
170 }
171 try {
172 _incoming = documents.incoming.listen((docs) {
173 pendingIncoming.addAll(docs);
174 notifyListeners();
175 });
176 } catch (_) {
177 /* No document support on this platform. */
178 }
179 ready = true;
180 _timer = Timer.periodic(const Duration(seconds: 1), (_) {
181 if (vault.unlocked &&
182 DateTime.now().difference(_lastActivity) >=
183 Duration(minutes: idleMinutes)) {
184 lock();
185 }
186 });
187 notifyListeners();
188 }
190 void activity() {
191 _lastActivity = DateTime.now();
192 }
194 void changed() {
195 notifyListeners();
196 }
198 Future<T> withSystemDialog<T>(Future<T> Function() action) async {
199 systemDialog = true;
200 try {
201 return await action();
202 } finally {
203 systemDialog = false;
204 activity();
205 }
206 }
208 Future<void> saveSettings() async {
209 await store.write(
210 'preferences',
211 jsonEncode({
212 'language': locale?.languageCode,
213 'theme': theme.name,
214 'biometric': biometric,
215 'biometricVersion': biometricVersion,
216 'lockBackground': lockBackground,
217 'idleMinutes': idleMinutes,
218 'monitorSeconds': monitorSeconds,
219 'editor': editorPrefs,
220 }),
221 );
222 notifyListeners();
223 }
225 Future<void> acceptLicense() async {
226 await legal.accept(guidanceDisplayed: true);
227 accepted = true;
228 notifyListeners();
229 }
231 Future<void> openVault(String password, {bool create = false}) async {
232 if (!accepted) throw const Failure('licenseError');
233 if (create) {
234 try {
235 await vault.create(password);
236 } finally {
237 initialized = await vault.exists();
238 notifyListeners();
239 }
240 } else {
241 await vault.unlock(password);
242 }
243 activity();
244 biometricAttempted = false;
245 if (biometric && biometricVersion < 2) await _upgradeBiometric();
246 notifyListeners();
247 }
249 /// Moves a version 1 biometric key (which the device passcode could also
250 /// release) to version 2 storage. Writing does not prompt on iOS; Android
251 /// asks for a biometric confirmation, which the user may decline.
252 Future<void> _upgradeBiometric() async {
253 try {
254 await vault.enableBiometric(biometricStore(biometricReasonText));
255 biometricVersion = 2;
256 biometricNotice = null;
257 } catch (_) {
258 biometric = false;
259 biometricVersion = 2;
260 biometricNotice = 'biometricReenable';
261 }
262 await _deleteLegacyBiometric();
263 try {
264 await saveSettings();
265 } catch (_) {
266 /* Retried with the next settings change. */
267 }
268 }
270 Future<void> _deleteLegacyBiometric() async {
271 try {
272 await DeviceStore.legacyBiometric().delete('key');
273 } catch (_) {
274 /* Nothing stored or not accessible; it is never read again. */
275 }
276 }
278 /// Unlocks with the data key released by biometric authentication.
279 ///
280 /// The key is stored by the system so that only a successful strong
281 /// biometric check releases it; a `true` from a dialog alone opens
282 /// nothing. On failure the lock screen offers the master password.
283 Future<void> biometricUnlock(String reason) async {
284 if (!accepted) throw const Failure('licenseError');
285 if (!biometric || biometricVersion < 2) {
286 throw const Failure('biometricUnavailable');
287 }
288 authenticating = true;
289 biometricAttempted = true;
290 notifyListeners();
291 try {
292 await withSystemDialog(
293 () => vault.unlockBiometric(biometricStore(reason)),
294 );
295 biometricNotice = null;
296 activity();
297 } on Failure catch (e) {
298 if (e.code == 'biometricInvalid') {
299 // Removed by the system (biometrics changed) or stale. It can never
300 // open the Vault again; delete it and ask for the password.
301 biometric = false;
302 biometricNotice = 'biometricInvalid';
303 try {
304 await biometricStore(reason).delete('key');
305 } catch (_) {
306 /* Already gone. */
307 }
308 try {
309 await saveSettings();
310 } catch (_) {
311 /* Biometric stays off in memory. */
312 }
313 }
314 rethrow;
315 } on PlatformException catch (e) {
316 throw Failure(biometricFailure(e));
317 } finally {
318 authenticating = false;
319 notifyListeners();
320 }
321 }
323 Future<void> setBiometric(bool enable, String reason) async {
324 authenticating = true;
325 notifyListeners();
326 try {
327 if (enable) {
328 if (!await biometricSupported()) {
329 throw const Failure('biometricUnavailable');
330 }
331 // Writing the key needs no separate yes/no dialog: on Android the
332 // write itself is confirmed with a strong biometric, and on iOS the
333 // first read is. A failed write leaves biometric unlock off.
334 await withSystemDialog(
335 () => vault.enableBiometric(biometricStore(reason)),
336 );
337 biometricVersion = 2;
338 biometricNotice = null;
339 } else {
340 await biometricStore(reason).delete('key');
341 }
342 biometric = enable;
343 await saveSettings();
344 } on PlatformException catch (e) {
345 throw Failure(biometricFailure(e));
346 } finally {
347 authenticating = false;
348 notifyListeners();
349 }
350 }
352 /// Changes the master password. The Vault gets a new data key, so a
353 /// biometric key stored before the change can no longer open it; when
354 /// biometric unlock is on, the new key is stored in its place, or
355 /// biometric unlock is turned off if that is not possible.
356 Future<void> changePassword(
357 String current,
358 String next,
359 String reason,
360 ) async {
361 await vault.changePassword(current, next);
362 activity();
363 if (!biometric) return;
364 try {
365 await withSystemDialog(
366 () => vault.enableBiometric(biometricStore(reason)),
367 );
368 biometricVersion = 2;
369 } catch (_) {
370 biometric = false;
371 biometricNotice = 'biometricReenable';
372 try {
373 await biometricStore(reason).delete('key');
374 } catch (_) {
375 /* The stored key no longer opens the Vault. */
376 }
377 await saveSettings();
378 throw const Failure('passwordChangedBiometricOff');
379 }
380 await saveSettings();
381 }
383 void lock() {
384 final drafts = <Draft>[];
385 for (final source in draftSources.toList()) {
386 try {
387 final draft = source();
388 if (draft != null) drafts.add(draft);
389 } catch (_) {
390 /* A broken editor must not prevent locking. */
391 }
392 }
393 ssh.disconnect(clear: true);
394 vault.lock(drafts: drafts);
395 biometricAttempted = false;
396 onLocked?.call();
397 notifyListeners();
398 }
400 @override
401 void didChangeAppLifecycleState(AppLifecycleState state) {
402 foreground = state == AppLifecycleState.resumed;
403 if ((state == AppLifecycleState.paused ||
404 state == AppLifecycleState.hidden) &&
405 !systemDialog) {
406 // Returning from the background offers the biometric prompt again.
407 biometricAttempted = false;
408 }
409 if (state == AppLifecycleState.resumed) {
410 if (vault.unlocked &&
411 DateTime.now().difference(_lastActivity) >=
412 Duration(minutes: idleMinutes)) {
413 lock();
414 }
415 } else if ((state == AppLifecycleState.hidden ||
416 state == AppLifecycleState.paused ||
417 state == AppLifecycleState.detached) &&
418 !systemDialog &&
419 lockBackground) {
420 lock();
421 }
422 notifyListeners();
423 }
425 @override
426 void dispose() {
427 _timer?.cancel();
428 _incoming?.cancel();
429 WidgetsBinding.instance.removeObserver(this);
430 ssh.dispose();
431 vault.lock();
432 super.dispose();
433 }
SHA-256c41b2596df8aa3f9c08d3e9b3f5062af518cf44417188b516cf69638353198af

CH-J Proprietary Software License 1.14 · Kod sumber disediakan di bawah CH-J Proprietary Software License 1.14. Ketersediaannya tidak mengubah terma lesen atau memberikan hak tambahan.

SHA-256 arkib46e427a43e87af091004f36145590bda8fe90086dfb0f631afeb5029fb0b5655
Fail