Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@
android:theme="@style/Theme.NetBird"
tools:targetApi="33">

<!-- The managed configuration an EMM can set for this app. -->
<meta-data
android:name="android.content.APP_RESTRICTIONS"
android:resource="@xml/app_restrictions" />

<activity
android:name=".MainActivity"
android:launchMode="singleTask"
Expand Down
91 changes: 91 additions & 0 deletions app/src/main/java/io/netbird/client/MainActivity.java
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@
import androidx.appcompat.app.AppCompatActivity;

import io.netbird.client.databinding.ActivityMainBinding;
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.tool.RouteChangeListener;
Expand Down Expand Up @@ -108,6 +110,12 @@ private enum ConnectionState {
// unbound; the cancel is issued from onServiceConnected.
private boolean pendingExtendCancel = false;

// The MDM snapshot this screen was built from. The gating below is applied
// in code and only goes one way, so a policy that changed under us is
// answered by building the screen again rather than by unpicking it.
private String appliedMDMSnapshot = "";
private android.content.BroadcastReceiver mdmPolicyReceiver;

// Last known state for UI updates
private ConnectionState lastKnownState = ConnectionState.UNKNOWN;
private String lastFqdn = null;
Expand Down Expand Up @@ -236,6 +244,8 @@ public boolean canConnect() {
return NavigationUI.onNavDestinationSelected(item, navController);
});

applyMDMPolicy(bottomNav);

navController.addOnDestinationChangedListener((controller, destination, arguments) -> {
int destId = destination.getId();

Expand Down Expand Up @@ -451,6 +461,10 @@ public void onConfigurationChanged(@NonNull Configuration newConfig) {
@Override
protected void onPause() {
super.onPause();
if (mdmPolicyReceiver != null) {
unregisterReceiver(mdmPolicyReceiver);
mdmPolicyReceiver = null;
}
}

@Override
Expand All @@ -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() {
Expand Down
147 changes: 147 additions & 0 deletions app/src/main/java/io/netbird/client/ui/MDMLock.java
Original file line number Diff line number Diff line change
@@ -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:
* <ul>
* <li>a feature the policy switches off entirely disappears — there is nothing
* to explain about a screen the organisation does not offer;</li>
* <li>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.</li>
* </ul>
*
* 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);
}
}
Loading