Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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