1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
/// What the window's close button does.
enum CloseAction { ask, tray, quit }
/// The home and accounts pages' design: the phone's three designs
/// (Pixel, Portal, Old), FoxyVPN's phone look (the big connect button),
/// or the classic pages this app shipped with.
enum UiDesign { foxy, classic, pixel, portal, old }
/// The app's own preferences (language, theme, close button), kept in
/// `app-settings.json` in the data folder beside the core's files. The VPN
/// settings belong to the core.
class AppSettings extends ChangeNotifier {
AppSettings({this.file});
final File? file;
/// A language code (`en`, `fa`, `zh`, `ru`), or empty for the system
/// language.
String language = '';
ThemeMode themeMode = ThemeMode.system;
CloseAction closeAction = CloseAction.ask;
/// The home and accounts design. A bare settings object (a test, a
/// preview) starts classic; [AppSettings.load] — how the app itself
/// starts — starts pixel until the user picks otherwise.
UiDesign uiDesign = UiDesign.classic;
/// True until the user finished the welcome page.
bool firstRun = false;
/// Shows the public IP (the address websites see). Off by default: the
/// location is shown by its country either way.
bool showPublicIp = false;
/// When the user closed the "account running out" notice; it stays away
/// for a day after.
DateTime? lowQuotaDismissedAt;
/// The newer version whose notice the user closed; a later one shows.
String dismissedUpdate = '';
/// The last successful look for a newer version, and the newer version it
/// found ("" for none) with its page, so the notice outlasts a restart.
DateTime? updateCheckedAt;
String updateVersion = '';
String updateUrl = '';
/// Downloads a newer version in the background and installs it the next
/// time the app starts (the Windows app, which updates itself); on unless
/// the user turned it off.
bool autoUpdate = true;
/// Starts the app in the tray when the user signs in to Windows; on
/// unless the user turned it off.
bool autostart = true;
/// Connects by itself when started with the computer ([autostart]); off
/// until the user turns it on. The core decides how (Daemon.AutoConnect).
bool autoConnect = false;
/// The home page's card when TUN mode refused a program's call or game
/// (UDP); on unless the user turned it off.
bool udpNotice = true;
/// The app's languages, in the order the language menus list them.
/// 'zh' is Simplified and 'zh_Hant' Traditional Chinese.
static const supportedLanguages = [
'en',
'fa',
'zh',
'zh_Hant',
'ru',
'tr',
'ar',
// The rest by their own names.
'az',
'id',
'es',
'fr',
'hu',
'pt',
'vi',
'bn',
];
/// Languages written right to left.
static const rtlLanguages = {'fa', 'ar'};
/// The locale of a language code such as 'fa' or 'zh_Hant'.
static Locale localeOf(String language) {
final parts = language.split('_');
return parts.length == 2
? Locale.fromSubtags(languageCode: parts[0], scriptCode: parts[1])
: Locale(language);
}
/// The app language for the system's [locale]: Traditional Chinese for
/// the Hant script or Taiwan, Hong Kong, and Macau; else the language
/// when the app has it; else English.
static String languageFor(Locale locale) {
if (locale.languageCode == 'zh') {
final traditional =
locale.scriptCode == 'Hant' ||
const {'TW', 'HK', 'MO'}.contains(locale.countryCode);
return traditional ? 'zh_Hant' : 'zh';
}
return supportedLanguages.contains(locale.languageCode)
? locale.languageCode
: 'en';
}
/// The app language for the system's preferred [locales], in order: the
/// first one the app has; else English (not Flutter's default, the first
/// language it was built with).
static String languageForList(List<Locale>? locales) {
for (final locale in locales ?? const <Locale>[]) {
if (locale.languageCode == 'zh' ||
supportedLanguages.contains(locale.languageCode)) {
return languageFor(locale);
}
}
return 'en';
}
/// The core's routing regions (`routing.region`); empty is none.
static const regions = ['ir', 'cn', 'ru', 'af', 'tr', 'id', 'br', ''];
Locale? get locale => language.isEmpty ? null : localeOf(language);
/// The routing region most users of [language] want: Persian is Iran,
/// Chinese China, Russian Russia; null when a language says nothing.
static String? regionForLanguage(String language) => switch (language) {
'fa' => 'ir',
'zh' => 'cn',
'ru' => 'ru',
'id' => 'id',
'tr' => 'tr',
'pt' => 'br',
_ => null,
};
/// The region to offer first: from [language], else from the system's
/// country ([countryCode], such as IR), else none.
static String suggestedRegion(String language, String? countryCode) {
final byLanguage = regionForLanguage(language);
if (byLanguage != null) return byLanguage;
final country = (countryCode ?? '').toLowerCase();
return regions.contains(country) ? country : '';
}
static Future<AppSettings> load(String dataDir) async {
final s = AppSettings(
file: File('$dataDir${Platform.pathSeparator}app-settings.json'),
);
// The app's design until the settings file says otherwise: the
// phone's Pixel design.
s.uiDesign = UiDesign.pixel;
try {
final j = jsonDecode(await s.file!.readAsString());
if (j is Map<String, Object?>) {
if (j['language'] case final String l
when supportedLanguages.contains(l)) {
s.language = l;
}
s.uiDesign =
UiDesign.values.asNameMap()[j['ui_design']] ?? UiDesign.pixel;
s.themeMode =
ThemeMode.values.asNameMap()[j['theme']] ?? ThemeMode.system;
s.closeAction =
CloseAction.values.asNameMap()[j['close']] ?? CloseAction.ask;
s.firstRun = j['welcome_done'] != true;
s.showPublicIp = j['show_public_ip'] == true;
if (j['dismissed_update'] case final String v) s.dismissedUpdate = v;
if (j['update_version'] case final String v) s.updateVersion = v;
if (j['update_url'] case final String v) s.updateUrl = v;
s.updateCheckedAt = switch (j['update_checked_at']) {
final String t => DateTime.tryParse(t),
_ => null,
};
s.lowQuotaDismissedAt = switch (j['low_quota_dismissed_at']) {
final String t => DateTime.tryParse(t),
_ => null,
};
s.autostart = j['autostart'] != false;
s.autoConnect = j['auto_connect'] == true;
s.autoUpdate = j['auto_update'] != false;
s.udpNotice = j['udp_notice'] != false;
}
} on FileSystemException {
// First run: defaults, and the welcome page.
s.firstRun = true;
} on FormatException {
// A broken file: defaults, rewritten on the next change.
}
return s;
}
void update({
String? language,
ThemeMode? themeMode,
CloseAction? closeAction,
UiDesign? uiDesign,
bool? firstRun,
bool? showPublicIp,
DateTime? lowQuotaDismissedAt,
String? dismissedUpdate,
DateTime? updateCheckedAt,
String? updateVersion,
String? updateUrl,
bool? autostart,
bool? autoConnect,
bool? autoUpdate,
bool? udpNotice,
}) {
this.udpNotice = udpNotice ?? this.udpNotice;
this.autoUpdate = autoUpdate ?? this.autoUpdate;
this.autostart = autostart ?? this.autostart;
this.autoConnect = autoConnect ?? this.autoConnect;
this.showPublicIp = showPublicIp ?? this.showPublicIp;
this.lowQuotaDismissedAt = lowQuotaDismissedAt ?? this.lowQuotaDismissedAt;
this.dismissedUpdate = dismissedUpdate ?? this.dismissedUpdate;
this.updateCheckedAt = updateCheckedAt ?? this.updateCheckedAt;
this.updateVersion = updateVersion ?? this.updateVersion;
this.updateUrl = updateUrl ?? this.updateUrl;
this.firstRun = firstRun ?? this.firstRun;
this.language = language ?? this.language;
this.themeMode = themeMode ?? this.themeMode;
this.closeAction = closeAction ?? this.closeAction;
this.uiDesign = uiDesign ?? this.uiDesign;
notifyListeners();
_save();
}
Future<void> _save() async {
final f = file;
if (f == null) return;
try {
await f.parent.create(recursive: true);
await f.writeAsString(
const JsonEncoder.withIndent(' ').convert({
'language': language,
'theme': themeMode.name,
'close': closeAction.name,
'ui_design': uiDesign.name,
'welcome_done': !firstRun,
'show_public_ip': showPublicIp,
'low_quota_dismissed_at': lowQuotaDismissedAt?.toIso8601String(),
'dismissed_update': dismissedUpdate,
'update_checked_at': updateCheckedAt?.toIso8601String(),
'update_version': updateVersion,
'update_url': updateUrl,
'autostart': autostart,
'auto_connect': autoConnect,
'auto_update': autoUpdate,
'udp_notice': udpNotice,
}),
);
} on FileSystemException {
// Not fatal: the choice holds until the app quits.
}
}
}