diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 257d36ab..a099501b 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -44,6 +44,11 @@
android:theme="@style/Theme.NetBird"
tools:targetApi="33">
+
+
+
{
int destId = destination.getId();
@@ -451,6 +461,10 @@ public void onConfigurationChanged(@NonNull Configuration newConfig) {
@Override
protected void onPause() {
super.onPause();
+ if (mdmPolicyReceiver != null) {
+ unregisterReceiver(mdmPolicyReceiver);
+ mdmPolicyReceiver = null;
+ }
}
@Override
@@ -459,6 +473,83 @@ protected void onResume() {
// Profiles are switched and deleted from a fragment, which reports
// neither, so re-read the active one whenever we come back into view.
syncSshSessionProfile();
+
+ // A policy can change while the app is in the background, where the
+ // service's receiver acts on the engine but no screen is there to react.
+ listenForMDMPolicy();
+ MDMBridge.refresh(this);
+ rebuildIfMDMPolicyChanged();
+ }
+
+ /**
+ * Takes the screen down to what the policy leaves of it.
+ *
+ * Networks is a whole tab, so it goes from the bar rather than staying as a
+ * destination the user can reach and find empty. Everything finer-grained is
+ * each screen's own business.
+ */
+ private void applyMDMPolicy(NavigationBarView bottomNav) {
+ appliedMDMSnapshot = MDMBridge.snapshotToken(this);
+ MDMRestrictions restrictions = MDMBridge.restrictions(this);
+ if (!restrictions.features.disableNetworks) {
+ return;
+ }
+ bottomNav.getMenu().findItem(R.id.nav_networks).setVisible(false);
+ NavDestination current = navController.getCurrentDestination();
+ if (current != null && current.getId() == R.id.nav_networks) {
+ navController.navigate(R.id.nav_home);
+ }
+ }
+
+ /**
+ * Rebuilds the screen when the policy is not the one it was built from —
+ * which is also how a withdrawn policy gives the user their settings back.
+ */
+ private void rebuildIfMDMPolicyChanged() {
+ if (appliedMDMSnapshot.equals(MDMBridge.snapshotToken(this))) {
+ return;
+ }
+ Log.d(LOGTAG, "MDM policy changed, rebuilding the screen");
+ recreate();
+ }
+
+ /**
+ * The service applies a changed policy to the engine and says so; this is
+ * the half the user sees. Registered only while the screen is in view — a
+ * toast has nobody to reach otherwise, and onResume catches up on anything
+ * missed.
+ */
+ private void listenForMDMPolicy() {
+ if (mdmPolicyReceiver != null) {
+ return;
+ }
+ mdmPolicyReceiver = new android.content.BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ MDMBridge.refresh(MainActivity.this);
+ boolean announced = VPNService.ACTION_MDM_POLICY_APPLIED.equals(intent.getAction());
+ if (!announced && appliedMDMSnapshot.equals(MDMBridge.snapshotToken(MainActivity.this))) {
+ // The OS pushed a policy that changes nothing this screen
+ // shows; saying so would be noise.
+ return;
+ }
+ Toast.makeText(MainActivity.this, R.string.mdm_policy_applied, Toast.LENGTH_LONG).show();
+ rebuildIfMDMPolicyChanged();
+ }
+ };
+ android.content.IntentFilter mdmFilter =
+ new android.content.IntentFilter(VPNService.ACTION_MDM_POLICY_APPLIED);
+ // The service announces a policy it has applied to the engine, but it is
+ // only alive while the tunnel is. Listening for the OS notification as
+ // well means a screen reacts to a policy pushed with the VPN off, rather
+ // than waiting for the next time it comes into view.
+ mdmFilter.addAction(Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED);
+ ContextCompat.registerReceiver(
+ this,
+ mdmPolicyReceiver,
+ mdmFilter,
+ ContextCompat.RECEIVER_NOT_EXPORTED
+ );
}
private void syncSshSessionProfile() {
diff --git a/app/src/main/java/io/netbird/client/ui/MDMLock.java b/app/src/main/java/io/netbird/client/ui/MDMLock.java
new file mode 100644
index 00000000..d326274b
--- /dev/null
+++ b/app/src/main/java/io/netbird/client/ui/MDMLock.java
@@ -0,0 +1,147 @@
+package io.netbird.client.ui;
+
+import android.content.Context;
+import android.util.TypedValue;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewParent;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import androidx.core.content.ContextCompat;
+
+import io.netbird.client.R;
+
+/**
+ * How a screen shows that an administrator decided a setting.
+ *
+ * One convention, applied everywhere, so the app cannot drift into several
+ * answers to the same situation:
+ *
+ *
a feature the policy switches off entirely disappears — there is nothing
+ * to explain about a screen the organisation does not offer;
+ *
a single managed setting stays visible but locked, with a line under it
+ * saying who decided it. A control that silently vanishes reads as a bug;
+ * one that is there and greyed out explains itself.
+ *
+ *
+ * The locking is done in code rather than in the layouts because the same rows
+ * are ordinary, editable settings on an unmanaged device, which is the common
+ * case.
+ */
+public final class MDMLock {
+
+ private static final float DIMMED = 0.5f;
+ private static final String NOTICE_TAG = "mdm_managed_notice";
+
+ private MDMLock() {
+ }
+
+ /**
+ * Locks a settings row: the row stops responding, the controls in it are
+ * disabled, and a line naming the organisation is added underneath.
+ *
+ * @param row the tappable row, whose click listener is dropped
+ * @param controls the switches, fields or buttons inside it
+ */
+ public static void lock(View row, View... controls) {
+ if (row == null) {
+ return;
+ }
+ row.setOnClickListener(null);
+ row.setClickable(false);
+ row.setFocusable(false);
+ row.setAlpha(DIMMED);
+ disable(controls);
+ addNotice(row);
+ }
+
+ /**
+ * Locks controls that do not sit in a row of their own — a text field with
+ * its save button, say — and explains it underneath the last of them.
+ */
+ public static void lockControls(View... controls) {
+ disable(controls);
+ if (controls.length > 0) {
+ addNotice(controls[controls.length - 1]);
+ }
+ }
+
+ /**
+ * Hides a row the policy takes away, along with the divider that follows it,
+ * which the layouts include without an id of its own.
+ */
+ public static void hide(View row) {
+ if (row == null) {
+ return;
+ }
+ row.setVisibility(View.GONE);
+ View next = nextSibling(row);
+ // Only a bare View: every other row and section header is some subclass,
+ // so this cannot swallow the heading of the section that comes next.
+ if (next != null && next.getClass() == View.class) {
+ next.setVisibility(View.GONE);
+ }
+ }
+
+ private static void disable(View... controls) {
+ for (View control : controls) {
+ if (control != null) {
+ control.setEnabled(false);
+ control.setAlpha(DIMMED);
+ }
+ }
+ }
+
+ /**
+ * Adds the "managed by your organization" line under a view.
+ *
+ * Only where the layout is a vertical column, which is what the settings
+ * screens are; anywhere else the caption is left out rather than dropped into
+ * a layout that would place it somewhere surprising.
+ */
+ private static void addNotice(View anchor) {
+ ViewParent parent = anchor.getParent();
+ if (!(parent instanceof LinearLayout)) {
+ return;
+ }
+ LinearLayout column = (LinearLayout) parent;
+ if (column.getOrientation() != LinearLayout.VERTICAL) {
+ return;
+ }
+ int at = column.indexOfChild(anchor) + 1;
+ if (at < column.getChildCount() && NOTICE_TAG.equals(column.getChildAt(at).getTag())) {
+ // Already explained: the screens re-apply the policy on every resume.
+ return;
+ }
+
+ Context context = column.getContext();
+ TextView notice = new TextView(context);
+ notice.setTag(NOTICE_TAG);
+ notice.setText(R.string.mdm_managed_by_organization);
+ notice.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12);
+ notice.setTextColor(ContextCompat.getColor(context, R.color.nb_txt_light));
+
+ LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
+ int side = dp(context, 20);
+ params.setMargins(side, dp(context, 6), side, dp(context, 8));
+ notice.setLayoutParams(params);
+
+ column.addView(notice, at);
+ }
+
+ private static View nextSibling(View view) {
+ ViewParent parent = view.getParent();
+ if (!(parent instanceof ViewGroup)) {
+ return null;
+ }
+ ViewGroup group = (ViewGroup) parent;
+ int at = group.indexOfChild(view) + 1;
+ return at < group.getChildCount() ? group.getChildAt(at) : null;
+ }
+
+ private static int dp(Context context, int value) {
+ return Math.round(value * context.getResources().getDisplayMetrics().density);
+ }
+}
diff --git a/app/src/main/java/io/netbird/client/ui/advanced/AdvancedFragment.java b/app/src/main/java/io/netbird/client/ui/advanced/AdvancedFragment.java
index d5b68c3f..1c71c5d8 100644
--- a/app/src/main/java/io/netbird/client/ui/advanced/AdvancedFragment.java
+++ b/app/src/main/java/io/netbird/client/ui/advanced/AdvancedFragment.java
@@ -17,8 +17,11 @@
import io.netbird.client.R;
import io.netbird.client.databinding.FragmentAdvancedBinding;
+import io.netbird.client.tool.MDMBridge;
+import io.netbird.client.tool.MDMRestrictions;
import io.netbird.client.tool.Preferences;
import io.netbird.client.tool.ProfileManagerWrapper;
+import io.netbird.client.ui.MDMLock;
public class AdvancedFragment extends Fragment implements ThemePickerSheet.OnThemeChangedListener {
@@ -28,6 +31,8 @@ public class AdvancedFragment extends Fragment implements ThemePickerSheet.OnThe
private FragmentAdvancedBinding binding;
private io.netbird.gomobile.android.Preferences goPreferences;
+ private String configFilePath;
+ private MDMRestrictions mdm = MDMRestrictions.EMPTY;
private void showReconnectionNeededWarningDialog() {
final View dialogView = getLayoutInflater().inflate(R.layout.dialog_simple_alert_message, null);
@@ -60,13 +65,13 @@ public View onCreateView(@NonNull LayoutInflater inflater,
// Get config path from ProfileManager instead of constructing it
ProfileManagerWrapper profileManager = new ProfileManagerWrapper(inflater.getContext());
- String configFilePath;
try {
configFilePath = profileManager.getActiveConfigPath();
} catch (Exception e) {
throw new RuntimeException("Failed to get config path: " + e.getMessage(), e);
}
- goPreferences = new io.netbird.gomobile.android.Preferences(configFilePath);
+ goPreferences = MDMBridge.openPreferences(inflater.getContext(), configFilePath);
+ mdm = MDMBridge.restrictions(inflater.getContext());
binding = FragmentAdvancedBinding.inflate(inflater, container, false);
View root = binding.getRoot();
@@ -98,9 +103,8 @@ public View onCreateView(@NonNull LayoutInflater inflater,
// Rosenpass settings
try {
binding.switchRosenpass.setChecked(goPreferences.getRosenpassEnabled());
- if (!binding.switchRosenpass.isChecked()) {
- binding.switchRosenpassPermissive.setEnabled(false);
- } else {
+ setPermissiveEnabled(binding.switchRosenpass.isChecked());
+ if (binding.switchRosenpass.isChecked()) {
binding.switchRosenpassPermissive.setChecked(goPreferences.getRosenpassPermissive());
}
@@ -108,38 +112,25 @@ public View onCreateView(@NonNull LayoutInflater inflater,
Log.e(LOGTAG, "Error getting Rosenpass settings", e);
Toast.makeText(inflater.getContext(), getString(R.string.error_generic, e.toString()), Toast.LENGTH_SHORT).show();
binding.switchRosenpass.setChecked(false);
- binding.switchRosenpassPermissive.setEnabled(false);
+ setPermissiveEnabled(false);
}
binding.switchRosenpass.setOnCheckedChangeListener((buttonView, isChecked) -> {
- if (isChecked) {
- goPreferences.setRosenpassEnabled(true);
- binding.switchRosenpassPermissive.setEnabled(true);
-
- } else {
- goPreferences.setRosenpassEnabled(false);
- binding.switchRosenpassPermissive.setEnabled(false);
+ goPreferences.setRosenpassEnabled(isChecked);
+ setPermissiveEnabled(isChecked);
+ // Only when it is ours to drive: toggling a managed switch would fire
+ // its listener and stage a write the policy is about to refuse.
+ if (!isChecked && !permissiveManaged()) {
binding.switchRosenpassPermissive.setChecked(false);
}
-
- try {
- goPreferences.commit();
- } catch (Exception e) {
- Log.e(LOGTAG, "Error committing Rosenpass settings", e);
- Toast.makeText(inflater.getContext(), getString(R.string.error_generic, e.toString()), Toast.LENGTH_SHORT).show();
- }
+ commit();
});
binding.layoutRosenpas.setOnClickListener(v -> binding.switchRosenpass.toggle());
binding.switchRosenpassPermissive.setOnCheckedChangeListener((buttonView, isChecked) -> {
goPreferences.setRosenpassPermissive(isChecked);
- try {
- goPreferences.commit();
- } catch (Exception e) {
- Log.e(LOGTAG, "Error committing Rosenpass settings", e);
- Toast.makeText(inflater.getContext(), getString(R.string.error_generic, e.toString()), Toast.LENGTH_SHORT).show();
- }
+ commit();
});
binding.layoutRosenpassPermissive.setOnClickListener(v -> binding.switchRosenpassPermissive.toggle());
@@ -159,9 +150,100 @@ public View onCreateView(@NonNull LayoutInflater inflater,
sheet.show(getChildFragmentManager(), "ThemePickerSheet");
});
+ // Last: locking a row takes away the listeners installed above.
+ applyMDMPolicy();
+
return root;
}
+ /**
+ * Locks what an administrator decided.
+ *
+ * The switches already show the enforced values — the Go preferences answer
+ * with the policy's value for a managed key — so this only has to stop the
+ * user changing them and say why. disableUpdateSettings is the blunt case:
+ * the organisation allows no configuration changes at all, so every setting
+ * that reaches the config goes read-only.
+ *
+ * The theme row is left alone. It is how the screen looks to this user on
+ * this device, not configuration the organisation is managing.
+ */
+ private void applyMDMPolicy() {
+ boolean everything = mdm.features.disableUpdateSettings;
+
+ if (everything || mdm.mdm.preSharedKey) {
+ MDMLock.lockControls(binding.presharedKey, binding.btnSave);
+ }
+ lockRow(everything || mdm.mdm.rosenpassEnabled,
+ binding.layoutRosenpas, binding.switchRosenpass);
+ lockRow(everything || mdm.mdm.rosenpassPermissive,
+ binding.layoutRosenpassPermissive, binding.switchRosenpassPermissive);
+ lockRow(everything || mdm.mdm.allowServerSSH != null,
+ binding.layoutAllowSsh, binding.switchAllowSsh);
+ lockRow(everything || mdm.mdm.disableClientRoutes,
+ binding.layoutDisableClientRoutes, binding.switchDisableClientRoutes);
+ lockRow(everything || mdm.mdm.disableServerRoutes,
+ binding.layoutDisableServerRoutes, binding.switchDisableServerRoutes);
+ lockRow(everything || mdm.mdm.blockInbound,
+ binding.layoutBlockInbound, binding.switchBlockInbound);
+ lockRow(everything, binding.layoutDisableDns, binding.switchDisableDns);
+ lockRow(everything, binding.layoutDisableFirewall, binding.switchDisableFirewall);
+ lockRow(everything, binding.layoutDisableIpv6, binding.switchDisableIpv6);
+ lockRow(everything, binding.layoutForceRelayConnection, binding.switchForceRelayConnection);
+ }
+
+ private void lockRow(boolean managed, View row, View control) {
+ if (managed) {
+ MDMLock.lock(row, control);
+ }
+ }
+
+ /**
+ * Rosenpass permissive follows Rosenpass itself — off with it, on with it —
+ * unless the policy holds it, in which case it stays where the policy put it.
+ */
+ private void setPermissiveEnabled(boolean enabled) {
+ binding.switchRosenpassPermissive.setEnabled(enabled && !permissiveManaged());
+ }
+
+ private boolean permissiveManaged() {
+ return mdm.features.disableUpdateSettings || mdm.mdm.rosenpassPermissive;
+ }
+
+ /** Writes the staged settings, reporting a policy refusal as one. */
+ private void commit() {
+ try {
+ goPreferences.commit();
+ } catch (Exception e) {
+ // A refused write stays staged on the Go side, so every later commit
+ // through this instance would be refused for the same reason, long
+ // after the user moved on to another setting. Start again from what
+ // is actually on disk.
+ goPreferences = MDMBridge.openPreferences(requireContext(), configFilePath);
+ reportWriteFailure(requireContext(), e);
+ }
+ }
+
+ /**
+ * A write the policy refused is not an error the user can do anything about,
+ * so it is named as what it is. Go reports it with the offending keys, which
+ * are worth repeating: the screen locks what it knows is managed, and a
+ * refusal here means something managed that it did not know about.
+ */
+ private void reportWriteFailure(Context context, Exception e) {
+ String keys = MDMRestrictions.rejectedKeys(e.getMessage());
+ if (keys == null) {
+ Log.e(LOGTAG, "Failed to save the setting", e);
+ Toast.makeText(context, getString(R.string.error_generic, e.toString()),
+ Toast.LENGTH_SHORT).show();
+ return;
+ }
+ Toast.makeText(context, keys.isEmpty()
+ ? getString(R.string.mdm_managed_setting)
+ : getString(R.string.mdm_managed_setting_keys, keys),
+ Toast.LENGTH_LONG).show();
+ }
+
@Override
public void onThemeChanged(int mode) {
if (binding != null) {
@@ -182,66 +264,38 @@ private void initializeEngineConfigSwitches() {
// Set up change listeners
binding.switchDisableClientRoutes.setOnCheckedChangeListener((buttonView, isChecked) -> {
- try {
- goPreferences.setDisableClientRoutes(isChecked);
- goPreferences.commit();
- } catch (Exception e) {
- Log.e(LOGTAG, "Failed to set disable client routes", e);
- }
+ goPreferences.setDisableClientRoutes(isChecked);
+ commit();
});
binding.switchDisableServerRoutes.setOnCheckedChangeListener((buttonView, isChecked) -> {
- try {
- goPreferences.setDisableServerRoutes(isChecked);
- goPreferences.commit();
- } catch (Exception e) {
- Log.e(LOGTAG, "Failed to set disable server routes", e);
- }
+ goPreferences.setDisableServerRoutes(isChecked);
+ commit();
});
binding.switchDisableDns.setOnCheckedChangeListener((buttonView, isChecked) -> {
- try {
- goPreferences.setDisableDNS(isChecked);
- goPreferences.commit();
- } catch (Exception e) {
- Log.e(LOGTAG, "Failed to set disable DNS", e);
- }
+ goPreferences.setDisableDNS(isChecked);
+ commit();
});
binding.switchDisableFirewall.setOnCheckedChangeListener((buttonView, isChecked) -> {
- try {
- goPreferences.setDisableFirewall(isChecked);
- goPreferences.commit();
- } catch (Exception e) {
- Log.e(LOGTAG, "Failed to set disable firewall", e);
- }
+ goPreferences.setDisableFirewall(isChecked);
+ commit();
});
binding.switchAllowSsh.setOnCheckedChangeListener((buttonView, isChecked) -> {
- try {
- goPreferences.setServerSSHAllowed(isChecked);
- goPreferences.commit();
- } catch (Exception e) {
- Log.e(LOGTAG, "Failed to set server SSH allowed", e);
- }
+ goPreferences.setServerSSHAllowed(isChecked);
+ commit();
});
binding.switchBlockInbound.setOnCheckedChangeListener((buttonView, isChecked) -> {
- try {
- goPreferences.setBlockInbound(isChecked);
- goPreferences.commit();
- } catch (Exception e) {
- Log.e(LOGTAG, "Failed to set block inbound", e);
- }
+ goPreferences.setBlockInbound(isChecked);
+ commit();
});
binding.switchDisableIpv6.setOnCheckedChangeListener((buttonView, isChecked) -> {
- try {
- goPreferences.setDisableIPv6(isChecked);
- goPreferences.commit();
- } catch (Exception e) {
- Log.e(LOGTAG, "Failed to set disable IPv6", e);
- }
+ goPreferences.setDisableIPv6(isChecked);
+ commit();
});
// Make parent rows clickable to toggle switches (for TV remote)
@@ -289,14 +343,13 @@ private void setPreSharedKey(String key, Context context) {
Toast.makeText(context, context.getString(R.string.error_config_path, e.getMessage()), Toast.LENGTH_LONG).show();
return;
}
- io.netbird.gomobile.android.Preferences preferences = new io.netbird.gomobile.android.Preferences(configFilePath);
+ io.netbird.gomobile.android.Preferences preferences = MDMBridge.openPreferences(context, configFilePath);
try {
preferences.setPreSharedKey(key);
preferences.commit();
Toast.makeText(context, R.string.advanced_presharedkey_saved_success, Toast.LENGTH_SHORT).show();
} catch (Exception e) {
- Log.e(LOGTAG, "Failed to save pre-shared key", e);
- Toast.makeText(context, R.string.advanced_presharedkey_save_error + ": " + e.getMessage(), Toast.LENGTH_LONG).show();
+ reportWriteFailure(context, e);
}
}
@@ -309,8 +362,11 @@ private boolean hasPreSharedKey(Context context) {
Log.e(LOGTAG, "Failed to get config path", e);
return false;
}
- io.netbird.gomobile.android.Preferences preferences = new io.netbird.gomobile.android.Preferences(configFilePath);
+ io.netbird.gomobile.android.Preferences preferences = MDMBridge.openPreferences(context, configFilePath);
try {
+ // Asks whether there is a key rather than for the key itself: a
+ // policy-supplied one is never handed to the native layer, and the
+ // field only ever shows the placeholder anyway.
return preferences.hasPreSharedKey();
} catch (Exception e) {
return false;
diff --git a/app/src/main/java/io/netbird/client/ui/fistinstall/FirstInstallFragment.java b/app/src/main/java/io/netbird/client/ui/fistinstall/FirstInstallFragment.java
index f8e4c9b7..3cc75d8c 100644
--- a/app/src/main/java/io/netbird/client/ui/fistinstall/FirstInstallFragment.java
+++ b/app/src/main/java/io/netbird/client/ui/fistinstall/FirstInstallFragment.java
@@ -23,7 +23,10 @@
import io.netbird.client.PlatformUtils;
import io.netbird.client.R;
import io.netbird.client.databinding.FragmentFirstinstallBinding;
+import io.netbird.client.tool.MDMBridge;
+import io.netbird.client.tool.MDMRestrictions;
import io.netbird.client.tool.ProfileManagerWrapper;
+import io.netbird.client.ui.MDMLock;
import io.netbird.client.ui.PreferenceUI;
import io.netbird.client.ui.server.ManagementServerSwitch;
import io.netbird.client.ui.server.ManagementUrl;
@@ -56,6 +59,10 @@ public class FirstInstallFragment extends Fragment {
// Set after a failed reachability check so a second tap continues anyway.
private boolean unreachable;
+ // True once the policy has taken the management server over, so the
+ // busy-state helper below cannot hand it back when a login finishes.
+ private boolean serverLocked;
+
@Override
public View onCreateView(@NonNull LayoutInflater inflater,
ViewGroup container, Bundle savedInstanceState) {
@@ -85,6 +92,30 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat
binding.txtAndroidtvBeta.setVisibility(View.VISIBLE);
binding.btnContinue.postDelayed(() -> binding.btnContinue.requestFocus(), 200);
}
+
+ applyMDMPolicy(view);
+ }
+
+ /**
+ * On a device that is enrolled before it is handed over, the server is
+ * already decided. Showing it and locking it is more honest than an empty
+ * field the user fills in only for the policy to overrule them at the first
+ * connection.
+ */
+ private void applyMDMPolicy(View root) {
+ MDMRestrictions restrictions = MDMBridge.restrictions(requireContext());
+ if (!restrictions.mdm.managesManagementURL() && !restrictions.features.disableUpdateSettings) {
+ return;
+ }
+ if (restrictions.mdm.managesManagementURL()
+ && !ManagementUrl.isCloud(restrictions.mdm.managementURL)) {
+ serverSwitch.setSelfHostedSilently(true);
+ binding.editTextServerUrl.setText(restrictions.mdm.managementURL);
+ binding.editTextServerUrl.setVisibility(View.VISIBLE);
+ }
+ serverLocked = true;
+ serverSwitch.setEnabled(false);
+ MDMLock.lockControls(root.findViewById(R.id.toggle_server_mode), binding.editTextServerUrl);
}
private void onModeChanged(boolean selfHosted) {
@@ -170,7 +201,7 @@ private void applyServer(String managementUrl, String setupKey) {
}
try {
- Preferences preferences = Android.newPreferences(configPath);
+ Preferences preferences = MDMBridge.openPreferences(requireContext(), configPath);
preferences.setManagementURL(managementUrl);
preferences.commit();
} catch (Exception e) {
@@ -188,7 +219,7 @@ private void applyServer(String managementUrl, String setupKey) {
setBusy(true);
Auth auth;
try {
- auth = Android.newAuth(configPath, managementUrl, null);
+ auth = MDMBridge.newAuth(requireContext(), configPath, managementUrl);
} catch (Exception e) {
Log.e(TAG, "Failed to create authenticator", e);
setBusy(false);
@@ -233,8 +264,8 @@ private void setBusy(boolean busy) {
binding.btnContinue.setText(busy
? R.string.profiles_dialog_checking
: R.string.fragment_firstinstall_continue);
- binding.editTextServerUrl.setEnabled(!busy);
- serverSwitch.setEnabled(!busy);
+ binding.editTextServerUrl.setEnabled(!busy && !serverLocked);
+ serverSwitch.setEnabled(!busy && !serverLocked);
setupKeySection.setEnabled(!busy);
}
diff --git a/app/src/main/java/io/netbird/client/ui/home/NetworksFragment.java b/app/src/main/java/io/netbird/client/ui/home/NetworksFragment.java
index 05b3be16..9269e4ce 100644
--- a/app/src/main/java/io/netbird/client/ui/home/NetworksFragment.java
+++ b/app/src/main/java/io/netbird/client/ui/home/NetworksFragment.java
@@ -27,6 +27,8 @@
import io.netbird.client.ServiceAccessor;
import io.netbird.client.StateListenerRegistry;
import io.netbird.client.databinding.FragmentNetworksBinding;
+import io.netbird.client.tool.MDMBridge;
+import io.netbird.client.tool.ProfileManagerWrapper;
public class NetworksFragment extends Fragment {
@@ -84,6 +86,11 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat
resourcesRecyclerView.setAdapter(adapter);
resourcesRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext()));
+ if (clientRoutesDisabledByPolicy()) {
+ showRoutesManagedByPolicy();
+ return;
+ }
+
model.getUiState().observe(getViewLifecycleOwner(), uiState -> {
resources.clear();
resources.addAll(uiState.getResources());
@@ -125,6 +132,35 @@ public void onDestroyView() {
super.onDestroyView();
}
+ /**
+ * Whether the policy has turned client routes off altogether.
+ *
+ * The managed flag alone is not enough: an administrator can manage the key
+ * and leave routes on, and the list is perfectly useful then. The value is
+ * read through the Go preferences, which answer with the policy's.
+ */
+ private boolean clientRoutesDisabledByPolicy() {
+ if (!MDMBridge.restrictions(requireContext()).mdm.disableClientRoutes) {
+ return false;
+ }
+ try {
+ String configPath = new ProfileManagerWrapper(requireContext()).getActiveConfigPath();
+ return MDMBridge.openPreferences(requireContext(), configPath).getDisableClientRoutes();
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ /**
+ * Says so instead of offering switches that select routes the engine has
+ * been told not to use.
+ */
+ private void showRoutesManagedByPolicy() {
+ binding.networksList.setVisibility(View.GONE);
+ binding.zeroPeerLayout.getRoot().setVisibility(View.GONE);
+ binding.routesManagedNotice.setVisibility(View.VISIBLE);
+ }
+
private void updateResourcesCounter(List resources) {
TextView textPeersCount = binding.textOpenPanel;
int connected = 0;
diff --git a/app/src/main/java/io/netbird/client/ui/profile/ProfileEditorDialog.java b/app/src/main/java/io/netbird/client/ui/profile/ProfileEditorDialog.java
index 3e8cf91e..db72d90f 100644
--- a/app/src/main/java/io/netbird/client/ui/profile/ProfileEditorDialog.java
+++ b/app/src/main/java/io/netbird/client/ui/profile/ProfileEditorDialog.java
@@ -23,10 +23,13 @@
import io.netbird.client.R;
import io.netbird.client.tool.Profile;
import io.netbird.client.tool.ProfileManagerWrapper;
+import io.netbird.client.ui.MDMLock;
import io.netbird.client.ui.server.ManagementServerSwitch;
import io.netbird.client.ui.server.ManagementUrl;
import io.netbird.client.ui.server.SetupKeySection;
import io.netbird.gomobile.android.Android;
+import io.netbird.client.tool.MDMBridge;
+import io.netbird.client.tool.MDMRestrictions;
import io.netbird.gomobile.android.Auth;
import io.netbird.gomobile.android.ErrListener;
import io.netbird.gomobile.android.Preferences;
@@ -71,6 +74,10 @@ public interface OnProfileSavedListener {
// the user can save anyway (soft warning, desktop parity).
private boolean unreachable;
+ // True once the policy has taken the management server over, so the
+ // busy-state helper below cannot hand it back when a check finishes.
+ private boolean serverLocked;
+
private ProfileEditorDialog(Context context, ProfileManagerWrapper profileManager,
Profile editing, OnProfileSavedListener listener) {
this.context = context;
@@ -132,12 +139,36 @@ private void showDialog() {
cancelButton.setOnClickListener(v -> dialog.dismiss());
okButton.setOnClickListener(v -> onSubmit());
+ applyMDMPolicy(dialogView);
+
dialog.show();
nameInput.requestFocus();
// Editing usually means overwriting the name, so pre-select it.
nameInput.selectAll();
}
+ /**
+ * A management server the organisation decided is not the profile's to
+ * change. The field already shows the enforced value — the Go preferences
+ * answer with it for a managed key — so this only ends the editing of it
+ * and says why.
+ */
+ private void applyMDMPolicy(View dialogView) {
+ MDMRestrictions restrictions = MDMBridge.restrictions(context);
+ if (!restrictions.mdm.managesManagementURL() && !restrictions.features.disableUpdateSettings) {
+ return;
+ }
+ if (restrictions.mdm.managesManagementURL()
+ && !ManagementUrl.isCloud(restrictions.mdm.managementURL)) {
+ serverSwitch.setSelfHostedSilently(true);
+ urlInput.setText(restrictions.mdm.managementURL);
+ urlInput.setVisibility(View.VISIBLE);
+ }
+ serverLocked = true;
+ serverSwitch.setEnabled(false);
+ MDMLock.lockControls(dialogView.findViewById(R.id.toggle_server_mode), urlInput);
+ }
+
private int submitLabel() {
return isEditing() ? R.string.profiles_dialog_edit_submit : R.string.profiles_add;
}
@@ -177,7 +208,7 @@ private void seedFromProfile() {
private String readManagementUrl() {
try {
String configPath = profileManager.getConfigPath(editing.getID());
- return Android.newPreferences(configPath).getManagementURL();
+ return MDMBridge.openPreferences(context, configPath).getManagementURL();
} catch (Exception e) {
// A profile that has never connected may not have a URL stored yet;
// fall back to Cloud rather than blocking the edit.
@@ -282,8 +313,7 @@ private void updateProfile(String name, String managementUrl) {
Log.e(TAG, "Failed to update management URL", e);
// The rename above may already have gone through; report the
// server failure rather than silently keeping the old URL.
- urlInput.setError(context.getString(R.string.profiles_dialog_url_invalid));
- urlInput.requestFocus();
+ showUrlWriteFailure(e);
return;
}
}
@@ -294,9 +324,22 @@ private void updateProfile(String name, String managementUrl) {
}
}
+ /**
+ * A URL the policy refused is not a malformed one. Saying which it was is
+ * the difference between the user hunting for a typo that is not there and
+ * understanding that the server is not theirs to choose.
+ */
+ private void showUrlWriteFailure(Exception e) {
+ boolean refused = MDMRestrictions.rejectedKeys(e.getMessage()) != null;
+ urlInput.setError(context.getString(refused
+ ? R.string.mdm_managed_setting
+ : R.string.profiles_dialog_url_invalid));
+ urlInput.requestFocus();
+ }
+
private void writeManagementUrl(String profileId, String managementUrl) throws Exception {
String configPath = profileManager.getConfigPath(profileId);
- Preferences preferences = Android.newPreferences(configPath);
+ Preferences preferences = MDMBridge.openPreferences(context, configPath);
preferences.setManagementURL(managementUrl);
preferences.commit();
}
@@ -319,8 +362,7 @@ private void createProfile(String name, String managementUrl) {
// Roll back: don't leave behind a profile pointing at the
// cloud server when the user asked for a self-hosted one.
rollBack(created);
- urlInput.setError(context.getString(R.string.profiles_dialog_url_invalid));
- urlInput.requestFocus();
+ showUrlWriteFailure(e);
return;
}
}
@@ -357,7 +399,7 @@ private void enrollWithSetupKey(Profile created, String managementUrl, String ke
setChecking(true);
Auth auth;
try {
- auth = Android.newAuth(configPath, managementUrl, null);
+ auth = MDMBridge.newAuth(context, configPath, managementUrl);
} catch (Exception e) {
Log.e(TAG, "Failed to create authenticator", e);
setChecking(false);
@@ -427,8 +469,8 @@ private void setChecking(boolean checking) {
okButton.setEnabled(!checking);
cancelButton.setEnabled(!checking);
nameInput.setEnabled(!checking);
- urlInput.setEnabled(!checking);
- serverSwitch.setEnabled(!checking);
+ urlInput.setEnabled(!checking && !serverLocked);
+ serverSwitch.setEnabled(!checking && !serverLocked);
setupKeySection.setEnabled(!checking);
// Pin the width before swapping in the shorter "Checking…" label so the
diff --git a/app/src/main/java/io/netbird/client/ui/profile/ProfilesFragment.java b/app/src/main/java/io/netbird/client/ui/profile/ProfilesFragment.java
index 7f181dcf..d120acf4 100644
--- a/app/src/main/java/io/netbird/client/ui/profile/ProfilesFragment.java
+++ b/app/src/main/java/io/netbird/client/ui/profile/ProfilesFragment.java
@@ -24,6 +24,7 @@
import java.util.List;
import io.netbird.client.R;
+import io.netbird.client.tool.MDMBridge;
import io.netbird.client.tool.Profile;
import io.netbird.client.tool.ProfileManagerWrapper;
@@ -35,6 +36,8 @@ public class ProfilesFragment extends Fragment {
private ProfileManagerWrapper profileManager;
private ProfileUsageTracker usageTracker;
private final List profiles = new ArrayList<>();
+ /** True while the organisation pins one profile and offers no others. */
+ private boolean managed;
@Nullable
@Override
@@ -48,24 +51,30 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c
recyclerView = view.findViewById(R.id.recycler_profiles);
recyclerView.setLayoutManager(new LinearLayoutManager(requireContext()));
+ managed = MDMBridge.restrictions(requireContext()).features.disableProfiles;
+
adapter = new ProfilesAdapter(profiles, new ProfilesAdapter.ProfileActionListener() {
@Override
public void onSwitchProfile(Profile profile) {
+ if (refused()) return;
showSwitchDialog(profile);
}
@Override
public void onEditProfile(Profile profile) {
+ if (refused()) return;
showEditDialog(profile);
}
@Override
public void onLogoutProfile(Profile profile) {
+ if (refused()) return;
showLogoutDialog(profile);
}
@Override
public void onRemoveProfile(Profile profile) {
+ if (refused()) return;
showRemoveDialog(profile);
}
});
@@ -73,12 +82,28 @@ public void onRemoveProfile(Profile profile) {
FloatingActionButton btnAdd = view.findViewById(R.id.btn_add_profile);
btnAdd.setOnClickListener(v -> showAddDialog());
+ if (managed) {
+ btnAdd.setVisibility(GONE);
+ }
loadProfiles();
return view;
}
+ /**
+ * Settings does not offer this screen under the policy, but the back stack
+ * can still land on it, and switching or deleting a profile is not an action
+ * to leave half-guarded.
+ */
+ private boolean refused() {
+ if (!managed) {
+ return false;
+ }
+ Toast.makeText(requireContext(), R.string.mdm_managed_setting, Toast.LENGTH_SHORT).show();
+ return true;
+ }
+
private void loadProfiles() {
profiles.clear();
List loadedProfiles = profileManager.listProfiles();
diff --git a/app/src/main/java/io/netbird/client/ui/settings/SettingsFragment.java b/app/src/main/java/io/netbird/client/ui/settings/SettingsFragment.java
index 6e88456d..8980e04d 100644
--- a/app/src/main/java/io/netbird/client/ui/settings/SettingsFragment.java
+++ b/app/src/main/java/io/netbird/client/ui/settings/SettingsFragment.java
@@ -18,8 +18,11 @@
import io.netbird.client.R;
import io.netbird.client.databinding.FragmentSettingsBinding;
+import io.netbird.client.tool.MDMBridge;
+import io.netbird.client.tool.MDMRestrictions;
import io.netbird.client.tool.Profile;
import io.netbird.client.tool.ProfileManagerWrapper;
+import io.netbird.client.ui.MDMLock;
import io.netbird.client.ui.profile.ProfileEditorDialog;
public class SettingsFragment extends Fragment {
@@ -77,6 +80,31 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat
});
setVersionText();
+
+ // After the listeners above: locking a row takes its listener away.
+ applyMDMPolicy();
+ }
+
+ /**
+ * What an administrator has taken off this screen.
+ *
+ * Profiles and the advanced section go away entirely — an organisation that
+ * pins one configuration has no use for a screen offering others. The server
+ * row stays: it is where the user sees which server they are on, so it is
+ * locked rather than hidden.
+ */
+ private void applyMDMPolicy() {
+ MDMRestrictions restrictions = MDMBridge.restrictions(requireContext());
+
+ if (restrictions.features.disableProfiles) {
+ MDMLock.hide(binding.rowProfiles);
+ }
+ if (restrictions.mdm.hidesAdvancedView()) {
+ MDMLock.hide(binding.rowAdvanced);
+ }
+ if (restrictions.mdm.managesManagementURL() || restrictions.features.disableUpdateSettings) {
+ MDMLock.lock(binding.rowChangeServer);
+ }
}
@Override
diff --git a/app/src/main/java/io/netbird/client/ui/splittunneling/AppListAdapter.java b/app/src/main/java/io/netbird/client/ui/splittunneling/AppListAdapter.java
index 10129015..cb1085c5 100644
--- a/app/src/main/java/io/netbird/client/ui/splittunneling/AppListAdapter.java
+++ b/app/src/main/java/io/netbird/client/ui/splittunneling/AppListAdapter.java
@@ -29,6 +29,7 @@ public interface OnAppToggledListener {
private Set selected;
private SplitTunnelConfig.Mode mode;
private String filterQueryString = "";
+ private boolean readOnly;
public AppListAdapter(Set selected, SplitTunnelConfig.Mode mode,
OnAppToggledListener toggleListener) {
@@ -50,6 +51,15 @@ public void setSelected(Set selected, SplitTunnelConfig.Mode mode) {
notifyDataSetChanged();
}
+ /**
+ * Shows the selection without offering to change it, for when an
+ * administrator decided which applications the tunnel carries.
+ */
+ public void setReadOnly(boolean readOnly) {
+ this.readOnly = readOnly;
+ notifyDataSetChanged();
+ }
+
public void filterBySearchQuery(String query) {
filterQueryString = query == null ? "" : query;
applyFilter();
@@ -112,10 +122,21 @@ void bind(AppEntry app) {
}
binding.switchControl.setChecked(selected.contains(app.getPackageName()));
+ binding.appNote.setVisibility(View.GONE);
+
+ if (readOnly) {
+ // The selection belongs to the policy: the row reports what the
+ // tunnel does with this app instead of offering a choice.
+ binding.switchControl.setEnabled(false);
+ binding.getRoot().setAlpha(0.6f);
+ binding.getRoot().setOnClickListener(null);
+ binding.getRoot().setClickable(false);
+ return;
+ }
+
binding.switchControl.setEnabled(true);
binding.switchControl.setOnCheckedChangeListener((buttonView, isChecked) ->
toggleListener.onAppToggled(app.getPackageName(), isChecked));
- binding.appNote.setVisibility(View.GONE);
binding.getRoot().setAlpha(1f);
binding.getRoot().setOnClickListener(v -> binding.switchControl.toggle());
}
diff --git a/app/src/main/java/io/netbird/client/ui/splittunneling/SplitTunnelingFragment.java b/app/src/main/java/io/netbird/client/ui/splittunneling/SplitTunnelingFragment.java
index 1fe398bc..96a0a3bc 100644
--- a/app/src/main/java/io/netbird/client/ui/splittunneling/SplitTunnelingFragment.java
+++ b/app/src/main/java/io/netbird/client/ui/splittunneling/SplitTunnelingFragment.java
@@ -22,8 +22,10 @@
import io.netbird.client.R;
import io.netbird.client.ServiceAccessor;
import io.netbird.client.databinding.FragmentSplitTunnelingBinding;
+import io.netbird.client.tool.MDMBridge;
import io.netbird.client.tool.SplitTunnelConfig;
import io.netbird.client.tool.SplitTunnelStore;
+import io.netbird.client.ui.MDMLock;
/**
* Lets the user say which applications the tunnel carries.
@@ -46,6 +48,8 @@ public class SplitTunnelingFragment extends Fragment
private SplitTunnelConfig.Mode mode = SplitTunnelConfig.Mode.OFF;
private final Set excluded = new HashSet<>();
private final Set included = new HashSet<>();
+ /** True while an administrator decides the selection instead of the user. */
+ private boolean managed;
@Override
public void onAttach(@NonNull Context context) {
@@ -70,18 +74,23 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat
super.onViewCreated(view, savedInstanceState);
store = new SplitTunnelStore(requireContext());
- SplitTunnelConfig stored = store.load();
+ SplitTunnelConfig stored = managedOrStored();
mode = stored.getMode();
excluded.addAll(stored.getExcluded());
included.addAll(stored.getIncluded());
pruneAlwaysExcluded();
adapter = new AppListAdapter(activeSelection(), mode, this);
+ adapter.setReadOnly(managed);
binding.appsRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext()));
binding.appsRecyclerView.setAdapter(adapter);
- binding.rowMode.setOnClickListener(v ->
- SplitTunnelModeSheet.newInstance(mode).show(getChildFragmentManager(), "split_tunnel_mode"));
+ if (managed) {
+ MDMLock.lock(binding.rowMode);
+ } else {
+ binding.rowMode.setOnClickListener(v ->
+ SplitTunnelModeSheet.newInstance(mode).show(getChildFragmentManager(), "split_tunnel_mode"));
+ }
binding.searchView.addTextChangedListener(new TextWatcher() {
@Override
@@ -123,7 +132,7 @@ public void onModeChanged(SplitTunnelConfig.Mode newMode) {
@Override
public void onAppToggled(String packageName, boolean selected) {
- if (SplitTunnelConfig.ALWAYS_EXCLUDED.contains(packageName)) {
+ if (managed || SplitTunnelConfig.ALWAYS_EXCLUDED.contains(packageName)) {
return;
}
Set selection = activeSelection();
@@ -140,7 +149,26 @@ private Set activeSelection() {
return mode == SplitTunnelConfig.Mode.INCLUDE ? included : excluded;
}
+ /**
+ * The policy's selection when an administrator decided it, the user's own
+ * otherwise — the same answer the tunnel acts on, so the screen shows what
+ * is actually applied rather than a stored choice that is being overruled.
+ */
+ private SplitTunnelConfig managedOrStored() {
+ SplitTunnelConfig policy = MDMBridge.managedSplitTunnel(requireContext());
+ // disableUpdateSettings leaves the user's own selection on screen but
+ // takes the editing away, the same as it does on the advanced screen.
+ managed = policy != null
+ || MDMBridge.restrictions(requireContext()).features.disableUpdateSettings;
+ return policy != null ? policy : store.load();
+ }
+
private void save() {
+ // A managed selection is not the user's to keep: writing it into their
+ // store would leave it behind as their own choice once the policy goes.
+ if (managed) {
+ return;
+ }
if (persist()) {
serviceAccessor.applySplitTunneling();
}
diff --git a/app/src/main/java/io/netbird/client/ui/troubleshoot/TroubleshootFragment.java b/app/src/main/java/io/netbird/client/ui/troubleshoot/TroubleshootFragment.java
index 9d02f216..3249824f 100644
--- a/app/src/main/java/io/netbird/client/ui/troubleshoot/TroubleshootFragment.java
+++ b/app/src/main/java/io/netbird/client/ui/troubleshoot/TroubleshootFragment.java
@@ -32,12 +32,16 @@
import io.netbird.client.R;
import io.netbird.client.ServiceAccessor;
import io.netbird.client.databinding.FragmentTroubleshootBinding;
+import io.netbird.client.tool.MDMBridge;
import io.netbird.client.tool.Preferences;
import io.netbird.client.tool.ProfileManagerWrapper;
+import io.netbird.client.ui.MDMLock;
public class TroubleshootFragment extends Fragment implements AnonymizeLevelSheet.OnLevelChangedListener {
private static final String LOGTAG = "TroubleshootFragment";
+ // Not in the Go enforcement snapshot; see initializeRemoteJobsSwitch.
+ private static final String KEY_ALLOW_REMOTE_JOBS = "allowRemoteJobs";
private static final String STATE_PENDING_BUNDLE = "pendingBundlePath";
private FragmentTroubleshootBinding binding;
@@ -154,7 +158,8 @@ private void updateAnonymizeValue() {
private void initializeRemoteJobsSwitch(Context context) {
try {
String configFilePath = new ProfileManagerWrapper(context).getActiveConfigPath();
- io.netbird.gomobile.android.Preferences goPreferences = new io.netbird.gomobile.android.Preferences(configFilePath);
+ io.netbird.gomobile.android.Preferences goPreferences =
+ MDMBridge.openPreferences(requireContext(), configFilePath);
binding.switchAllowRemoteJobs.setChecked(goPreferences.getRemoteJobsAllowed());
binding.switchAllowRemoteJobs.setOnCheckedChangeListener((buttonView, isChecked) -> {
try {
@@ -165,6 +170,18 @@ private void initializeRemoteJobsSwitch(Context context) {
}
});
binding.allowRemoteJobsLayout.setOnClickListener(v -> binding.switchAllowRemoteJobs.toggle());
+
+ // Last: locking the row takes the listeners above away. The switch
+ // already shows the enforced value — the Go preferences answer with
+ // the policy's — so this only ends the editing of it.
+ //
+ // allowRemoteJobs is asked of the managed configuration directly
+ // because the Go enforcement snapshot has no field for it: the
+ // desktop clients apply the key without offering a control.
+ if (MDMBridge.restrictions(context).features.disableUpdateSettings
+ || MDMBridge.manages(context, KEY_ALLOW_REMOTE_JOBS)) {
+ MDMLock.lock(binding.allowRemoteJobsLayout, binding.switchAllowRemoteJobs);
+ }
} catch (Exception e) {
Log.e(LOGTAG, "Failed to initialize remote jobs switch", e);
}
diff --git a/app/src/main/res/layout/fragment_networks.xml b/app/src/main/res/layout/fragment_networks.xml
index 347c8ccf..9db9a9c4 100644
--- a/app/src/main/res/layout/fragment_networks.xml
+++ b/app/src/main/res/layout/fragment_networks.xml
@@ -18,6 +18,25 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
+
+
+
Keine App ausgewählt, daher nutzen weiterhin alle Apps das VPN. Wählen Sie mindestens eine aus.Wird immer vom VPN ausgenommenKann das VPN nicht nutzen
+
+
+ Von Ihrer Organisation verwaltet
+ Diese Einstellung wird von Ihrer Organisation verwaltet und kann nicht geändert werden.
+ Diese Einstellung wird von Ihrer Organisation verwaltet und kann nicht geändert werden. (%s)
+ Netzwerkrouten werden von Ihrer Organisation verwaltet
+ Die NetBird-Konfiguration wurde durch Ihre IT-Richtlinie aktualisiert.
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index e44d46be..586f1791 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -320,4 +320,11 @@
No hay ninguna aplicación seleccionada, así que todas siguen usando la VPN. Elija al menos una.Siempre fuera de la VPNNo puede usar la VPN
+
+
+ Gestionado por su organización
+ Esta configuración está gestionada por su organización y no se puede cambiar.
+ Esta configuración está gestionada por su organización y no se puede cambiar. (%s)
+ Las rutas de red están gestionadas por su organización
+ La configuración de NetBird se actualizó según su política de TI.
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index 7e8baa0f..d250f86a 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -320,4 +320,11 @@
Aucune application sélectionnée, toutes continuent donc d\'utiliser le VPN. Choisissez-en au moins une.Toujours exclu du VPNNe peut pas utiliser le VPN
+
+
+ Géré par votre organisation
+ Ce paramètre est géré par votre organisation et ne peut pas être modifié.
+ Ce paramètre est géré par votre organisation et ne peut pas être modifié. (%s)
+ Les routes réseau sont gérées par votre organisation
+ La configuration NetBird a été mise à jour par votre politique informatique.
diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml
index 1004c96d..267e0700 100644
--- a/app/src/main/res/values-hu/strings.xml
+++ b/app/src/main/res/values-hu/strings.xml
@@ -317,4 +317,11 @@
Nincs kijelölt alkalmazás, ezért továbbra is mind a VPN-t használja. Jelöljön ki legalább egyet.Mindig kimarad a VPN-bőlNem használhatja a VPN-t
+
+
+ A szervezete kezeli
+ Ezt a beállítást a szervezete kezeli, ezért nem módosítható.
+ Ezt a beállítást a szervezete kezeli, ezért nem módosítható. (%s)
+ A hálózati útvonalakat a szervezete kezeli
+ A NetBird beállításait az informatikai szabályzat frissítette.
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
index 631cd69a..9e321075 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -320,4 +320,11 @@
Nessuna app selezionata, quindi tutte continuano a usare la VPN. Scegline almeno una.Sempre esclusa dalla VPNNon può usare la VPN
+
+
+ Gestito dalla tua organizzazione
+ Questa impostazione è gestita dalla tua organizzazione e non può essere modificata.
+ Questa impostazione è gestita dalla tua organizzazione e non può essere modificata. (%s)
+ Le route di rete sono gestite dalla tua organizzazione
+ La configurazione di NetBird è stata aggiornata dai criteri IT.
diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml
index 4e074098..949d387d 100644
--- a/app/src/main/res/values-ja/strings.xml
+++ b/app/src/main/res/values-ja/strings.xml
@@ -318,4 +318,11 @@
アプリが選択されていないため、すべてのアプリが VPN を使用したままです。1 つ以上選択してください。常に VPN から除外されますVPN を使用できません
+
+
+ 組織によって管理されています
+ この設定は組織によって管理されているため、変更できません。
+ この設定は組織によって管理されているため、変更できません。(%s)
+ ネットワークルートは組織によって管理されています
+ NetBird の設定が IT ポリシーによって更新されました。
diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml
index 045a9ea5..7e392b8a 100644
--- a/app/src/main/res/values-pt/strings.xml
+++ b/app/src/main/res/values-pt/strings.xml
@@ -320,4 +320,11 @@
Nenhum aplicativo selecionado, então todos continuam usando a VPN. Escolha pelo menos um.Sempre fora da VPNNão pode usar a VPN
+
+
+ Gerenciado pela sua organização
+ Esta configuração é gerenciada pela sua organização e não pode ser alterada.
+ Esta configuração é gerenciada pela sua organização e não pode ser alterada. (%s)
+ As rotas de rede são gerenciadas pela sua organização
+ A configuração do NetBird foi atualizada pela política de TI.
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index f2ad2714..a49e327d 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -326,4 +326,11 @@
Приложения не выбраны, поэтому VPN продолжают использовать все. Выберите хотя бы одно.Всегда исключено из VPNНе может использовать VPN
+
+
+ Управляется вашей организацией
+ Эта настройка управляется вашей организацией и не может быть изменена.
+ Эта настройка управляется вашей организацией и не может быть изменена. (%s)
+ Сетевые маршруты управляются вашей организацией
+ Конфигурация NetBird обновлена ИТ-политикой.
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index 41d02847..076785e7 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -317,4 +317,11 @@
未选择任何应用,因此所有应用仍会使用 VPN。请至少选择一个。始终不使用 VPN无法使用 VPN
+
+
+ 由您的组织管理
+ 此设置由您的组织管理,无法更改。
+ 此设置由您的组织管理,无法更改。(%s)
+ 网络路由由您的组织管理
+ NetBird 配置已由您的 IT 策略更新。
diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml
new file mode 100644
index 00000000..82f2ec75
--- /dev/null
+++ b/app/src/main/res/values/arrays.xml
@@ -0,0 +1,13 @@
+
+
+
+
+ @string/restriction_split_tunnel_mode_allow
+ @string/restriction_split_tunnel_mode_disallow
+
+
+ allow
+ disallow
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 39f5d7c0..829bbefe 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -323,4 +323,11 @@
No app selected, so every app keeps using the VPN. Pick at least one.Always kept out of the VPNCannot use the VPN
+
+
+ Managed by your organization
+ This setting is managed by your organization and cannot be changed.
+ This setting is managed by your organization and cannot be changed. (%s)
+ Network routes are managed by your organization
+ NetBird configuration was updated by your IT policy.
diff --git a/app/src/main/res/values/strings_app_restrictions.xml b/app/src/main/res/values/strings_app_restrictions.xml
new file mode 100644
index 00000000..1e1033bd
--- /dev/null
+++ b/app/src/main/res/values/strings_app_restrictions.xml
@@ -0,0 +1,63 @@
+
+
+
+ Management server URL
+ The NetBird management server every profile connects to. Leave unset to let the user choose.
+
+ Pre-shared key
+ WireGuard pre-shared key applied to peer connections. The user never sees the value.
+
+ Post-quantum encryption
+ Enables Rosenpass key exchange in addition to WireGuard.
+
+ Allow peers without post-quantum encryption
+ Connects to peers that do not offer Rosenpass, instead of refusing them.
+
+ Disable client routes
+ Stops the device from using routes other peers advertise.
+
+ Disable server routes
+ Stops the device from advertising routes to other peers.
+
+ Block inbound connections
+ Drops connections opened by other peers towards this device.
+
+ Allow the SSH server
+ Lets other peers open an SSH session to this device.
+
+ Allow remote debug bundles
+ Lets the management server ask this device for a debug bundle.
+
+ Split tunnelling mode
+ Whether the applications listed below are the only ones in the tunnel (allow), or the only ones kept out of it (disallow).
+ Only the listed applications use the VPN
+ The listed applications bypass the VPN
+
+ Split tunnelling applications
+ Package names, separated by commas. Applications not installed on the device are ignored.
+
+ Hide profiles
+ Removes profile switching, creation and removal from the app.
+
+ Hide networks
+ Removes the Networks tab, leaving routing to the policy.
+
+ Hide advanced settings
+ Removes the advanced settings screen from the app.
+
+ WireGuard port
+ UDP port the tunnel binds to. Must be between 1 and 65535.
+
+ Lazy connections
+ Connects to a peer on first use instead of at startup, overriding the setting the management server sends.
+
+ Debug bundle upload URL
+ Where remote debug bundles are uploaded. Must be an https URL. Leave unset to use the service the management server names.
+
+ Prevent changing settings
+ Leaves every setting visible but read-only.
+
diff --git a/app/src/main/res/xml/app_restrictions.xml b/app/src/main/res/xml/app_restrictions.xml
new file mode 100644
index 00000000..2f5c0e84
--- /dev/null
+++ b/app/src/main/res/xml/app_restrictions.xml
@@ -0,0 +1,132 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 8db3f2da..2f9efe35 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -2,6 +2,7 @@
agp = "8.13.0"
browser = "1.8.0"
junit = "4.13.2"
+json = "20240303"
junitVersion = "1.2.1"
espressoCore = "3.6.1"
uiautomator = "2.3.0"
@@ -22,6 +23,8 @@ work = "2.10.1"
[libraries]
browser = { module = "androidx.browser:browser", version.ref = "browser" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
+# Real org.json for JVM unit tests: the one in android.jar is a stub that throws.
+json = { group = "org.json", name = "json", version.ref = "json" }
ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" }
diff --git a/tool/build.gradle.kts b/tool/build.gradle.kts
index 34ec4384..595902b9 100644
--- a/tool/build.gradle.kts
+++ b/tool/build.gradle.kts
@@ -35,6 +35,7 @@ dependencies {
implementation(libs.material)
implementation(libs.work.runtime)
testImplementation(libs.junit)
+ testImplementation(libs.json)
androidTestImplementation(libs.ext.junit)
androidTestImplementation(libs.espresso.core)
androidTestImplementation(libs.work.testing)
diff --git a/tool/src/main/java/io/netbird/client/tool/EngineRunner.java b/tool/src/main/java/io/netbird/client/tool/EngineRunner.java
index cef67a8a..b4b0ac0b 100644
--- a/tool/src/main/java/io/netbird/client/tool/EngineRunner.java
+++ b/tool/src/main/java/io/netbird/client/tool/EngineRunner.java
@@ -38,6 +38,7 @@ class EngineRunner {
private volatile SessionMonitor sessionMonitor;
private final Client goClient;
private ConnectionListener connectionListener;
+ private volatile boolean restartPending;
public EngineRunner(Context context, NetworkChangeListener networkChangeListener, TunAdapter tunAdapter,
IFaceDiscover iFaceDiscover, String versionName, boolean isTraceLogEnabled, boolean isDebuggable,
@@ -54,6 +55,11 @@ public EngineRunner(Context context, NetworkChangeListener networkChangeListener
iFaceDiscover,
networkChangeListener);
+ // The engine reads MDM-managed values through this. Registering it on the
+ // client covers every config read the run loop makes, including the ones
+ // that happen before any screen has asked about the policy.
+ goClient.setMDMPolicyFetcher(MDMBridge.fetcher(context));
+
updateLogLevel(isTraceLogEnabled, isDebuggable);
// The Go-side subscription is client-scoped and survives engine
@@ -185,14 +191,41 @@ private synchronized void runClient(@Nullable URLOpener urlOpener, boolean isAnd
} finally {
engineIsRunning = false;
dnsWatch.removeDNSChangeListener();
- notifyServiceStateListeners(false);
}
Log.e(LOGTAG, "service stopped");
+ finishRun();
};
new Thread(r).start();
}
+ /**
+ * Ends a run, in one step: either the engine goes back up for a pending
+ * restart, or the stop is announced.
+ *
+ * Both halves are decided here, under the same lock {@link #stop()} takes,
+ * because they are one lifecycle transition and splitting them leaves two
+ * ways to go wrong. A stop that arrives while the run is winding down either
+ * lands before the decision, cancelling the restart so the stop is announced
+ * like any other, or after the new run has begun, where it stops that run.
+ * Neither order can start an engine the user has just turned off.
+ *
+ * Listeners therefore never see a stop that is about to be undone, which is
+ * what let a notification sit on "connecting" with nothing coming.
+ */
+ private synchronized void finishRun() {
+ if (restartPending) {
+ restartPending = false;
+ // Deliberately not an interactive start: a policy change must not
+ // pop a browser at a user who did nothing. If the new policy needs
+ // a login the run loop reports NeedsLogin, and the notification
+ // says so.
+ runClient(null, false);
+ return;
+ }
+ notifyServiceStateListeners(false);
+ }
+
private void changed(DNSList dnsServers) throws Exception {
goClient.onUpdatedHostDNS(dnsServers);
}
@@ -340,6 +373,33 @@ public synchronized void removeServiceStateListener(ServiceStateListener service
}
public synchronized void stop() {
+ // A stop the user asked for outranks a restart waiting to happen: the
+ // engine coming back by itself after they turned it off would be the
+ // worst way to find out a policy had changed.
+ restartPending = false;
+ goClient.stop();
+ }
+
+ /**
+ * Re-reads the managed configuration and reports whether it changed since it
+ * was last asked. The comparison is the Go side's, so what counts as a change
+ * is decided in one place for every platform.
+ */
+ public boolean hasMDMPolicyChanged() {
+ return goClient.hasMDMPolicyChanged();
+ }
+
+ /**
+ * Stops the engine and brings it back up, so a changed policy takes hold on a
+ * running tunnel. A no-op while the engine is down: the policy is read again
+ * when it next starts.
+ */
+ public synchronized void restart() {
+ if (!engineIsRunning) {
+ return;
+ }
+ Log.d(LOGTAG, "restarting the engine to apply a new MDM policy");
+ restartPending = true;
goClient.stop();
}
diff --git a/tool/src/main/java/io/netbird/client/tool/IFace.java b/tool/src/main/java/io/netbird/client/tool/IFace.java
index 1635acac..9f06a10e 100644
--- a/tool/src/main/java/io/netbird/client/tool/IFace.java
+++ b/tool/src/main/java/io/netbird/client/tool/IFace.java
@@ -152,12 +152,13 @@ private void prepareDnsSetting(VpnService.Builder builder, String dns) {
* The selection is read here rather than passed in because the tunnel is
* also rebuilt from VPNService without going through the Go engine, and both
* paths must see the same stored answer. It belongs to the active profile,
- * so switching profile switches which applications the tunnel carries.
+ * so switching profile switches which applications the tunnel carries —
+ * unless an administrator has decided it, in which case the policy's
+ * selection is the one that reaches the interface.
*/
private void applyAppFilter(VpnService.Builder builder) {
PackageManager packageManager = vpnService.getPackageManager();
- SplitTunnelConfig.Resolution resolution = new SplitTunnelStore(vpnService)
- .load()
+ SplitTunnelConfig.Resolution resolution = effectiveSplitTunnel()
.resolve(vpnService.getPackageName(), packageName -> {
try {
packageManager.getApplicationInfo(packageName, 0);
@@ -185,6 +186,22 @@ private void applyAppFilter(VpnService.Builder builder) {
+ resolution.getPackages().size() + " package(s)");
}
+ /**
+ * The selection a policy imposes, or the user's own when it imposes none.
+ *
+ * Read on every tunnel build rather than cached: the tunnel is rebuilt when
+ * the policy changes, and that rebuild is the moment the new list has to take
+ * effect.
+ */
+ private SplitTunnelConfig effectiveSplitTunnel() {
+ SplitTunnelConfig managed = MDMBridge.managedSplitTunnel(vpnService);
+ if (managed != null) {
+ Log.d(LOGTAG, "app filter is managed by the MDM policy");
+ return managed;
+ }
+ return new SplitTunnelStore(vpnService).load();
+ }
+
@SuppressLint("DefaultLocale")
@Override
public void updateAddr(String s) throws Exception {
diff --git a/tool/src/main/java/io/netbird/client/tool/MDMBridge.java b/tool/src/main/java/io/netbird/client/tool/MDMBridge.java
new file mode 100644
index 00000000..36fd763b
--- /dev/null
+++ b/tool/src/main/java/io/netbird/client/tool/MDMBridge.java
@@ -0,0 +1,139 @@
+package io.netbird.client.tool;
+
+import android.content.Context;
+import android.util.Log;
+
+import org.json.JSONObject;
+
+import io.netbird.gomobile.android.Android;
+import io.netbird.gomobile.android.Auth;
+import io.netbird.gomobile.android.Preferences;
+
+/**
+ * The single place the app reaches the MDM layer.
+ *
+ * Two jobs. It hands the policy source to every Go object that makes a decision
+ * from it — a client, a profile manager, a preferences instance, a login — so a
+ * new call site cannot quietly end up without one and read unmanaged values. And
+ * it holds the enforcement snapshot the screens render from, re-read when the app
+ * comes back into view and when the OS reports the policy changed, rather than on
+ * every question a layout asks.
+ */
+public final class MDMBridge {
+
+ private static final String LOGTAG = "MDMBridge";
+
+ private static volatile MDMPolicyFetcher fetcher;
+ private static volatile MDMRestrictions cached;
+ private static volatile String cachedToken = "";
+
+ private MDMBridge() {
+ }
+
+ /** The process-wide policy source; the bundle behind it is re-read per call. */
+ public static synchronized MDMPolicyFetcher fetcher(Context context) {
+ if (fetcher == null) {
+ fetcher = new MDMPolicyFetcher(context);
+ }
+ return fetcher;
+ }
+
+ /**
+ * Opens the Go preferences for a config file with the policy source attached.
+ * Use this instead of {@code Android.newPreferences} — a bare instance reads
+ * and writes as though no policy existed, which would both show the user
+ * unmanaged values and let a write past a managed key.
+ */
+ public static Preferences openPreferences(Context context, String configPath) {
+ Preferences preferences = Android.newPreferences(configPath);
+ preferences.setMDMPolicyFetcher(fetcher(context));
+ return preferences;
+ }
+
+ /**
+ * Builds an authenticator under the active policy. A managed management URL
+ * wins over the one passed in, which is decided on the Go side.
+ */
+ public static Auth newAuth(Context context, String configPath, String managementUrl) throws Exception {
+ return Android.newAuth(configPath, managementUrl, fetcher(context));
+ }
+
+ /**
+ * The enforcement snapshot the screens render from. Cheap to call: the
+ * snapshot is kept until something says it may have changed.
+ */
+ public static MDMRestrictions restrictions(Context context) {
+ MDMRestrictions snapshot = cached;
+ return snapshot != null ? snapshot : refresh(context);
+ }
+
+ /**
+ * Re-reads the snapshot through the Go bridge.
+ *
+ * A throwaway preferences instance is used rather than a shared one because
+ * getRestrictionsJSON() consults only the policy loader — it never reads or
+ * writes the config file — so this is free of side effects on the profile.
+ */
+ public static MDMRestrictions refresh(Context context) {
+ String json = read(context);
+ cachedToken = json;
+ cached = MDMRestrictions.decode(json);
+ return cached;
+ }
+
+ /**
+ * A value that changes exactly when the snapshot does.
+ *
+ * The screens lock and hide their controls in code, which only goes one way:
+ * a screen already built cannot tell that a setting became editable again.
+ * Comparing this against the value a screen was built from says when it has
+ * to be built afresh, and covers a policy being withdrawn as well as applied.
+ */
+ public static String snapshotToken(Context context) {
+ restrictions(context);
+ return cachedToken;
+ }
+
+ /**
+ * Whether the managed configuration carries this key at all.
+ *
+ * The Go snapshot is the authority for every key it reports, and screens
+ * should use it. This is for the few keys it does not carry — the ones the
+ * desktop clients enforce without a control of their own — and it reads the
+ * same managed configuration Go is handed, so the two cannot disagree.
+ */
+ public static boolean manages(Context context, String key) {
+ String json = fetcher(context).fetchJSON();
+ if (json.isEmpty()) {
+ return false;
+ }
+ try {
+ return new JSONObject(json).has(key);
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ /**
+ * The applications the policy says the tunnel carries, or null when it does
+ * not say. Read straight from the managed configuration rather than from the
+ * snapshot, which reports that the keys are managed but not which packages
+ * they name.
+ */
+ public static SplitTunnelConfig managedSplitTunnel(Context context) {
+ return MDMSplitTunnel.fromManagedConfiguration(fetcher(context).fetchJSON());
+ }
+
+ private static String read(Context context) {
+ try {
+ String configPath = new ProfileManagerWrapper(context).getActiveConfigPath();
+ return openPreferences(context, configPath).getRestrictionsJSON();
+ } catch (Exception e) {
+ // No active profile yet, or a bridge that could not answer. Reporting
+ // "nothing is managed" leaves the app usable; claiming the opposite
+ // would lock a user out of settings over a transient failure.
+ Log.w(LOGTAG, "could not read the MDM restrictions", e);
+ return "";
+ }
+ }
+}
diff --git a/tool/src/main/java/io/netbird/client/tool/MDMPolicyFetcher.java b/tool/src/main/java/io/netbird/client/tool/MDMPolicyFetcher.java
new file mode 100644
index 00000000..87f8bb51
--- /dev/null
+++ b/tool/src/main/java/io/netbird/client/tool/MDMPolicyFetcher.java
@@ -0,0 +1,100 @@
+package io.netbird.client.tool;
+
+import android.content.Context;
+import android.content.RestrictionsManager;
+import android.os.Bundle;
+import android.os.Parcelable;
+import android.util.Log;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import io.netbird.gomobile.android.PolicyFetcher;
+
+/**
+ * Hands the Go MDM layer the configuration a device owner or EMM set for this
+ * app.
+ *
+ * Android delivers managed configuration per app, into the app's own process,
+ * so unlike iOS there is no second process to mirror it into: the service that
+ * runs the engine reads the same bundle the UI does. No managed configuration
+ * means an empty answer, and the client then behaves as if the feature did not
+ * exist.
+ *
+ * One instance can serve every Go object that needs a policy source — the
+ * bundle is re-read on each call, so a fetcher registered once keeps answering
+ * with the current policy rather than the one that was live at registration.
+ */
+public class MDMPolicyFetcher implements PolicyFetcher {
+
+ private static final String LOGTAG = "MDMPolicyFetcher";
+
+ private final Context context;
+
+ public MDMPolicyFetcher(Context context) {
+ // The application context: this outlives any activity, and the Go side
+ // keeps the fetcher for as long as the client lives.
+ this.context = context.getApplicationContext();
+ }
+
+ @Override
+ public String fetchJSON() {
+ try {
+ RestrictionsManager manager =
+ (RestrictionsManager) context.getSystemService(Context.RESTRICTIONS_SERVICE);
+ if (manager == null) {
+ return "";
+ }
+ return ManagedConfiguration.encode(toMap(manager.getApplicationRestrictions()));
+ } catch (RuntimeException e) {
+ // Called from Go, sometimes on the engine's own goroutine: an
+ // exception escaping here would take the process down over a policy
+ // we could not read. An empty answer means the same as no MDM.
+ Log.w(LOGTAG, "could not read the managed configuration", e);
+ return "";
+ }
+ }
+
+ /**
+ * Flattens a restrictions bundle into a plain map, so the encoding rules can
+ * be tested without an Android runtime.
+ *
+ * Nested bundles and bundle arrays are what the OS produces for the
+ * {@code bundle} and {@code bundle_array} restriction types; they are
+ * carried across as objects and arrays of objects even though no key uses
+ * them yet, because dropping them silently would be the harder failure to
+ * explain later.
+ */
+ @SuppressWarnings("deprecation") // Bundle.get: the typed getters cannot be
+ // used without knowing each key's type, which is exactly what this does not
+ // want to hardcode.
+ static Map toMap(Bundle bundle) {
+ Map out = new LinkedHashMap<>();
+ if (bundle == null) {
+ return out;
+ }
+ for (String key : bundle.keySet()) {
+ Object value = bundle.get(key);
+ if (value instanceof Bundle) {
+ out.put(key, toMap((Bundle) value));
+ } else if (value instanceof Parcelable[]) {
+ out.put(key, toList((Parcelable[]) value));
+ } else if (value != null) {
+ out.put(key, value);
+ }
+ }
+ return out;
+ }
+
+ private static List