455 lines
20 KiB
Dart
455 lines
20 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:path/path.dart' as p;
|
|
import 'package:path_provider/path_provider.dart';
|
|
|
|
import '../../core/webdav.dart';
|
|
import '../../services/secret_store.dart';
|
|
import '../../l10n/tr.dart';
|
|
import '../settings.dart';
|
|
|
|
/// 설정 파일 읽기·쓰기
|
|
/// 설정 파일 읽기 · 쓰기.
|
|
/// - 쓰기: 임시 파일에 다 쓴 뒤 바꿔치기 (쓰는 도중 앱이 끝나도 settings.json 이 깨지지 않게).
|
|
/// 바꿔치기 전의 정상 파일은 settings.json.bak 으로 남긴다.
|
|
/// - 읽기: settings.json 을 읽지 못하면 settings.json.bak 으로 되살린다. 읽지 못한 파일은 지우지 않고
|
|
/// settings.broken-(시각).json 으로 보관한다 (다음 저장에 덮여 사라지지 않게). 무슨 일이 있었는지는 [problem].
|
|
class SettingsStore {
|
|
final String? _path;
|
|
|
|
/// 비밀 값 (WebDAV 비밀번호 · OpenSubtitles) 을 두는 곳. 경로를 직접 주면 (시험) 메모리
|
|
final SecretStore secrets;
|
|
|
|
SettingsStore([this._path, SecretStore? secrets])
|
|
: secrets = secrets ?? (_path == null ? SecretStore.platform() : MemorySecretStore());
|
|
|
|
/// 안전 저장소를 읽을 수 있었는지 (못 읽었으면 저장소를 건드리지 않는다 - 지워 버리지 않게)
|
|
bool _secretsOk = true;
|
|
|
|
/// 안전 저장소에 마지막으로 쓴 (읽은) 값 - 바뀐 것만 쓴다
|
|
Map<String, String> _known = {};
|
|
|
|
/// 설정 파일에 예전부터 평문으로 있던 값 (저장소 열쇠 → 값). 안전 저장소에 들어가기 전까지는 파일에서 지우지 않는다 (40-1).
|
|
/// 새로 넣은 값은 저장소에 못 쓰더라도 평문으로 쓰지 않는다 (사용자 결정) - 대신 [secretIssue] 로 알린다.
|
|
final Map<String, String> _filePlain = {};
|
|
|
|
/// 안전 저장소 문제 (화면 위 알림 · [다시 시도]). null = 문제 없음
|
|
final secretIssue = ValueNotifier<SecretIssue?>(null);
|
|
|
|
/// 안전 저장소의 열쇠
|
|
static const _osKeys = {
|
|
'openSubtitlesKey': 'os.key',
|
|
'openSubtitlesUser': 'os.user',
|
|
'openSubtitlesPassword': 'os.password',
|
|
};
|
|
static String _davKey(String id) => 'dav.pw.$id';
|
|
static String _sftpPw(String id) => 'sftp.pw.$id';
|
|
static String _sftpKey(String id) => 'sftp.key.$id';
|
|
static String _sftpKp(String id) => 'sftp.kp.$id';
|
|
static String _smbPw(String id) => 'smb.pw.$id';
|
|
static String _ftpPw(String id) => 'ftp.pw.$id';
|
|
static String _aiKey(String id) => 'ai.key.$id';
|
|
static const _davServers = 'dav.servers';
|
|
|
|
static String _os(AppSettings s, String k) => switch (k) {
|
|
'openSubtitlesKey' => s.mkv.openSubtitlesKey,
|
|
'openSubtitlesUser' => s.mkv.openSubtitlesUser,
|
|
_ => s.mkv.openSubtitlesPassword,
|
|
};
|
|
|
|
/// 지금 설정의 비밀 값 (빈 값은 넣지 않음 = 저장소에서 지움)
|
|
/// 규칙 (관리자 10/21): 새 종류의 비밀 값은 지금 꼴 ([secretForms]) 과 앞부분이 겹치지 않는 새 머리글 (dav2. 등) 로 만든다 -
|
|
/// 겹치면 그 꼴을 아는 예전 판이 저장할 때 "없어진 항목" 으로 보고 지운다. 새 꼴은 [secretForms] 에도 더한다
|
|
static Map<String, String> _secretsOf(AppSettings s) => !s.general.rememberPasswords ? const {} : {
|
|
for (final e in _osKeys.entries)
|
|
if (_os(s, e.key).isNotEmpty) e.value: _os(s, e.key),
|
|
for (final x in s.remote.webdavServers)
|
|
if (x.password.isNotEmpty) _davKey(x.id): x.password,
|
|
for (final x in s.aiImage.services)
|
|
if (x.apiKey.isNotEmpty) _aiKey(x.id): x.apiKey,
|
|
for (final x in s.remote.sftpServers) ...{
|
|
if (x.password.isNotEmpty) _sftpPw(x.id): x.password,
|
|
if (x.privateKey.isNotEmpty) _sftpKey(x.id): x.privateKey,
|
|
if (x.keyPassphrase.isNotEmpty) _sftpKp(x.id): x.keyPassphrase,
|
|
},
|
|
for (final x in s.remote.smbServers)
|
|
if (x.password.isNotEmpty) _smbPw(x.id): x.password,
|
|
for (final x in s.remote.ftpServers)
|
|
if (x.password.isNotEmpty) _ftpPw(x.id): x.password,
|
|
// 서버 목록 (비밀번호 없이): 예전 판이 설정 파일에서 서버 목록을 지워도 되살릴 수 있게 (40)
|
|
if (s.remote.webdavServers.isNotEmpty) _davServers: jsonEncode([for (final x in s.remote.webdavServers) x.toJson()]),
|
|
};
|
|
|
|
/// 저장소에 못 넣은 예전 평문 값을 설정 JSON 에 그대로 둔다
|
|
static void _putPlain(Map<String, Object?> j, String key, String value) {
|
|
for (final e in _osKeys.entries) {
|
|
if (e.value == key) j[e.key] = value;
|
|
}
|
|
if (key.startsWith('ai.key.')) {
|
|
final id = key.substring('ai.key.'.length);
|
|
for (final x in (j['aiServices'] as List?) ?? const []) {
|
|
if (x is Map && '${x['id']}' == id) x['apiKey'] = value;
|
|
}
|
|
}
|
|
if (key.startsWith('dav.pw.')) {
|
|
final id = key.substring('dav.pw.'.length);
|
|
for (final x in (j['webdavServers'] as List?) ?? const []) {
|
|
if (x is Map && '${x['id']}' == id) x['password'] = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 비어 있는 비밀 값을 안전 저장소의 것으로 채운다 (파일에 평문이 있으면 그것을 쓰고 [_filePlain] 에 기억)
|
|
void _fill(AppSettings s, Map<String, String> stored) {
|
|
final os = <String, String>{};
|
|
for (final e in _osKeys.entries) {
|
|
final v = _os(s, e.key);
|
|
os[e.key] = v.isNotEmpty ? v : stored[e.value] ?? '';
|
|
}
|
|
s
|
|
..mkv.openSubtitlesKey = os['openSubtitlesKey']!
|
|
..mkv.openSubtitlesUser = os['openSubtitlesUser']!
|
|
..mkv.openSubtitlesPassword = os['openSubtitlesPassword']!;
|
|
s.remote.webdavServers = [
|
|
for (final x in s.remote.webdavServers)
|
|
x.password.isNotEmpty ? x : x.copyWith(password: stored[_davKey(x.id)] ?? ''),
|
|
];
|
|
s.aiImage.services = [
|
|
for (final x in s.aiImage.services) x.apiKey.isNotEmpty ? x : x.copyWith(apiKey: stored[_aiKey(x.id)] ?? ''),
|
|
];
|
|
s.remote.sftpServers = [
|
|
for (final x in s.remote.sftpServers)
|
|
x.copyWith(
|
|
password: x.password.isNotEmpty ? x.password : stored[_sftpPw(x.id)] ?? '',
|
|
privateKey: x.privateKey.isNotEmpty ? x.privateKey : stored[_sftpKey(x.id)] ?? '',
|
|
keyPassphrase: x.keyPassphrase.isNotEmpty ? x.keyPassphrase : stored[_sftpKp(x.id)] ?? '',
|
|
),
|
|
];
|
|
s.remote.smbServers = [
|
|
for (final x in s.remote.smbServers) x.password.isNotEmpty ? x : x.copyWith(password: stored[_smbPw(x.id)] ?? ''),
|
|
];
|
|
s.remote.ftpServers = [
|
|
for (final x in s.remote.ftpServers) x.password.isNotEmpty ? x : x.copyWith(password: stored[_ftpPw(x.id)] ?? ''),
|
|
];
|
|
}
|
|
|
|
/// 읽은 설정에 안전 저장소의 비밀 값을 채운다. 파일에 예전 평문 값이 있으면 저장소로 옮기고 파일에서 지운다 (54).
|
|
Future<void> _loadSecrets(AppSettings s) async {
|
|
Map<String, String> stored;
|
|
try {
|
|
stored = await secrets.readAll();
|
|
_secretsOk = true;
|
|
} catch (_) {
|
|
// TODO(M0): 비밀 저장소를 못 읽으면 빈 것으로 보고 이어 간다 - 마스터가 없는 것으로 보여 문이 열린다 (보안).
|
|
// 설정 나누기 (A) 뒤 따로 고친다 (관리자 10/22). 나누기 동안 동작은 그대로
|
|
stored = {};
|
|
_secretsOk = false;
|
|
}
|
|
_known = Map.of(stored);
|
|
_filePlain.clear();
|
|
// 예전 판이 서버 목록을 지운 설정 파일 (항목 자체가 없음) → 저장소의 목록으로
|
|
if (!s.extra.containsKey('webdavServers') && stored[_davServers] != null) {
|
|
try {
|
|
s.remote.webdavServers = [
|
|
for (final x in jsonDecode(stored[_davServers]!) as List)
|
|
if (x is Map) DavServer.fromJson(x),
|
|
];
|
|
} catch (_) {}
|
|
}
|
|
// 파일의 평문 값 (예전 판 · 저장소에 못 넣었던 것) 기억
|
|
for (final e in _osKeys.entries) {
|
|
final v = _os(s, e.key);
|
|
if (v.isNotEmpty) _filePlain[e.value] = v;
|
|
}
|
|
for (final x in s.remote.webdavServers) {
|
|
if (x.password.isNotEmpty) _filePlain[_davKey(x.id)] = x.password;
|
|
}
|
|
for (final x in s.aiImage.services) {
|
|
if (x.apiKey.isNotEmpty) _filePlain[_aiKey(x.id)] = x.apiKey;
|
|
}
|
|
_fill(s, stored);
|
|
secretIssue.value = !_secretsOk
|
|
? const SecretIssue(SecretIssueKind.readFailed)
|
|
: secrets.lostCopy != null
|
|
? SecretIssue(SecretIssueKind.lost, kept: secrets.lostCopy)
|
|
: null;
|
|
// 옮기기: 저장소에 쓰고 파일에서 지운다 (못 쓴 것은 파일에 그대로)
|
|
if (_filePlain.isNotEmpty && _secretsOk) await save(s);
|
|
// 백업으로 되살렸거나 예전 이름의 파일을 옮겨 왔으면, 저장소에 다 들어간 뒤 그 파일들의 평문도 지운다 (40-4)
|
|
if (_secretsOk && _filePlain.isEmpty) {
|
|
for (final f in _scrub) {
|
|
await _scrubFile(f);
|
|
}
|
|
}
|
|
_scrub.clear();
|
|
}
|
|
|
|
/// 평문 비밀 값이 남아 있을 수 있는 파일 (.bak 으로 되살림 · jj_capcut 에서 옮김)
|
|
final List<File> _scrub = [];
|
|
|
|
static Future<void> _scrubFile(File f) async {
|
|
try {
|
|
if (!await f.exists()) return;
|
|
final j = jsonDecode(await f.readAsString()) as Map<String, dynamic>;
|
|
final clean = AppSettings.stripSecrets(j);
|
|
if (jsonEncode(clean) == jsonEncode(j)) return;
|
|
await f.writeAsString(const JsonEncoder.withIndent(' ').convert(clean), flush: true);
|
|
} catch (_) {}
|
|
}
|
|
|
|
/// 깨진 설정 파일의 보관본에서 비밀 값을 지운다 (JSON 으로 읽을 수 없으니 글자로)
|
|
static String redactSecrets(String text) => text.replaceAllMapped(
|
|
RegExp(r'"(openSubtitlesKey|openSubtitlesUser|openSubtitlesPassword|password|apiKey|privateKey|keyPassphrase)"\s*:\s*"(?:[^"\\]|\\.)*"'),
|
|
(m) => '"${m[1]}": ""');
|
|
|
|
/// 읽기 오류를 알릴 글 (파일 내용은 넣지 않음 - JSON 오류 메시지에는 원문 일부가 들어간다)
|
|
static String _describe(Object e) =>
|
|
e is FormatException ? 'FormatException: ${e.message}${e.offset != null ? ' (offset ${e.offset})' : ''}' : '$e';
|
|
|
|
/// [다시 시도]: 안전 저장소를 다시 읽고 (못 읽었으면) 저장하지 못한 값을 다시 쓴다. 문제가 없어지면 true
|
|
Future<bool> retrySecrets(AppSettings s) async {
|
|
if (!_secretsOk) {
|
|
try {
|
|
final stored = await secrets.readAll();
|
|
_secretsOk = true;
|
|
_known = Map.of(stored);
|
|
_fill(s, stored);
|
|
} catch (_) {
|
|
secretIssue.value = const SecretIssue(SecretIssueKind.readFailed);
|
|
return false;
|
|
}
|
|
}
|
|
await save(s);
|
|
final i = secretIssue.value;
|
|
if (i?.kind == SecretIssueKind.readFailed) secretIssue.value = null;
|
|
return secretIssue.value == null || secretIssue.value!.kind == SecretIssueKind.lost;
|
|
}
|
|
|
|
/// 마지막 [load] 에서 생긴 일 (null = 문제 없음). 앱이 켜질 때 사용자에게 알린다.
|
|
SettingsLoadProblem? problem;
|
|
|
|
Future<String> _file() async =>
|
|
_path ?? p.join((await getApplicationSupportDirectory()).path, 'settings.json');
|
|
|
|
static AppSettings _parse(String text) => AppSettings.fromJson(jsonDecode(text) as Map<String, dynamic>);
|
|
|
|
Future<AppSettings> load() async {
|
|
final s = await _loadFile();
|
|
await _loadSecrets(s);
|
|
return s;
|
|
}
|
|
|
|
Future<AppSettings> _loadFile() async {
|
|
problem = null;
|
|
final f = File(await _file());
|
|
try {
|
|
// 이전 이름(jj_capcut) 의 설정 파일이 있으면 옮겨 온다
|
|
final legacy = File(p.join(p.dirname(f.parent.path), 'jj_capcut', 'settings.json'));
|
|
if (_path == null && !await f.exists() && await legacy.exists()) {
|
|
await f.parent.create(recursive: true);
|
|
await legacy.copy(f.path);
|
|
_scrub.add(legacy);
|
|
}
|
|
} catch (_) {}
|
|
final bak = File('${f.path}.bak');
|
|
if (!await f.exists()) {
|
|
// 바꿔치기 도중 끝나 settings.json 만 없는 경우: 백업이 있으면 그것으로
|
|
if (await bak.exists()) {
|
|
try {
|
|
final s = _parse(await bak.readAsString());
|
|
problem = SettingsLoadProblem(restoredFromBackup: true, brokenCopy: null, error: tr('settings.json 없음'));
|
|
_scrub.add(bak);
|
|
return s;
|
|
} catch (_) {}
|
|
}
|
|
return AppSettings();
|
|
}
|
|
String? text;
|
|
Object? error;
|
|
try {
|
|
text = await f.readAsString();
|
|
return _parse(text);
|
|
} catch (e) {
|
|
error = e;
|
|
}
|
|
// 읽지 못함: 깨진 파일을 보관하고 백업으로
|
|
String? kept;
|
|
try {
|
|
final stamp = DateTime.now().toIso8601String().replaceAll(':', '').split('.').first;
|
|
kept = p.join(f.parent.path, 'settings.broken-$stamp.json');
|
|
// 비밀 값은 지우고 보관 (40-4)
|
|
await File(kept).writeAsString(redactSecrets(utf8.decode(await f.readAsBytes(), allowMalformed: true)));
|
|
} catch (_) {
|
|
kept = null;
|
|
}
|
|
if (await bak.exists()) {
|
|
try {
|
|
final s = _parse(await bak.readAsString());
|
|
problem = SettingsLoadProblem(restoredFromBackup: true, brokenCopy: kept, error: _describe(error));
|
|
_scrub.add(bak);
|
|
return s;
|
|
} catch (_) {}
|
|
}
|
|
problem = SettingsLoadProblem(restoredFromBackup: false, brokenCopy: kept, error: _describe(error));
|
|
return AppSettings();
|
|
}
|
|
|
|
/// 한 번에 하나씩 쓴다 (빠르게 여러 번 바꿔도 파일이 섞이지 않게)
|
|
Future<void> _last = Future.value();
|
|
|
|
Future<void> save(AppSettings s) {
|
|
final j = s.toJson();
|
|
final values = _secretsOf(s);
|
|
return _last = _last.catchError((_) {}).then((_) async {
|
|
final unsaved = await _writeSecrets(values);
|
|
// 40-1: 저장소에 들어가지 못한 값 중 파일에 원래 평문으로 있던 것은 지우지 않고 그대로 둔다
|
|
for (final k in _filePlain.keys.toList()) {
|
|
if (!values.containsKey(k) || !unsaved.contains(k)) {
|
|
_filePlain.remove(k); // 사용자가 지웠거나 저장소에 들어감
|
|
} else {
|
|
_putPlain(j, k, _filePlain[k]!);
|
|
}
|
|
}
|
|
final missing = unsaved.where((k) => k != _davServers).toList();
|
|
if (missing.isNotEmpty) {
|
|
// 91: 새로 넣은 값이 저장되지 않았으면, 앱을 끈 뒤 어떻게 되는지 (예전 값으로 돌아감 / 사라짐)
|
|
var reverts = false, lost = false;
|
|
for (final k in missing) {
|
|
final old = _filePlain[k] ?? _known[k];
|
|
if (old == values[k]) continue; // 예전 평문 그대로 (파일에 남아 있음)
|
|
if (old != null) {
|
|
reverts = true;
|
|
} else {
|
|
lost = true;
|
|
}
|
|
}
|
|
secretIssue.value = SecretIssue(_secretsOk ? SecretIssueKind.writeFailed : SecretIssueKind.readFailed,
|
|
revertsToOld: reverts, lostOnExit: lost);
|
|
} else if (secretIssue.value?.kind == SecretIssueKind.writeFailed) {
|
|
secretIssue.value = null;
|
|
}
|
|
await _write(const JsonEncoder.withIndent(' ').convert(j));
|
|
});
|
|
}
|
|
|
|
/// 이 판의 설정이 맡는 안전 저장소 열쇠 - 이 판이 만드는 정확한 이름 꼴만 ([_secretsOf] 와 같은 꼴).
|
|
/// 관리자 지시 (10/21, 15 알림): 예전에는 머리글 (dav. · sftp. 등) 만 보아, 새 판이 같은 머리글로 새 종류의 열쇠 (예: dav.token.x) 를
|
|
/// 쓰면 이 판에서 보통 저장할 때 지웠다 - 예전 판으로 되돌리면 사용자 비밀번호를 잃을 수 있었다 (40 의 비밀 값 쪽).
|
|
/// 그 밖은 마스터 · 최상 (sec.) 이거나 이 판이 모르는 꼴 (다른 판이 쓴 열쇠) - 보통 저장에서는 건드리지 않는다
|
|
/// 규칙 (관리자 10/21): 새 종류의 비밀 값은 아래 꼴들과 앞부분이 겹치지 않는 새 머리글 (dav2. 등) 을 쓴다 (겹치면 예전 판이 저장할 때 지움)
|
|
static bool ownsSecret(String k) => secretForms.any((f) => f.hasMatch(k));
|
|
|
|
/// 이 판이 만드는 비밀 열쇠의 꼴 ([_secretsOf] 와 같아야 한다 - 시험이 모든 열쇠가 정확히 한 꼴에만 맞는지 본다)
|
|
static final secretForms = [
|
|
for (final k in _osKeys.values) RegExp('^${RegExp.escape(k)}\$'),
|
|
RegExp('^${RegExp.escape(_davServers)}\$'),
|
|
for (final pre in const ['dav.pw', 'sftp.pw', 'sftp.key', 'sftp.kp', 'smb.pw', 'ftp.pw', 'ai.key']) RegExp('^${RegExp.escape(pre)}' r'\..+$'),
|
|
];
|
|
|
|
/// 관리자 판단 (10/21): 이 판이 모르는 꼴의 비밀 값 (다른 판이 쓴 열쇠 - 머리글이 같아도) 을 지운다 - 사용자가 초기화 창에서
|
|
/// [저장된 실제 비밀번호 · API 키] 를 골랐을 때만 부른다 (고르지 않았으면 남김). 마스터 · 최상 (sec.) 은 [마스터 비밀번호] 체크가 정해 여기서 남긴다.
|
|
/// 지운 열쇠 수
|
|
Future<int> clearForeignSecrets() async {
|
|
final all = await secrets.readAll();
|
|
var n = 0;
|
|
for (final k in all.keys) {
|
|
if (k.startsWith('sec.') || ownsSecret(k)) continue;
|
|
await secrets.delete(k);
|
|
_known.remove(k);
|
|
n++;
|
|
}
|
|
return n;
|
|
}
|
|
|
|
/// 바뀐 비밀 값만 안전 저장소에 쓴다 (빈 값 · 지운 서버는 저장소에서도 지움). 저장소에 넣지 못한 열쇠를 돌려준다.
|
|
Future<Set<String>> _writeSecrets(Map<String, String> values) async {
|
|
if (!_secretsOk) return {...values.keys.where((k) => _known[k] != values[k])};
|
|
final unsaved = <String>{};
|
|
for (final k in {..._known.keys, ...values.keys}) {
|
|
if (!ownsSecret(k)) continue;
|
|
final v = values[k];
|
|
if (v == _known[k]) continue;
|
|
try {
|
|
if (v == null) {
|
|
await secrets.delete(k);
|
|
_known.remove(k);
|
|
} else {
|
|
await secrets.write(k, v);
|
|
_known[k] = v;
|
|
}
|
|
} catch (_) {
|
|
if (v != null) unsaved.add(k);
|
|
}
|
|
}
|
|
return unsaved;
|
|
}
|
|
|
|
Future<void> _write(String text) async {
|
|
final f = File(await _file());
|
|
await f.parent.create(recursive: true);
|
|
final tmp = File('${f.path}.tmp');
|
|
await tmp.writeAsString(text, flush: true);
|
|
if (await f.exists()) {
|
|
// 지금 파일이 정상이면 백업으로 남긴다 (깨진 파일로 좋은 백업을 덮지 않게). 비밀 값은 빼고 (54)
|
|
try {
|
|
final j = jsonDecode(await f.readAsString()) as Map<String, dynamic>;
|
|
AppSettings.fromJson(j);
|
|
// 저장소에 못 넣어 파일에 남겨 둔 평문이 있으면 백업에도 그대로 (40-1)
|
|
await File('${f.path}.bak').writeAsString(
|
|
const JsonEncoder.withIndent(' ').convert(_filePlain.isNotEmpty ? j : AppSettings.stripSecrets(j)),
|
|
flush: true);
|
|
} catch (_) {}
|
|
}
|
|
await tmp.rename(f.path);
|
|
}
|
|
}
|
|
|
|
/// 안전 저장소 문제의 종류
|
|
enum SecretIssueKind {
|
|
/// 안전 저장소를 읽을 수 없음 (이번 실행에서 넣은 비밀번호는 저장되지 않음)
|
|
readFailed,
|
|
|
|
/// 안전 저장소에 쓰지 못함 (넣은 비밀번호가 저장되지 않음)
|
|
writeFailed,
|
|
|
|
/// 안전 저장소를 풀 수 없어 저장된 비밀번호를 되살리지 못함 (풀지 못한 파일은 [SecretIssue.kept] 에 보관)
|
|
lost,
|
|
}
|
|
|
|
class SecretIssue {
|
|
final SecretIssueKind kind;
|
|
final String? kept;
|
|
|
|
/// 91: 저장하지 못한 새 값 중 예전 값이 있는 것이 있다 (앱을 끄면 예전 비밀번호로 돌아감)
|
|
final bool revertsToOld;
|
|
|
|
/// 저장하지 못한 새 값 중 예전 값이 없는 것이 있다 (앱을 끄면 사라짐)
|
|
final bool lostOnExit;
|
|
|
|
const SecretIssue(this.kind, {this.kept, this.revertsToOld = false, this.lostOnExit = false});
|
|
|
|
// 같은 상황이면 알림을 다시 띄우지 않게 (90)
|
|
@override
|
|
bool operator ==(Object other) =>
|
|
other is SecretIssue &&
|
|
other.kind == kind &&
|
|
other.kept == kept &&
|
|
other.revertsToOld == revertsToOld &&
|
|
other.lostOnExit == lostOnExit;
|
|
@override
|
|
int get hashCode => Object.hash(kind, kept, revertsToOld, lostOnExit);
|
|
}
|
|
|
|
/// 설정 파일을 읽다 생긴 일 (앱이 켜질 때 알림)
|
|
class SettingsLoadProblem {
|
|
/// 백업 (직전 정상 저장) 으로 되살렸는지. false 면 처음 설정으로 켰다.
|
|
final bool restoredFromBackup;
|
|
|
|
/// 읽지 못한 원래 파일을 보관한 곳
|
|
final String? brokenCopy;
|
|
final String error;
|
|
const SettingsLoadProblem({required this.restoredFromBackup, required this.brokenCopy, required this.error});
|
|
}
|