Skip to content

Commit 8f826ab

Browse files
Adam Fiskclaude
andcommitted
unbounded: gate entire UI surface on server Features[unbounded] flag
Censored users should not see a "share your connection" UI on their device — it can be a red flag on-device evidence even when broflake itself is server-gated off. Mirror the radiance shouldRunUnbounded gate up into Flutter so the Unbounded tab, settings sub-page, project promo tile, first-visit welcome dialog, and auto-enable hooks all disappear when Features[unbounded] is false. Adds FeatureFlag.unbounded backed by the same "unbounded" key the server already emits (common/types.go UNBOUNDED). Default getBool(...) is false, so any user whose /v1/config-new response omits the flag (no connectivity, parse failure, censored region) sees the safe state: no Unbounded UI at all. The user's "Hide Unbounded tab" toggle (appSettingProvider unboundedHidden) still wins on top of this for non-censored users who want it hidden. The new effective predicate is unboundedAvailable && !unboundedHidden. The welcome dialog at share_my_connection.dart:572 and the info-bubble re-opener at :607 are both inside UnboundedTab.build, which never mounts when the tab is hidden, so no defensive code is needed there. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6236309 commit 8f826ab

3 files changed

Lines changed: 67 additions & 39 deletions

File tree

lib/core/models/feature_flags.dart

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,15 @@ enum FeatureFlag {
22
privateGcp('private.gcp'),
33
metrics('otel.metrics'),
44
traces('otel.traces'),
5-
autoUpdateEnabled('autoUpdateEnabled');
5+
autoUpdateEnabled('autoUpdateEnabled'),
6+
// Server-side gate for the entire Unbounded / Share My Connection
7+
// surface. When false (the default for censored regions), the
8+
// Unbounded tab, settings entry, project link, and auto-enable hooks
9+
// all disappear — censored users should never see a "share your
10+
// connection" UI that could draw attention to them on-device. Mirrors
11+
// radiance/unbounded/unbounded.go shouldRunUnbounded, which already
12+
// gates execution on the same Features[UNBOUNDED] flag.
13+
unbounded('unbounded');
614

715
final String key;
816

lib/features/home/home.dart

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ class Home extends HookConsumerWidget {
4141
appSettingProvider.select((s) => s.unboundedHidden),
4242
);
4343
final featureFlag = ref.watch(featureFlagProvider);
44+
// Server-side gate for the whole Unbounded UI surface. Censored
45+
// regions get the flag off, so the tab, strip, and any auto-enable
46+
// hook disappear. The user's own "Hide Unbounded tab" toggle still
47+
// wins on top of this for non-censored users who want it hidden.
48+
final unboundedAvailable = featureFlag.getBool(FeatureFlag.unbounded);
49+
final showUnboundedTab = unboundedAvailable && !unboundedHidden;
4450
final vpnStatus = ref.watch(vpnProvider);
4551
final shareActive = ref.watch(shareProvider.select((s) => s.active));
4652

@@ -108,6 +114,7 @@ class Home extends HookConsumerWidget {
108114
// because the user has already opted in via settings.
109115
useEffect(() {
110116
WidgetsBinding.instance.addPostFrameCallback((_) {
117+
if (!unboundedAvailable) return;
111118
final appSetting = ref.read(appSettingProvider);
112119
if (!appSetting.onboardingCompleted) return;
113120
if (!appSetting.unboundedAutoEnable) return;
@@ -116,11 +123,12 @@ class Home extends HookConsumerWidget {
116123
ref.read(shareProvider.notifier).autoStart(ref);
117124
});
118125
return null;
119-
}, const []);
126+
}, [unboundedAvailable]);
120127

121128
ref.listen<VPNStatus>(vpnProvider, (prev, next) {
122129
if (prev == next) return;
123130
if (next != VPNStatus.connected) return;
131+
if (!unboundedAvailable) return;
124132
final autoEnable =
125133
ref.read(appSettingProvider).unboundedAutoEnable;
126134
if (!autoEnable) return;
@@ -166,10 +174,11 @@ class Home extends HookConsumerWidget {
166174
onPressed: () => appRouter.push(const SignInEmail()),
167175
),
168176
],
169-
// Tab strip collapses when the user has hidden the Unbounded
170-
// tab in Unbounded Settings — with only one tab left, a strip
171-
// is just noise. Body falls back to VpnTab directly.
172-
bottom: unboundedHidden
177+
// Tab strip collapses when Unbounded is unavailable — either the
178+
// server flag is off (censored region) or the user hid the tab
179+
// in Unbounded Settings. With only one tab left, a strip is just
180+
// noise; body falls back to VpnTab directly.
181+
bottom: !showUnboundedTab
173182
? null
174183
: TabBar(
175184
controller: tabController,
@@ -181,7 +190,7 @@ class Home extends HookConsumerWidget {
181190
],
182191
),
183192
),
184-
body: unboundedHidden
193+
body: !showUnboundedTab
185194
? const VpnTab()
186195
: TabBarView(
187196
controller: tabController,

lib/features/setting/setting.dart

Lines changed: 43 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ import 'package:lantern/core/localization/localization_constants.dart';
77
import 'package:lantern/core/updater/updater.dart';
88
import 'package:lantern/core/utils/pro_utils.dart';
99
import 'package:lantern/core/widgets/subscription_tags.dart';
10+
import 'package:lantern/core/models/feature_flags.dart';
1011
import 'package:lantern/features/home/provider/app_setting_notifier.dart';
12+
import 'package:lantern/features/home/provider/feature_flag_notifier.dart';
1113
import 'package:lantern/features/home/provider/home_notifier.dart';
1214
import 'package:lantern/features/setting/appearance.dart'
1315
show appearanceModeLabel, showAppearanceBottomSheet;
@@ -45,6 +47,11 @@ class _SettingState extends ConsumerState<Setting> {
4547
final email = ref.watch(userEmailProvider);
4648

4749
final appSetting = ref.watch(appSettingProvider);
50+
// Server-side gate. Censored regions get Features[unbounded]=false,
51+
// and every Unbounded-flavoured row in this menu (the settings sub-
52+
// page link AND the project promo card at the bottom) disappears.
53+
final unboundedAvailable =
54+
ref.watch(featureFlagProvider).getBool(FeatureFlag.unbounded);
4855

4956
final hasProSession = (user?.legacyUserData.isPro ?? false) &&
5057
(user?.legacyUserData.unpassRegistered ?? false);
@@ -124,13 +131,15 @@ class _SettingState extends ConsumerState<Setting> {
124131
icon: AppImagePaths.glob,
125132
onPressed: () => settingMenuTap(_SettingType.vpnSetting),
126133
),
127-
DividerSpace(),
128-
AppTile(
129-
label: 'Unbounded Settings',
130-
icon: AppImagePaths.share,
131-
onPressed: () =>
132-
settingMenuTap(_SettingType.unboundedSetting),
133-
),
134+
if (unboundedAvailable) ...[
135+
DividerSpace(),
136+
AppTile(
137+
label: 'Unbounded Settings',
138+
icon: AppImagePaths.share,
139+
onPressed: () =>
140+
settingMenuTap(_SettingType.unboundedSetting),
141+
),
142+
],
134143
DividerSpace(),
135144
AppTile(
136145
label: 'language'.i18n,
@@ -199,36 +208,38 @@ class _SettingState extends ConsumerState<Setting> {
199208
),
200209
),
201210
},
202-
const SizedBox(height: defaultSize),
203-
Padding(
204-
padding: const EdgeInsets.only(left: 16),
205-
child: Text(
206-
'lantern_projects'.i18n,
207-
style: textTheme.labelLarge!.copyWith(
208-
color: context.textSecondary,
211+
if (unboundedAvailable) ...[
212+
const SizedBox(height: defaultSize),
213+
Padding(
214+
padding: const EdgeInsets.only(left: 16),
215+
child: Text(
216+
'lantern_projects'.i18n,
217+
style: textTheme.labelLarge!.copyWith(
218+
color: context.textSecondary,
219+
),
209220
),
210221
),
211-
),
212-
const SizedBox(height: 4),
213-
Card(
214-
child: AppTile(
215-
minHeight: 72,
216-
icon: AppImagePaths.lanternLogoRounded,
217-
iconUseThemeColor: false,
218-
trailing: AppImage(path: AppImagePaths.outsideBrowser),
219-
label: 'unbounded'.i18n,
220-
subtitle: Text(
221-
'help_fight_global_internet_censorship'.i18n,
222-
style: textTheme.labelMedium!.copyWith(
223-
color: context.textTertiary,
222+
const SizedBox(height: 4),
223+
Card(
224+
child: AppTile(
225+
minHeight: 72,
226+
icon: AppImagePaths.lanternLogoRounded,
227+
iconUseThemeColor: false,
228+
trailing: AppImage(path: AppImagePaths.outsideBrowser),
229+
label: 'unbounded'.i18n,
230+
subtitle: Text(
231+
'help_fight_global_internet_censorship'.i18n,
232+
style: textTheme.labelMedium!.copyWith(
233+
color: context.textTertiary,
234+
),
224235
),
236+
onPressed: () {
237+
UrlUtils.openUrl(AppUrls.unbounded);
238+
},
225239
),
226-
onPressed: () {
227-
UrlUtils.openUrl(AppUrls.unbounded);
228-
},
229240
),
230-
),
231-
SizedBox(height: defaultSize),
241+
SizedBox(height: defaultSize),
242+
],
232243
],
233244
),
234245
);

0 commit comments

Comments
 (0)