diff options
author | Harsh Shandilya <me@msfjarvis.dev> | 2020-03-09 19:06:11 +0530 |
---|---|---|
committer | Harsh Shandilya <me@msfjarvis.dev> | 2020-03-09 19:24:27 +0530 |
commit | 7d48bef70a56d4370856eedab619b1f83ac3d0d0 (patch) | |
tree | 76fd859578e499cd3a8fd2f402652530ea36a72d /ui/src/main/java/com/wireguard/android | |
parent | 6bc3e257f80a273d35d07099bd4ed99eb45163bf (diff) |
Rename app module to ui
Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>
Diffstat (limited to 'ui/src/main/java/com/wireguard/android')
49 files changed, 5937 insertions, 0 deletions
diff --git a/ui/src/main/java/com/wireguard/android/Application.java b/ui/src/main/java/com/wireguard/android/Application.java new file mode 100644 index 00000000..2ebeb69d --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/Application.java @@ -0,0 +1,171 @@ +/* + * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android; + +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.os.AsyncTask; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.os.StrictMode; +import android.util.Log; + +import androidx.preference.PreferenceManager; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatDelegate; + +import com.wireguard.android.backend.Backend; +import com.wireguard.android.backend.GoBackend; +import com.wireguard.android.backend.WgQuickBackend; +import com.wireguard.android.configStore.FileConfigStore; +import com.wireguard.android.model.TunnelManager; +import com.wireguard.android.util.AsyncWorker; +import com.wireguard.android.util.ExceptionLoggers; +import com.wireguard.android.util.ModuleLoader; +import com.wireguard.android.util.RootShell; +import com.wireguard.android.util.ToolsInstaller; + +import java.lang.ref.WeakReference; +import java.util.Locale; + +import java9.util.concurrent.CompletableFuture; + +public class Application extends android.app.Application { + private static final String TAG = "WireGuard/" + Application.class.getSimpleName(); + public static final String USER_AGENT; + + static { + String preferredAbi = "unknown ABI"; + if (Build.SUPPORTED_ABIS.length > 0) + preferredAbi = Build.SUPPORTED_ABIS[0]; + USER_AGENT = String.format(Locale.ENGLISH, "WireGuard/%s (Android %d; %s; %s; %s %s; %s)", BuildConfig.VERSION_NAME, Build.VERSION.SDK_INT, preferredAbi, Build.BOARD, Build.MANUFACTURER, Build.MODEL, Build.FINGERPRINT); + } + + @SuppressWarnings("NullableProblems") private static WeakReference<Application> weakSelf; + private final CompletableFuture<Backend> futureBackend = new CompletableFuture<>(); + @SuppressWarnings("NullableProblems") private AsyncWorker asyncWorker; + @Nullable private Backend backend; + @SuppressWarnings("NullableProblems") private RootShell rootShell; + @SuppressWarnings("NullableProblems") private SharedPreferences sharedPreferences; + @SuppressWarnings("NullableProblems") private ToolsInstaller toolsInstaller; + @SuppressWarnings("NullableProblems") private ModuleLoader moduleLoader; + @SuppressWarnings("NullableProblems") private TunnelManager tunnelManager; + + public Application() { + weakSelf = new WeakReference<>(this); + } + + public static Application get() { + return weakSelf.get(); + } + + public static AsyncWorker getAsyncWorker() { + return get().asyncWorker; + } + + public static Backend getBackend() { + final Application app = get(); + synchronized (app.futureBackend) { + if (app.backend == null) { + Backend backend = null; + boolean didStartRootShell = false; + if (!ModuleLoader.isModuleLoaded() && app.moduleLoader.moduleMightExist()) { + try { + app.rootShell.start(); + didStartRootShell = true; + app.moduleLoader.loadModule(); + } catch (final Exception ignored) { + } + } + if (ModuleLoader.isModuleLoaded()) { + try { + if (!didStartRootShell) + app.rootShell.start(); + backend = new WgQuickBackend(app.getApplicationContext(), app.rootShell, app.toolsInstaller); + } catch (final Exception ignored) { + } + } + if (backend == null) { + backend = new GoBackend(app.getApplicationContext()); + GoBackend.setAlwaysOnCallback(() -> { + get().tunnelManager.restoreState(true).whenComplete(ExceptionLoggers.D); + }); + } + app.backend = backend; + } + return app.backend; + } + } + + public static CompletableFuture<Backend> getBackendAsync() { + return get().futureBackend; + } + + public static RootShell getRootShell() { + return get().rootShell; + } + + public static SharedPreferences getSharedPreferences() { + return get().sharedPreferences; + } + + public static ToolsInstaller getToolsInstaller() { + return get().toolsInstaller; + } + + public static ModuleLoader getModuleLoader() { + return get().moduleLoader; + } + + public static TunnelManager getTunnelManager() { + return get().tunnelManager; + } + + @Override + protected void attachBaseContext(final Context context) { + super.attachBaseContext(context); + + if (BuildConfig.MIN_SDK_VERSION > Build.VERSION.SDK_INT) { + final Intent intent = new Intent(Intent.ACTION_MAIN); + intent.addCategory(Intent.CATEGORY_HOME); + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + startActivity(intent); + System.exit(0); + } + + if (BuildConfig.DEBUG) { + StrictMode.setVmPolicy(new StrictMode.VmPolicy.Builder().detectAll().penaltyLog().build()); + } + } + + @Override + public void onCreate() { + Log.i(TAG, USER_AGENT); + super.onCreate(); + + asyncWorker = new AsyncWorker(AsyncTask.SERIAL_EXECUTOR, new Handler(Looper.getMainLooper())); + rootShell = new RootShell(getApplicationContext()); + toolsInstaller = new ToolsInstaller(getApplicationContext(), rootShell); + moduleLoader = new ModuleLoader(getApplicationContext(), rootShell, USER_AGENT); + + sharedPreferences = PreferenceManager.getDefaultSharedPreferences(getApplicationContext()); + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + AppCompatDelegate.setDefaultNightMode( + sharedPreferences.getBoolean("dark_theme", false) ? + AppCompatDelegate.MODE_NIGHT_YES : AppCompatDelegate.MODE_NIGHT_NO); + } else { + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM); + } + + tunnelManager = new TunnelManager(new FileConfigStore(getApplicationContext())); + tunnelManager.onCreate(); + + asyncWorker.supplyAsync(Application::getBackend).thenAccept(futureBackend::complete); + } +} diff --git a/ui/src/main/java/com/wireguard/android/BootShutdownReceiver.java b/ui/src/main/java/com/wireguard/android/BootShutdownReceiver.java new file mode 100644 index 00000000..e3ffce7a --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/BootShutdownReceiver.java @@ -0,0 +1,38 @@ +/* + * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +import com.wireguard.android.backend.WgQuickBackend; +import com.wireguard.android.model.TunnelManager; +import com.wireguard.android.util.ExceptionLoggers; + +public class BootShutdownReceiver extends BroadcastReceiver { + private static final String TAG = "WireGuard/" + BootShutdownReceiver.class.getSimpleName(); + + @Override + public void onReceive(final Context context, final Intent intent) { + Application.getBackendAsync().thenAccept(backend -> { + if (!(backend instanceof WgQuickBackend)) + return; + final String action = intent.getAction(); + if (action == null) + return; + final TunnelManager tunnelManager = Application.getTunnelManager(); + if (Intent.ACTION_BOOT_COMPLETED.equals(action)) { + Log.i(TAG, "Broadcast receiver restoring state (boot)"); + tunnelManager.restoreState(false).whenComplete(ExceptionLoggers.D); + } else if (Intent.ACTION_SHUTDOWN.equals(action)) { + Log.i(TAG, "Broadcast receiver saving state (shutdown)"); + tunnelManager.saveState(); + } + }); + } +} diff --git a/ui/src/main/java/com/wireguard/android/QuickTileService.java b/ui/src/main/java/com/wireguard/android/QuickTileService.java new file mode 100644 index 00000000..66aecec3 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/QuickTileService.java @@ -0,0 +1,174 @@ +/* + * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android; + +import android.content.Intent; +import androidx.databinding.Observable; +import androidx.databinding.Observable.OnPropertyChangedCallback; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.drawable.Icon; +import android.os.Build; +import android.os.IBinder; +import android.service.quicksettings.Tile; +import android.service.quicksettings.TileService; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import android.util.Log; + +import com.wireguard.android.activity.MainActivity; +import com.wireguard.android.activity.TunnelToggleActivity; +import com.wireguard.android.backend.Tunnel.State; +import com.wireguard.android.model.ObservableTunnel; +import com.wireguard.android.widget.SlashDrawable; + +import java.util.Objects; + +/** + * Service that maintains the application's custom Quick Settings tile. This service is bound by the + * system framework as necessary to update the appearance of the tile in the system UI, and to + * forward click events to the application. + */ + +@RequiresApi(Build.VERSION_CODES.N) +public class QuickTileService extends TileService { + private static final String TAG = "WireGuard/" + QuickTileService.class.getSimpleName(); + + private final OnStateChangedCallback onStateChangedCallback = new OnStateChangedCallback(); + private final OnTunnelChangedCallback onTunnelChangedCallback = new OnTunnelChangedCallback(); + @Nullable private Icon iconOff; + @Nullable private Icon iconOn; + @Nullable private ObservableTunnel tunnel; + + /* This works around an annoying unsolved frameworks bug some people are hitting. */ + @Override + @Nullable + public IBinder onBind(final Intent intent) { + IBinder ret = null; + try { + ret = super.onBind(intent); + } catch (final Exception e) { + Log.d(TAG, "Failed to bind to TileService", e); + } + return ret; + } + + @Override + public void onClick() { + if (tunnel != null) { + unlockAndRun(() -> { + final Tile tile = getQsTile(); + if (tile != null) { + tile.setIcon(tile.getIcon() == iconOn ? iconOff : iconOn); + tile.updateTile(); + } + tunnel.setState(State.TOGGLE).whenComplete((v, t) -> { + if (t == null) { + updateTile(); + } else { + final Intent toggleIntent = new Intent(this, TunnelToggleActivity.class); + toggleIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + startActivity(toggleIntent); + } + }); + }); + } else { + final Intent intent = new Intent(this, MainActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + startActivityAndCollapse(intent); + } + } + + @Override + public void onCreate() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + iconOff = iconOn = Icon.createWithResource(this, R.drawable.ic_tile); + return; + } + final SlashDrawable icon = new SlashDrawable(getResources().getDrawable(R.drawable.ic_tile, Application.get().getTheme())); + icon.setAnimationEnabled(false); /* Unfortunately we can't have animations, since Icons are marshaled. */ + icon.setSlashed(false); + Bitmap b = Bitmap.createBitmap(icon.getIntrinsicWidth(), icon.getIntrinsicHeight(), Bitmap.Config.ARGB_8888); + Canvas c = new Canvas(b); + icon.setBounds(0, 0, c.getWidth(), c.getHeight()); + icon.draw(c); + iconOn = Icon.createWithBitmap(b); + icon.setSlashed(true); + b = Bitmap.createBitmap(icon.getIntrinsicWidth(), icon.getIntrinsicHeight(), Bitmap.Config.ARGB_8888); + c = new Canvas(b); + icon.setBounds(0, 0, c.getWidth(), c.getHeight()); + icon.draw(c); + iconOff = Icon.createWithBitmap(b); + } + + @Override + public void onStartListening() { + Application.getTunnelManager().addOnPropertyChangedCallback(onTunnelChangedCallback); + if (tunnel != null) + tunnel.addOnPropertyChangedCallback(onStateChangedCallback); + updateTile(); + } + + @Override + public void onStopListening() { + if (tunnel != null) + tunnel.removeOnPropertyChangedCallback(onStateChangedCallback); + Application.getTunnelManager().removeOnPropertyChangedCallback(onTunnelChangedCallback); + } + + private void updateTile() { + // Update the tunnel. + final ObservableTunnel newTunnel = Application.getTunnelManager().getLastUsedTunnel(); + if (newTunnel != tunnel) { + if (tunnel != null) + tunnel.removeOnPropertyChangedCallback(onStateChangedCallback); + tunnel = newTunnel; + if (tunnel != null) + tunnel.addOnPropertyChangedCallback(onStateChangedCallback); + } + // Update the tile contents. + final String label; + final int state; + final Tile tile = getQsTile(); + if (tunnel != null) { + label = tunnel.getName(); + state = tunnel.getState() == State.UP ? Tile.STATE_ACTIVE : Tile.STATE_INACTIVE; + } else { + label = getString(R.string.app_name); + state = Tile.STATE_INACTIVE; + } + if (tile == null) + return; + tile.setLabel(label); + if (tile.getState() != state) { + tile.setIcon(state == Tile.STATE_ACTIVE ? iconOn : iconOff); + tile.setState(state); + } + tile.updateTile(); + } + + private final class OnStateChangedCallback extends OnPropertyChangedCallback { + @Override + public void onPropertyChanged(final Observable sender, final int propertyId) { + if (!Objects.equals(sender, tunnel)) { + sender.removeOnPropertyChangedCallback(this); + return; + } + if (propertyId != 0 && propertyId != BR.state) + return; + updateTile(); + } + } + + private final class OnTunnelChangedCallback extends OnPropertyChangedCallback { + @Override + public void onPropertyChanged(final Observable sender, final int propertyId) { + if (propertyId != 0 && propertyId != BR.lastUsedTunnel) + return; + updateTile(); + } + } +} diff --git a/ui/src/main/java/com/wireguard/android/activity/BaseActivity.java b/ui/src/main/java/com/wireguard/android/activity/BaseActivity.java new file mode 100644 index 00000000..8ec58ee8 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/activity/BaseActivity.java @@ -0,0 +1,99 @@ +/* + * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.activity; + +import androidx.databinding.CallbackRegistry; +import androidx.databinding.CallbackRegistry.NotifierCallback; +import android.os.Bundle; +import androidx.annotation.Nullable; + +import com.wireguard.android.Application; +import com.wireguard.android.model.ObservableTunnel; + +import java.util.Objects; + +/** + * Base class for activities that need to remember the currently-selected tunnel. + */ + +public abstract class BaseActivity extends ThemeChangeAwareActivity { + private static final String KEY_SELECTED_TUNNEL = "selected_tunnel"; + + private final SelectionChangeRegistry selectionChangeRegistry = new SelectionChangeRegistry(); + @Nullable private ObservableTunnel selectedTunnel; + + public void addOnSelectedTunnelChangedListener(final OnSelectedTunnelChangedListener listener) { + selectionChangeRegistry.add(listener); + } + + @Nullable + public ObservableTunnel getSelectedTunnel() { + return selectedTunnel; + } + + @Override + protected void onCreate(@Nullable final Bundle savedInstanceState) { + // Restore the saved tunnel if there is one; otherwise grab it from the arguments. + final String savedTunnelName; + if (savedInstanceState != null) + savedTunnelName = savedInstanceState.getString(KEY_SELECTED_TUNNEL); + else if (getIntent() != null) + savedTunnelName = getIntent().getStringExtra(KEY_SELECTED_TUNNEL); + else + savedTunnelName = null; + + if (savedTunnelName != null) + Application.getTunnelManager().getTunnels() + .thenAccept(tunnels -> setSelectedTunnel(tunnels.get(savedTunnelName))); + + // The selected tunnel must be set before the superclass method recreates fragments. + super.onCreate(savedInstanceState); + } + + @Override + protected void onSaveInstanceState(final Bundle outState) { + if (selectedTunnel != null) + outState.putString(KEY_SELECTED_TUNNEL, selectedTunnel.getName()); + super.onSaveInstanceState(outState); + } + + protected abstract void onSelectedTunnelChanged(@Nullable ObservableTunnel oldTunnel, @Nullable ObservableTunnel newTunnel); + + public void removeOnSelectedTunnelChangedListener( + final OnSelectedTunnelChangedListener listener) { + selectionChangeRegistry.remove(listener); + } + + public void setSelectedTunnel(@Nullable final ObservableTunnel tunnel) { + final ObservableTunnel oldTunnel = selectedTunnel; + if (Objects.equals(oldTunnel, tunnel)) + return; + selectedTunnel = tunnel; + onSelectedTunnelChanged(oldTunnel, tunnel); + selectionChangeRegistry.notifyCallbacks(oldTunnel, 0, tunnel); + } + + public interface OnSelectedTunnelChangedListener { + void onSelectedTunnelChanged(@Nullable ObservableTunnel oldTunnel, @Nullable ObservableTunnel newTunnel); + } + + private static final class SelectionChangeNotifier + extends NotifierCallback<OnSelectedTunnelChangedListener, ObservableTunnel, ObservableTunnel> { + @Override + public void onNotifyCallback(final OnSelectedTunnelChangedListener listener, + final ObservableTunnel oldTunnel, final int ignored, + final ObservableTunnel newTunnel) { + listener.onSelectedTunnelChanged(oldTunnel, newTunnel); + } + } + + private static final class SelectionChangeRegistry + extends CallbackRegistry<OnSelectedTunnelChangedListener, ObservableTunnel, ObservableTunnel> { + private SelectionChangeRegistry() { + super(new SelectionChangeNotifier()); + } + } +} diff --git a/ui/src/main/java/com/wireguard/android/activity/MainActivity.java b/ui/src/main/java/com/wireguard/android/activity/MainActivity.java new file mode 100644 index 00000000..4c33f000 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/activity/MainActivity.java @@ -0,0 +1,144 @@ +/* + * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.activity; + +import android.annotation.SuppressLint; +import android.content.Intent; +import android.os.Bundle; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import androidx.fragment.app.FragmentTransaction; +import androidx.appcompat.app.ActionBar; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View.OnApplyWindowInsetsListener; +import android.widget.LinearLayout; + +import com.wireguard.android.R; +import com.wireguard.android.fragment.TunnelDetailFragment; +import com.wireguard.android.fragment.TunnelEditorFragment; +import com.wireguard.android.model.ObservableTunnel; + +import java.util.List; + +/** + * CRUD interface for WireGuard tunnels. This activity serves as the main entry point to the + * WireGuard application, and contains several fragments for listing, viewing details of, and + * editing the configuration and interface state of WireGuard tunnels. + */ + +public class MainActivity extends BaseActivity + implements FragmentManager.OnBackStackChangedListener { + @Nullable private ActionBar actionBar; + private boolean isTwoPaneLayout; + + @Override + public void onBackPressed() { + final int backStackEntries = getSupportFragmentManager().getBackStackEntryCount(); + // If the two-pane layout does not have an editor open, going back should exit the app. + if (isTwoPaneLayout && backStackEntries <= 1) { + finish(); + return; + } + // Deselect the current tunnel on navigating back from the detail pane to the one-pane list. + if (!isTwoPaneLayout && backStackEntries == 1) { + getSupportFragmentManager().popBackStack(); + setSelectedTunnel(null); + return; + } + super.onBackPressed(); + } + + @Override public void onBackStackChanged() { + if (actionBar == null) + return; + // Do not show the home menu when the two-pane layout is at the detail view (see above). + final int backStackEntries = getSupportFragmentManager().getBackStackEntryCount(); + final int minBackStackEntries = isTwoPaneLayout ? 2 : 1; + actionBar.setDisplayHomeAsUpEnabled(backStackEntries >= minBackStackEntries); + } + + // We use onTouchListener here to avoid the UI click sound, hence + // calling View#performClick defeats the purpose of it. + @SuppressLint("ClickableViewAccessibility") + @Override + protected void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.main_activity); + actionBar = getSupportActionBar(); + isTwoPaneLayout = findViewById(R.id.master_detail_wrapper) instanceof LinearLayout; + getSupportFragmentManager().addOnBackStackChangedListener(this); + onBackStackChanged(); + // Dispatch insets on back stack change + // This is required to ensure replaced fragments are also able to consume insets + findViewById(R.id.master_detail_wrapper).setOnApplyWindowInsetsListener((OnApplyWindowInsetsListener) (v, insets) -> { + final FragmentManager fragmentManager = getSupportFragmentManager(); + fragmentManager.addOnBackStackChangedListener(() -> { + final List<Fragment> fragments = fragmentManager.getFragments(); + for (int i = 0; i < fragments.size(); i++) { + fragments.get(i).requireView().dispatchApplyWindowInsets(insets); + } + }); + return insets; + }); + } + + @Override + public boolean onCreateOptionsMenu(final Menu menu) { + getMenuInflater().inflate(R.menu.main_activity, menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + // The back arrow in the action bar should act the same as the back button. + onBackPressed(); + return true; + case R.id.menu_action_edit: + getSupportFragmentManager().beginTransaction() + .replace(R.id.detail_container, new TunnelEditorFragment()) + .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) + .addToBackStack(null) + .commit(); + return true; + case R.id.menu_action_save: + // This menu item is handled by the editor fragment. + return false; + case R.id.menu_settings: + startActivity(new Intent(this, SettingsActivity.class)); + return true; + default: + return super.onOptionsItemSelected(item); + } + } + + @Override + protected void onSelectedTunnelChanged(@Nullable final ObservableTunnel oldTunnel, + @Nullable final ObservableTunnel newTunnel) { + final FragmentManager fragmentManager = getSupportFragmentManager(); + final int backStackEntries = fragmentManager.getBackStackEntryCount(); + if (newTunnel == null) { + // Clear everything off the back stack (all editors and detail fragments). + fragmentManager.popBackStackImmediate(0, FragmentManager.POP_BACK_STACK_INCLUSIVE); + return; + } + if (backStackEntries == 2) { + // Pop the editor off the back stack to reveal the detail fragment. Use the immediate + // method to avoid the editor picking up the new tunnel while it is still visible. + fragmentManager.popBackStackImmediate(); + } else if (backStackEntries == 0) { + // Create and show a new detail fragment. + fragmentManager.beginTransaction() + .add(R.id.detail_container, new TunnelDetailFragment()) + .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) + .addToBackStack(null) + .commit(); + } + } +} diff --git a/ui/src/main/java/com/wireguard/android/activity/SettingsActivity.java b/ui/src/main/java/com/wireguard/android/activity/SettingsActivity.java new file mode 100644 index 00000000..f545c371 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/activity/SettingsActivity.java @@ -0,0 +1,129 @@ +/* + * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.activity; + +import android.content.pm.PackageManager; +import android.os.Build; +import android.os.Bundle; +import androidx.annotation.Nullable; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; +import androidx.preference.Preference; +import androidx.preference.PreferenceFragmentCompat; +import androidx.preference.PreferenceScreen; +import android.util.SparseArray; +import android.view.MenuItem; + +import com.wireguard.android.Application; +import com.wireguard.android.R; +import com.wireguard.android.backend.WgQuickBackend; +import com.wireguard.android.util.ModuleLoader; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Interface for changing application-global persistent settings. + */ + +public class SettingsActivity extends ThemeChangeAwareActivity { + private final SparseArray<PermissionRequestCallback> permissionRequestCallbacks = new SparseArray<>(); + private int permissionRequestCounter; + + public void ensurePermissions(final String[] permissions, final PermissionRequestCallback cb) { + final List<String> needPermissions = new ArrayList<>(permissions.length); + for (final String permission : permissions) { + if (ContextCompat.checkSelfPermission(this, permission) + != PackageManager.PERMISSION_GRANTED) + needPermissions.add(permission); + } + if (needPermissions.isEmpty()) { + final int[] granted = new int[permissions.length]; + Arrays.fill(granted, PackageManager.PERMISSION_GRANTED); + cb.done(permissions, granted); + return; + } + final int idx = permissionRequestCounter++; + permissionRequestCallbacks.put(idx, cb); + ActivityCompat.requestPermissions(this, + needPermissions.toArray(new String[needPermissions.size()]), idx); + } + + @Override + protected void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (getSupportFragmentManager().findFragmentById(android.R.id.content) == null) { + getSupportFragmentManager().beginTransaction() + .add(android.R.id.content, new SettingsFragment()) + .commit(); + } + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + finish(); + return true; + default: + return super.onOptionsItemSelected(item); + } + } + + @Override + public void onRequestPermissionsResult(final int requestCode, + final String[] permissions, + final int[] grantResults) { + final PermissionRequestCallback f = permissionRequestCallbacks.get(requestCode); + if (f != null) { + permissionRequestCallbacks.remove(requestCode); + f.done(permissions, grantResults); + } + } + + public interface PermissionRequestCallback { + void done(String[] permissions, int[] grantResults); + } + + public static class SettingsFragment extends PreferenceFragmentCompat { + @Override + public void onCreatePreferences(final Bundle savedInstanceState, final String key) { + addPreferencesFromResource(R.xml.preferences); + final PreferenceScreen screen = getPreferenceScreen(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) + screen.removePreference(getPreferenceManager().findPreference("dark_theme")); + + final Preference wgQuickOnlyPrefs[] = { + getPreferenceManager().findPreference("tools_installer"), + getPreferenceManager().findPreference("restore_on_boot") + }; + for (final Preference pref : wgQuickOnlyPrefs) + pref.setVisible(false); + Application.getBackendAsync().thenAccept(backend -> { + for (final Preference pref : wgQuickOnlyPrefs) { + if (backend instanceof WgQuickBackend) + pref.setVisible(true); + else + screen.removePreference(pref); + } + }); + + final Preference moduleInstaller = getPreferenceManager().findPreference("module_downloader"); + moduleInstaller.setVisible(false); + if (ModuleLoader.isModuleLoaded()) { + screen.removePreference(moduleInstaller); + } else { + Application.getAsyncWorker().runAsync(Application.getRootShell()::start).whenComplete((v, e) -> { + if (e == null) + moduleInstaller.setVisible(true); + else + screen.removePreference(moduleInstaller); + }); + } + } + } +} diff --git a/ui/src/main/java/com/wireguard/android/activity/ThemeChangeAwareActivity.java b/ui/src/main/java/com/wireguard/android/activity/ThemeChangeAwareActivity.java new file mode 100644 index 00000000..602ad37c --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/activity/ThemeChangeAwareActivity.java @@ -0,0 +1,47 @@ +/* + * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.activity; + +import android.content.SharedPreferences; +import android.content.res.Resources; +import android.os.Build; +import android.os.Bundle; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.app.AppCompatDelegate; +import android.util.Log; + +import com.wireguard.android.Application; + +import java.lang.reflect.Field; + +public abstract class ThemeChangeAwareActivity extends AppCompatActivity implements SharedPreferences.OnSharedPreferenceChangeListener { + private static final String TAG = "WireGuard/" + ThemeChangeAwareActivity.class.getSimpleName(); + + @Override + protected void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) + Application.getSharedPreferences().registerOnSharedPreferenceChangeListener(this); + } + + @Override + protected void onDestroy() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) + Application.getSharedPreferences().unregisterOnSharedPreferenceChangeListener(this); + super.onDestroy(); + } + + @Override + public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences, final String key) { + if ("dark_theme".equals(key)) { + AppCompatDelegate.setDefaultNightMode( + sharedPreferences.getBoolean(key, false) ? + AppCompatDelegate.MODE_NIGHT_YES : + AppCompatDelegate.MODE_NIGHT_NO); + } + } +} diff --git a/ui/src/main/java/com/wireguard/android/activity/TunnelCreatorActivity.java b/ui/src/main/java/com/wireguard/android/activity/TunnelCreatorActivity.java new file mode 100644 index 00000000..c87ec537 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/activity/TunnelCreatorActivity.java @@ -0,0 +1,34 @@ +/* + * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.activity; + +import android.os.Bundle; +import androidx.annotation.Nullable; + +import com.wireguard.android.fragment.TunnelEditorFragment; +import com.wireguard.android.model.ObservableTunnel; + +/** + * Standalone activity for creating tunnels. + */ + +public class TunnelCreatorActivity extends BaseActivity { + @Override + @SuppressWarnings("UnnecessaryFullyQualifiedName") + protected void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (getSupportFragmentManager().findFragmentById(android.R.id.content) == null) { + getSupportFragmentManager().beginTransaction() + .add(android.R.id.content, new TunnelEditorFragment()) + .commit(); + } + } + + @Override + protected void onSelectedTunnelChanged(@Nullable final ObservableTunnel oldTunnel, @Nullable final ObservableTunnel newTunnel) { + finish(); + } +} diff --git a/ui/src/main/java/com/wireguard/android/activity/TunnelToggleActivity.java b/ui/src/main/java/com/wireguard/android/activity/TunnelToggleActivity.java new file mode 100644 index 00000000..09a34bf7 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/activity/TunnelToggleActivity.java @@ -0,0 +1,51 @@ +/* + * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.activity; + +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.appcompat.app.AppCompatActivity; + +import android.content.ComponentName; +import android.os.Bundle; +import android.os.Build; +import android.service.quicksettings.TileService; +import android.util.Log; +import android.widget.Toast; + +import com.wireguard.android.Application; +import com.wireguard.android.QuickTileService; +import com.wireguard.android.R; +import com.wireguard.android.model.ObservableTunnel; +import com.wireguard.android.backend.Tunnel.State; +import com.wireguard.android.util.ErrorMessages; + +@RequiresApi(Build.VERSION_CODES.N) +public class TunnelToggleActivity extends AppCompatActivity { + private static final String TAG = "WireGuard/" + TunnelToggleActivity.class.getSimpleName(); + + @Override + protected void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + final ObservableTunnel tunnel = Application.getTunnelManager().getLastUsedTunnel(); + if (tunnel == null) + return; + tunnel.setState(State.TOGGLE).whenComplete((v, t) -> { + TileService.requestListeningState(this, new ComponentName(this, QuickTileService.class)); + onToggleFinished(t); + finishAffinity(); + }); + } + + private void onToggleFinished(@Nullable final Throwable throwable) { + if (throwable == null) + return; + final String error = ErrorMessages.get(throwable); + final String message = getString(R.string.toggle_error, error); + Log.e(TAG, message, throwable); + Toast.makeText(this, message, Toast.LENGTH_LONG).show(); + } +} diff --git a/ui/src/main/java/com/wireguard/android/configStore/ConfigStore.java b/ui/src/main/java/com/wireguard/android/configStore/ConfigStore.java new file mode 100644 index 00000000..d4761464 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/configStore/ConfigStore.java @@ -0,0 +1,67 @@ +/* + * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.configStore; + +import com.wireguard.config.Config; + +import java.util.Set; + +/** + * Interface for persistent storage providers for WireGuard configurations. + */ + +public interface ConfigStore { + /** + * Create a persistent tunnel, which must have a unique name within the persistent storage + * medium. + * + * @param name The name of the tunnel to create. + * @param config Configuration for the new tunnel. + * @return The configuration that was actually saved to persistent storage. + */ + Config create(final String name, final Config config) throws Exception; + + /** + * Delete a persistent tunnel. + * + * @param name The name of the tunnel to delete. + */ + void delete(final String name) throws Exception; + + /** + * Enumerate the names of tunnels present in persistent storage. + * + * @return The set of present tunnel names. + */ + Set<String> enumerate(); + + /** + * Load the configuration for the tunnel given by {@code name}. + * + * @param name The identifier for the configuration in persistent storage (i.e. the name of the + * tunnel). + * @return An in-memory representation of the configuration loaded from persistent storage. + */ + Config load(final String name) throws Exception; + + /** + * Rename the configuration for the tunnel given by {@code name}. + * + * @param name The identifier for the existing configuration in persistent storage. + * @param replacement The new identifier for the configuration in persistent storage. + */ + void rename(String name, String replacement) throws Exception; + + /** + * Save the configuration for an existing tunnel given by {@code name}. + * + * @param name The identifier for the configuration in persistent storage (i.e. the name of + * the tunnel). + * @param config An updated configuration object for the tunnel. + * @return The configuration that was actually saved to persistent storage. + */ + Config save(final String name, final Config config) throws Exception; +} diff --git a/ui/src/main/java/com/wireguard/android/configStore/FileConfigStore.java b/ui/src/main/java/com/wireguard/android/configStore/FileConfigStore.java new file mode 100644 index 00000000..45f2f759 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/configStore/FileConfigStore.java @@ -0,0 +1,103 @@ +/* + * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.configStore; + +import android.content.Context; +import android.util.Log; + +import com.wireguard.android.R; +import com.wireguard.config.BadConfigException; +import com.wireguard.config.Config; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Set; + +import java9.util.stream.Collectors; +import java9.util.stream.Stream; + +/** + * Configuration store that uses a {@code wg-quick}-style file for each configured tunnel. + */ + +public final class FileConfigStore implements ConfigStore { + private static final String TAG = "WireGuard/" + FileConfigStore.class.getSimpleName(); + + private final Context context; + + public FileConfigStore(final Context context) { + this.context = context; + } + + @Override + public Config create(final String name, final Config config) throws IOException { + Log.d(TAG, "Creating configuration for tunnel " + name); + final File file = fileFor(name); + if (!file.createNewFile()) + throw new IOException(context.getString(R.string.config_file_exists_error, file.getName())); + try (final FileOutputStream stream = new FileOutputStream(file, false)) { + stream.write(config.toWgQuickString().getBytes(StandardCharsets.UTF_8)); + } + return config; + } + + @Override + public void delete(final String name) throws IOException { + Log.d(TAG, "Deleting configuration for tunnel " + name); + final File file = fileFor(name); + if (!file.delete()) + throw new IOException(context.getString(R.string.config_delete_error, file.getName())); + } + + @Override + public Set<String> enumerate() { + return Stream.of(context.fileList()) + .filter(name -> name.endsWith(".conf")) + .map(name -> name.substring(0, name.length() - ".conf".length())) + .collect(Collectors.toUnmodifiableSet()); + } + + private File fileFor(final String name) { + return new File(context.getFilesDir(), name + ".conf"); + } + + @Override + public Config load(final String name) throws BadConfigException, IOException { + try (final FileInputStream stream = new FileInputStream(fileFor(name))) { + return Config.parse(stream); + } + } + + @Override + public void rename(final String name, final String replacement) throws IOException { + Log.d(TAG, "Renaming configuration for tunnel " + name + " to " + replacement); + final File file = fileFor(name); + final File replacementFile = fileFor(replacement); + if (!replacementFile.createNewFile()) + throw new IOException(context.getString(R.string.config_exists_error, replacement)); + if (!file.renameTo(replacementFile)) { + if (!replacementFile.delete()) + Log.w(TAG, "Couldn't delete marker file for new name " + replacement); + throw new IOException(context.getString(R.string.config_rename_error, file.getName())); + } + } + + @Override + public Config save(final String name, final Config config) throws IOException { + Log.d(TAG, "Saving configuration for tunnel " + name); + final File file = fileFor(name); + if (!file.isFile()) + throw new FileNotFoundException(context.getString(R.string.config_not_found_error, file.getName())); + try (final FileOutputStream stream = new FileOutputStream(file, false)) { + stream.write(config.toWgQuickString().getBytes(StandardCharsets.UTF_8)); + } + return config; + } +} diff --git a/ui/src/main/java/com/wireguard/android/databinding/BindingAdapters.java b/ui/src/main/java/com/wireguard/android/databinding/BindingAdapters.java new file mode 100644 index 00000000..ee216d4c --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/databinding/BindingAdapters.java @@ -0,0 +1,148 @@ +/* + * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.databinding; + +import androidx.databinding.BindingAdapter; +import androidx.databinding.DataBindingUtil; +import androidx.databinding.ObservableList; +import androidx.databinding.ViewDataBinding; +import androidx.databinding.adapters.ListenerUtil; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import android.text.InputFilter; +import android.view.LayoutInflater; +import android.widget.LinearLayout; +import android.widget.TextView; + +import com.wireguard.android.BR; +import com.wireguard.android.R; +import com.wireguard.android.databinding.ObservableKeyedRecyclerViewAdapter.RowConfigurationHandler; +import com.wireguard.android.util.ObservableKeyedList; +import com.wireguard.android.widget.ToggleSwitch; +import com.wireguard.android.widget.ToggleSwitch.OnBeforeCheckedChangeListener; +import com.wireguard.config.Attribute; +import com.wireguard.config.InetNetwork; +import com.wireguard.util.Keyed; + +import java9.util.Optional; + +/** + * Static methods for use by generated code in the Android data binding library. + */ + +@SuppressWarnings("unused") +public final class BindingAdapters { + private BindingAdapters() { + // Prevent instantiation. + } + + @BindingAdapter("checked") + public static void setChecked(final ToggleSwitch view, final boolean checked) { + view.setCheckedInternal(checked); + } + + @BindingAdapter("filter") + public static void setFilter(final TextView view, final InputFilter filter) { + view.setFilters(new InputFilter[]{filter}); + } + + @BindingAdapter({"items", "layout"}) + public static <E> + void setItems(final LinearLayout view, + @Nullable final ObservableList<E> oldList, final int oldLayoutId, + @Nullable final ObservableList<E> newList, final int newLayoutId) { + if (oldList == newList && oldLayoutId == newLayoutId) + return; + ItemChangeListener<E> listener = ListenerUtil.getListener(view, R.id.item_change_listener); + // If the layout changes, any existing listener must be replaced. + if (listener != null && oldList != null && oldLayoutId != newLayoutId) { + listener.setList(null); + listener = null; + // Stop tracking the old listener. + ListenerUtil.trackListener(view, null, R.id.item_change_listener); + } + // Avoid adding a listener when there is no new list or layout. + if (newList == null || newLayoutId == 0) + return; + if (listener == null) { + listener = new ItemChangeListener<>(view, newLayoutId); + ListenerUtil.trackListener(view, listener, R.id.item_change_listener); + } + // Either the list changed, or this is an entirely new listener because the layout changed. + listener.setList(newList); + } + + @BindingAdapter({"items", "layout"}) + public static <E> + void setItems(final LinearLayout view, + @Nullable final Iterable<E> oldList, final int oldLayoutId, + @Nullable final Iterable<E> newList, final int newLayoutId) { + if (oldList == newList && oldLayoutId == newLayoutId) + return; + view.removeAllViews(); + if (newList == null) + return; + final LayoutInflater layoutInflater = LayoutInflater.from(view.getContext()); + for (final E item : newList) { + final ViewDataBinding binding = + DataBindingUtil.inflate(layoutInflater, newLayoutId, view, false); + binding.setVariable(BR.collection, newList); + binding.setVariable(BR.item, item); + binding.executePendingBindings(); + view.addView(binding.getRoot()); + } + } + + @BindingAdapter(requireAll = false, value = {"items", "layout", "configurationHandler"}) + public static <K, E extends Keyed<? extends K>> + void setItems(final RecyclerView view, + @Nullable final ObservableKeyedList<K, E> oldList, final int oldLayoutId, + final RowConfigurationHandler oldRowConfigurationHandler, + @Nullable final ObservableKeyedList<K, E> newList, final int newLayoutId, + final RowConfigurationHandler newRowConfigurationHandler) { + if (view.getLayoutManager() == null) + view.setLayoutManager(new LinearLayoutManager(view.getContext(), RecyclerView.VERTICAL, false)); + + if (oldList == newList && oldLayoutId == newLayoutId) + return; + // The ListAdapter interface is not generic, so this cannot be checked. + @SuppressWarnings("unchecked") ObservableKeyedRecyclerViewAdapter<K, E> adapter = + (ObservableKeyedRecyclerViewAdapter<K, E>) view.getAdapter(); + // If the layout changes, any existing adapter must be replaced. + if (adapter != null && oldList != null && oldLayoutId != newLayoutId) { + adapter.setList(null); + adapter = null; + } + // Avoid setting an adapter when there is no new list or layout. + if (newList == null || newLayoutId == 0) + return; + if (adapter == null) { + adapter = new ObservableKeyedRecyclerViewAdapter<>(view.getContext(), newLayoutId, newList); + view.setAdapter(adapter); + } + + adapter.setRowConfigurationHandler(newRowConfigurationHandler); + // Either the list changed, or this is an entirely new listener because the layout changed. + adapter.setList(newList); + } + + @BindingAdapter("onBeforeCheckedChanged") + public static void setOnBeforeCheckedChanged(final ToggleSwitch view, + final OnBeforeCheckedChangeListener listener) { + view.setOnBeforeCheckedChangeListener(listener); + } + + @BindingAdapter("android:text") + public static void setText(final TextView view, final Optional<?> text) { + view.setText(text.map(Object::toString).orElse("")); + } + + @BindingAdapter("android:text") + public static void setText(final TextView view, @Nullable final Iterable<InetNetwork> networks) { + view.setText(networks != null ? Attribute.join(networks) : ""); + } +} diff --git a/ui/src/main/java/com/wireguard/android/databinding/ItemChangeListener.java b/ui/src/main/java/com/wireguard/android/databinding/ItemChangeListener.java new file mode 100644 index 00000000..e7303eae --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/databinding/ItemChangeListener.java @@ -0,0 +1,140 @@ +/* + * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.databinding; + +import androidx.databinding.DataBindingUtil; +import androidx.databinding.ObservableList; +import androidx.databinding.ViewDataBinding; +import androidx.annotation.Nullable; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import com.wireguard.android.BR; + +import java.lang.ref.WeakReference; +import java.util.Objects; + +/** + * Helper class for binding an ObservableList to the children of a ViewGroup. + */ + +class ItemChangeListener<T> { + private final OnListChangedCallback<T> callback = new OnListChangedCallback<>(this); + private final ViewGroup container; + private final int layoutId; + private final LayoutInflater layoutInflater; + @Nullable private ObservableList<T> list; + + ItemChangeListener(final ViewGroup container, final int layoutId) { + this.container = container; + this.layoutId = layoutId; + layoutInflater = LayoutInflater.from(container.getContext()); + } + + private View getView(final int position, @Nullable final View convertView) { + ViewDataBinding binding = convertView != null ? DataBindingUtil.getBinding(convertView) : null; + if (binding == null) { + binding = DataBindingUtil.inflate(layoutInflater, layoutId, container, false); + } + + Objects.requireNonNull(list, "Trying to get a view while list is still null"); + + binding.setVariable(BR.collection, list); + binding.setVariable(BR.item, list.get(position)); + binding.executePendingBindings(); + return binding.getRoot(); + } + + void setList(@Nullable final ObservableList<T> newList) { + if (list != null) + list.removeOnListChangedCallback(callback); + list = newList; + if (list != null) { + list.addOnListChangedCallback(callback); + callback.onChanged(list); + } else { + container.removeAllViews(); + } + } + + private static final class OnListChangedCallback<T> + extends ObservableList.OnListChangedCallback<ObservableList<T>> { + + private final WeakReference<ItemChangeListener<T>> weakListener; + + private OnListChangedCallback(final ItemChangeListener<T> listener) { + weakListener = new WeakReference<>(listener); + } + + @Override + public void onChanged(final ObservableList<T> sender) { + final ItemChangeListener<T> listener = weakListener.get(); + if (listener != null) { + // TODO: recycle views + listener.container.removeAllViews(); + for (int i = 0; i < sender.size(); ++i) + listener.container.addView(listener.getView(i, null)); + } else { + sender.removeOnListChangedCallback(this); + } + } + + @Override + public void onItemRangeChanged(final ObservableList<T> sender, final int positionStart, + final int itemCount) { + final ItemChangeListener<T> listener = weakListener.get(); + if (listener != null) { + for (int i = positionStart; i < positionStart + itemCount; ++i) { + final View child = listener.container.getChildAt(i); + listener.container.removeViewAt(i); + listener.container.addView(listener.getView(i, child)); + } + } else { + sender.removeOnListChangedCallback(this); + } + } + + @Override + public void onItemRangeInserted(final ObservableList<T> sender, final int positionStart, + final int itemCount) { + final ItemChangeListener<T> listener = weakListener.get(); + if (listener != null) { + for (int i = positionStart; i < positionStart + itemCount; ++i) + listener.container.addView(listener.getView(i, null)); + } else { + sender.removeOnListChangedCallback(this); + } + } + + @Override + public void onItemRangeMoved(final ObservableList<T> sender, final int fromPosition, + final int toPosition, final int itemCount) { + final ItemChangeListener<T> listener = weakListener.get(); + if (listener != null) { + final View[] views = new View[itemCount]; + for (int i = 0; i < itemCount; ++i) + views[i] = listener.container.getChildAt(fromPosition + i); + listener.container.removeViews(fromPosition, itemCount); + for (int i = 0; i < itemCount; ++i) + listener.container.addView(views[i], toPosition + i); + } else { + sender.removeOnListChangedCallback(this); + } + } + + @Override + public void onItemRangeRemoved(final ObservableList<T> sender, final int positionStart, + final int itemCount) { + final ItemChangeListener<T> listener = weakListener.get(); + if (listener != null) { + listener.container.removeViews(positionStart, itemCount); + } else { + sender.removeOnListChangedCallback(this); + } + } + } +} diff --git a/ui/src/main/java/com/wireguard/android/databinding/ObservableKeyedRecyclerViewAdapter.java b/ui/src/main/java/com/wireguard/android/databinding/ObservableKeyedRecyclerViewAdapter.java new file mode 100644 index 00000000..8b40dd91 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/databinding/ObservableKeyedRecyclerViewAdapter.java @@ -0,0 +1,159 @@ +/* + * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.databinding; + +import android.content.Context; +import androidx.databinding.DataBindingUtil; +import androidx.databinding.ObservableList; +import androidx.databinding.ViewDataBinding; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.RecyclerView.Adapter; +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import com.wireguard.android.BR; +import com.wireguard.android.util.ObservableKeyedList; +import com.wireguard.util.Keyed; + +import java.lang.ref.WeakReference; + +/** + * A generic {@code RecyclerView.Adapter} backed by a {@code ObservableKeyedList}. + */ + +public class ObservableKeyedRecyclerViewAdapter<K, E extends Keyed<? extends K>> extends Adapter<ObservableKeyedRecyclerViewAdapter.ViewHolder> { + + private final OnListChangedCallback<E> callback = new OnListChangedCallback<>(this); + private final int layoutId; + private final LayoutInflater layoutInflater; + @Nullable private ObservableKeyedList<K, E> list; + @Nullable private RowConfigurationHandler rowConfigurationHandler; + + ObservableKeyedRecyclerViewAdapter(final Context context, final int layoutId, + final ObservableKeyedList<K, E> list) { + this.layoutId = layoutId; + layoutInflater = LayoutInflater.from(context); + setList(list); + } + + @Nullable + private E getItem(final int position) { + if (list == null || position < 0 || position >= list.size()) + return null; + return list.get(position); + } + + @Override + public int getItemCount() { + return list != null ? list.size() : 0; + } + + @Override + public long getItemId(final int position) { + final K key = getKey(position); + return key != null ? key.hashCode() : -1; + } + + @Nullable + private K getKey(final int position) { + final E item = getItem(position); + return item != null ? item.getKey() : null; + } + + @SuppressWarnings("unchecked") + @Override + public void onBindViewHolder(final ViewHolder holder, final int position) { + holder.binding.setVariable(BR.collection, list); + holder.binding.setVariable(BR.key, getKey(position)); + holder.binding.setVariable(BR.item, getItem(position)); + holder.binding.executePendingBindings(); + + if (rowConfigurationHandler != null) { + final E item = getItem(position); + if (item != null) { + rowConfigurationHandler.onConfigureRow(holder.binding, item, position); + } + } + } + + @Override + public ViewHolder onCreateViewHolder(final ViewGroup parent, final int viewType) { + return new ViewHolder(DataBindingUtil.inflate(layoutInflater, layoutId, parent, false)); + } + + void setList(@Nullable final ObservableKeyedList<K, E> newList) { + if (list != null) + list.removeOnListChangedCallback(callback); + list = newList; + if (list != null) { + list.addOnListChangedCallback(callback); + } + notifyDataSetChanged(); + } + + void setRowConfigurationHandler(final RowConfigurationHandler rowConfigurationHandler) { + this.rowConfigurationHandler = rowConfigurationHandler; + } + + public interface RowConfigurationHandler<B extends ViewDataBinding, T> { + void onConfigureRow(B binding, T item, int position); + } + + private static final class OnListChangedCallback<E extends Keyed<?>> + extends ObservableList.OnListChangedCallback<ObservableList<E>> { + + private final WeakReference<ObservableKeyedRecyclerViewAdapter<?, E>> weakAdapter; + + private OnListChangedCallback(final ObservableKeyedRecyclerViewAdapter<?, E> adapter) { + weakAdapter = new WeakReference<>(adapter); + } + + @Override + public void onChanged(final ObservableList<E> sender) { + final ObservableKeyedRecyclerViewAdapter adapter = weakAdapter.get(); + if (adapter != null) + adapter.notifyDataSetChanged(); + else + sender.removeOnListChangedCallback(this); + } + + @Override + public void onItemRangeChanged(final ObservableList<E> sender, final int positionStart, + final int itemCount) { + onChanged(sender); + } + + @Override + public void onItemRangeInserted(final ObservableList<E> sender, final int positionStart, + final int itemCount) { + onChanged(sender); + } + + @Override + public void onItemRangeMoved(final ObservableList<E> sender, final int fromPosition, + final int toPosition, final int itemCount) { + onChanged(sender); + } + + @Override + public void onItemRangeRemoved(final ObservableList<E> sender, final int positionStart, + final int itemCount) { + onChanged(sender); + } + } + + public static class ViewHolder extends RecyclerView.ViewHolder { + final ViewDataBinding binding; + + public ViewHolder(final ViewDataBinding binding) { + super(binding.getRoot()); + + this.binding = binding; + } + } + +} diff --git a/ui/src/main/java/com/wireguard/android/fragment/AddTunnelsSheet.kt b/ui/src/main/java/com/wireguard/android/fragment/AddTunnelsSheet.kt new file mode 100644 index 00000000..3df141be --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/fragment/AddTunnelsSheet.kt @@ -0,0 +1,106 @@ +/* + * Copyright © 2020 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.fragment + +import android.content.Intent +import android.graphics.drawable.GradientDrawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.ViewTreeObserver +import android.widget.FrameLayout +import androidx.fragment.app.Fragment +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.zxing.integration.android.IntentIntegrator +import com.wireguard.android.R +import com.wireguard.android.activity.TunnelCreatorActivity +import com.wireguard.android.util.resolveAttribute + +class AddTunnelsSheet : BottomSheetDialogFragment() { + + private lateinit var behavior: BottomSheetBehavior<FrameLayout> + private val bottomSheetCallback = object : BottomSheetBehavior.BottomSheetCallback() { + override fun onSlide(bottomSheet: View, slideOffset: Float) { + } + + override fun onStateChanged(bottomSheet: View, newState: Int) { + if (newState == BottomSheetBehavior.STATE_COLLAPSED) { + dismiss() + } + } + } + + override fun getTheme(): Int { + return R.style.BottomSheetDialogTheme + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + if (savedInstanceState != null) dismiss() + return inflater.inflate(R.layout.add_tunnels_bottom_sheet, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + view.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener { + override fun onGlobalLayout() { + view.viewTreeObserver.removeOnGlobalLayoutListener(this) + val dialog = dialog as BottomSheetDialog? ?: return + behavior = dialog.behavior + behavior.state = BottomSheetBehavior.STATE_EXPANDED + behavior.peekHeight = 0 + behavior.addBottomSheetCallback(bottomSheetCallback) + dialog.findViewById<View>(R.id.create_empty)?.setOnClickListener { + dismiss() + onRequestCreateConfig() + } + dialog.findViewById<View>(R.id.create_from_file)?.setOnClickListener { + dismiss() + onRequestImportConfig() + } + dialog.findViewById<View>(R.id.create_from_qrcode)?.setOnClickListener { + dismiss() + onRequestScanQRCode() + } + } + }) + val gradientDrawable = GradientDrawable().apply { + setColor(requireContext().resolveAttribute(R.attr.colorBackground)) + } + view.background = gradientDrawable + } + + override fun dismiss() { + super.dismiss() + behavior.removeBottomSheetCallback(bottomSheetCallback) + } + + private fun requireTargetFragment(): Fragment { + return requireNotNull(targetFragment) { "A target fragment should always be set" } + } + + private fun onRequestCreateConfig() { + startActivity(Intent(activity, TunnelCreatorActivity::class.java)) + } + + private fun onRequestImportConfig() { + val intent = Intent(Intent.ACTION_GET_CONTENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "*/*" + } + requireTargetFragment().startActivityForResult(intent, TunnelListFragment.REQUEST_IMPORT) + } + + private fun onRequestScanQRCode() { + val integrator = IntentIntegrator.forSupportFragment(requireTargetFragment()).apply { + setOrientationLocked(false) + setBeepEnabled(false) + setPrompt(getString(R.string.qr_code_hint)) + } + integrator.initiateScan(listOf(IntentIntegrator.QR_CODE)) + } +} diff --git a/ui/src/main/java/com/wireguard/android/fragment/AppListDialogFragment.java b/ui/src/main/java/com/wireguard/android/fragment/AppListDialogFragment.java new file mode 100644 index 00000000..43178665 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/fragment/AppListDialogFragment.java @@ -0,0 +1,140 @@ +/* + * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.fragment; + +import android.app.Activity; +import android.app.Dialog; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.os.Bundle; +import androidx.annotation.Nullable; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.Fragment; +import androidx.appcompat.app.AlertDialog; +import android.widget.Toast; + +import com.wireguard.android.Application; +import com.wireguard.android.R; +import com.wireguard.android.databinding.AppListDialogFragmentBinding; +import com.wireguard.android.model.ApplicationData; +import com.wireguard.android.util.ErrorMessages; +import com.wireguard.android.util.ObservableKeyedArrayList; +import com.wireguard.android.util.ObservableKeyedList; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import java9.util.Comparators; +import java9.util.stream.Collectors; +import java9.util.stream.StreamSupport; + +public class AppListDialogFragment extends DialogFragment { + + private static final String KEY_EXCLUDED_APPS = "excludedApps"; + private final ObservableKeyedList<String, ApplicationData> appData = new ObservableKeyedArrayList<>(); + private List<String> currentlyExcludedApps = Collections.emptyList(); + + public static <T extends Fragment & AppExclusionListener> + AppListDialogFragment newInstance(final ArrayList<String> excludedApps, final T target) { + final Bundle extras = new Bundle(); + extras.putStringArrayList(KEY_EXCLUDED_APPS, excludedApps); + final AppListDialogFragment fragment = new AppListDialogFragment(); + fragment.setTargetFragment(target, 0); + fragment.setArguments(extras); + return fragment; + } + + private void loadData() { + final Activity activity = getActivity(); + if (activity == null) { + return; + } + + final PackageManager pm = activity.getPackageManager(); + Application.getAsyncWorker().supplyAsync(() -> { + final Intent launcherIntent = new Intent(Intent.ACTION_MAIN, null); + launcherIntent.addCategory(Intent.CATEGORY_LAUNCHER); + final List<ResolveInfo> resolveInfos = pm.queryIntentActivities(launcherIntent, 0); + + final List<ApplicationData> applicationData = new ArrayList<>(); + for (ResolveInfo resolveInfo : resolveInfos) { + String packageName = resolveInfo.activityInfo.packageName; + applicationData.add(new ApplicationData(resolveInfo.loadIcon(pm), resolveInfo.loadLabel(pm).toString(), packageName, currentlyExcludedApps.contains(packageName))); + } + + Collections.sort(applicationData, Comparators.comparing(ApplicationData::getName, String.CASE_INSENSITIVE_ORDER)); + return applicationData; + }).whenComplete(((data, throwable) -> { + if (data != null) { + appData.clear(); + appData.addAll(data); + } else { + final String error = ErrorMessages.get(throwable); + final String message = activity.getString(R.string.error_fetching_apps, error); + Toast.makeText(activity, message, Toast.LENGTH_LONG).show(); + dismissAllowingStateLoss(); + } + })); + } + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + final List<String> excludedApps = requireArguments().getStringArrayList(KEY_EXCLUDED_APPS); + currentlyExcludedApps = (excludedApps != null) ? excludedApps : Collections.emptyList(); + } + + @Override + public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) { + final AlertDialog.Builder alertDialogBuilder = new AlertDialog.Builder(requireActivity()); + alertDialogBuilder.setTitle(R.string.excluded_applications); + + final AppListDialogFragmentBinding binding = AppListDialogFragmentBinding.inflate(requireActivity().getLayoutInflater(), null, false); + binding.executePendingBindings(); + alertDialogBuilder.setView(binding.getRoot()); + + alertDialogBuilder.setPositiveButton(R.string.set_exclusions, (dialog, which) -> setExclusionsAndDismiss()); + alertDialogBuilder.setNegativeButton(R.string.cancel, (dialog, which) -> dialog.dismiss()); + alertDialogBuilder.setNeutralButton(R.string.toggle_all, (dialog, which) -> { + }); + + binding.setFragment(this); + binding.setAppData(appData); + + loadData(); + + final AlertDialog dialog = alertDialogBuilder.create(); + dialog.setOnShowListener(d -> dialog.getButton(DialogInterface.BUTTON_NEUTRAL).setOnClickListener(view -> { + final List<ApplicationData> selectedItems = StreamSupport.stream(appData) + .filter(ApplicationData::isExcludedFromTunnel) + .collect(Collectors.toList()); + final boolean excludeAll = selectedItems.isEmpty(); + for (final ApplicationData app : appData) + app.setExcludedFromTunnel(excludeAll); + })); + return dialog; + } + + private void setExclusionsAndDismiss() { + final List<String> excludedApps = new ArrayList<>(); + for (final ApplicationData data : appData) { + if (data.isExcludedFromTunnel()) { + excludedApps.add(data.getPackageName()); + } + } + + ((AppExclusionListener) getTargetFragment()).onExcludedAppsSelected(excludedApps); + dismiss(); + } + + public interface AppExclusionListener { + void onExcludedAppsSelected(List<String> excludedApps); + } + +} diff --git a/ui/src/main/java/com/wireguard/android/fragment/BaseFragment.java b/ui/src/main/java/com/wireguard/android/fragment/BaseFragment.java new file mode 100644 index 00000000..23bf44e7 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/fragment/BaseFragment.java @@ -0,0 +1,126 @@ +/* + * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.fragment; + +import android.content.Context; +import android.content.Intent; +import androidx.databinding.DataBindingUtil; +import androidx.databinding.ViewDataBinding; +import androidx.annotation.Nullable; +import com.google.android.material.snackbar.Snackbar; +import androidx.fragment.app.Fragment; +import android.util.Log; +import android.view.View; +import android.widget.Toast; + +import com.wireguard.android.Application; +import com.wireguard.android.R; +import com.wireguard.android.activity.BaseActivity; +import com.wireguard.android.activity.BaseActivity.OnSelectedTunnelChangedListener; +import com.wireguard.android.backend.GoBackend; +import com.wireguard.android.databinding.TunnelDetailFragmentBinding; +import com.wireguard.android.databinding.TunnelListItemBinding; +import com.wireguard.android.model.ObservableTunnel; +import com.wireguard.android.backend.Tunnel.State; +import com.wireguard.android.util.ErrorMessages; + +/** + * Base class for fragments that need to know the currently-selected tunnel. Only does anything when + * attached to a {@code BaseActivity}. + */ + +public abstract class BaseFragment extends Fragment implements OnSelectedTunnelChangedListener { + private static final int REQUEST_CODE_VPN_PERMISSION = 23491; + private static final String TAG = "WireGuard/" + BaseFragment.class.getSimpleName(); + @Nullable private BaseActivity activity; + @Nullable private ObservableTunnel pendingTunnel; + @Nullable private Boolean pendingTunnelUp; + + @Nullable + protected ObservableTunnel getSelectedTunnel() { + return activity != null ? activity.getSelectedTunnel() : null; + } + + @Override + public void onActivityResult(final int requestCode, final int resultCode, @Nullable final Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + if (requestCode == REQUEST_CODE_VPN_PERMISSION) { + if (pendingTunnel != null && pendingTunnelUp != null) + setTunnelStateWithPermissionsResult(pendingTunnel, pendingTunnelUp); + pendingTunnel = null; + pendingTunnelUp = null; + } + } + + @Override + public void onAttach(final Context context) { + super.onAttach(context); + if (context instanceof BaseActivity) { + activity = (BaseActivity) context; + activity.addOnSelectedTunnelChangedListener(this); + } else { + activity = null; + } + } + + @Override + public void onDetach() { + if (activity != null) + activity.removeOnSelectedTunnelChangedListener(this); + activity = null; + super.onDetach(); + } + + protected void setSelectedTunnel(@Nullable final ObservableTunnel tunnel) { + if (activity != null) + activity.setSelectedTunnel(tunnel); + } + + public void setTunnelState(final View view, final boolean checked) { + final ViewDataBinding binding = DataBindingUtil.findBinding(view); + final ObservableTunnel tunnel; + if (binding instanceof TunnelDetailFragmentBinding) + tunnel = ((TunnelDetailFragmentBinding) binding).getTunnel(); + else if (binding instanceof TunnelListItemBinding) + tunnel = ((TunnelListItemBinding) binding).getItem(); + else + return; + if (tunnel == null) + return; + + Application.getBackendAsync().thenAccept(backend -> { + if (backend instanceof GoBackend) { + final Intent intent = GoBackend.VpnService.prepare(view.getContext()); + if (intent != null) { + pendingTunnel = tunnel; + pendingTunnelUp = checked; + startActivityForResult(intent, REQUEST_CODE_VPN_PERMISSION); + return; + } + } + + setTunnelStateWithPermissionsResult(tunnel, checked); + }); + } + + private void setTunnelStateWithPermissionsResult(final ObservableTunnel tunnel, final boolean checked) { + tunnel.setState(State.of(checked)).whenComplete((state, throwable) -> { + if (throwable == null) + return; + final String error = ErrorMessages.get(throwable); + final int messageResId = checked ? R.string.error_up : R.string.error_down; + final String message = requireContext().getString(messageResId, error); + final View view = getView(); + if (view != null) + Snackbar.make(view, message, Snackbar.LENGTH_LONG).show(); + else + Toast.makeText(requireContext(), message, Toast.LENGTH_LONG).show(); + Log.e(TAG, message, throwable); + }); + } + +} diff --git a/ui/src/main/java/com/wireguard/android/fragment/ConfigNamingDialogFragment.java b/ui/src/main/java/com/wireguard/android/fragment/ConfigNamingDialogFragment.java new file mode 100644 index 00000000..effa0593 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/fragment/ConfigNamingDialogFragment.java @@ -0,0 +1,118 @@ +/* + * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.fragment; + +import android.app.Activity; +import android.app.Dialog; +import android.content.Context; +import android.content.DialogInterface; +import android.os.Bundle; +import androidx.annotation.Nullable; +import androidx.fragment.app.DialogFragment; +import androidx.appcompat.app.AlertDialog; +import android.view.inputmethod.InputMethodManager; + +import com.wireguard.android.Application; +import com.wireguard.android.R; +import com.wireguard.android.databinding.ConfigNamingDialogFragmentBinding; +import com.wireguard.config.BadConfigException; +import com.wireguard.config.Config; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Objects; + +public class ConfigNamingDialogFragment extends DialogFragment { + private static final String KEY_CONFIG_TEXT = "config_text"; + + @Nullable private ConfigNamingDialogFragmentBinding binding; + @Nullable private Config config; + @Nullable private InputMethodManager imm; + + public static ConfigNamingDialogFragment newInstance(final String configText) { + final Bundle extras = new Bundle(); + extras.putString(KEY_CONFIG_TEXT, configText); + final ConfigNamingDialogFragment fragment = new ConfigNamingDialogFragment(); + fragment.setArguments(extras); + return fragment; + } + + private void createTunnelAndDismiss() { + if (binding != null) { + final String name = binding.tunnelNameText.getText().toString(); + + Application.getTunnelManager().create(name, config).whenComplete((tunnel, throwable) -> { + if (tunnel != null) { + dismiss(); + } else { + binding.tunnelNameTextLayout.setError(throwable.getMessage()); + } + }); + } + } + + @Override + public void dismiss() { + setKeyboardVisible(false); + super.dismiss(); + } + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + final Bundle arguments = getArguments(); + final String configText = arguments.getString(KEY_CONFIG_TEXT); + final byte[] configBytes = configText.getBytes(StandardCharsets.UTF_8); + try { + config = Config.parse(new ByteArrayInputStream(configBytes)); + } catch (final BadConfigException | IOException e) { + throw new IllegalArgumentException("Invalid config passed to " + getClass().getSimpleName(), e); + } + } + + @Override + public Dialog onCreateDialog(final Bundle savedInstanceState) { + final Activity activity = requireActivity(); + + imm = (InputMethodManager) activity.getSystemService(Context.INPUT_METHOD_SERVICE); + + final AlertDialog.Builder alertDialogBuilder = new AlertDialog.Builder(activity); + alertDialogBuilder.setTitle(R.string.import_from_qr_code); + + binding = ConfigNamingDialogFragmentBinding.inflate(activity.getLayoutInflater(), null, false); + binding.executePendingBindings(); + alertDialogBuilder.setView(binding.getRoot()); + + alertDialogBuilder.setPositiveButton(R.string.create_tunnel, null); + alertDialogBuilder.setNegativeButton(R.string.cancel, (dialog, which) -> dismiss()); + + return alertDialogBuilder.create(); + } + + @Override public void onResume() { + super.onResume(); + + final AlertDialog dialog = (AlertDialog) getDialog(); + if (dialog != null) { + dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener(v -> createTunnelAndDismiss()); + + setKeyboardVisible(true); + } + } + + private void setKeyboardVisible(final boolean visible) { + Objects.requireNonNull(imm); + + if (visible) { + imm.toggleSoftInput(InputMethodManager.SHOW_FORCED, 0); + } else if (binding != null) { + imm.hideSoftInputFromWindow(binding.tunnelNameText.getWindowToken(), 0); + } + } + +} diff --git a/ui/src/main/java/com/wireguard/android/fragment/TunnelDetailFragment.java b/ui/src/main/java/com/wireguard/android/fragment/TunnelDetailFragment.java new file mode 100644 index 00000000..8d90fa7e --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/fragment/TunnelDetailFragment.java @@ -0,0 +1,162 @@ +/* + * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.fragment; + +import android.os.Bundle; +import androidx.annotation.Nullable; +import androidx.databinding.DataBindingUtil; + +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.View; +import android.view.ViewGroup; + +import com.wireguard.android.R; +import com.wireguard.android.databinding.TunnelDetailFragmentBinding; +import com.wireguard.android.databinding.TunnelDetailPeerBinding; +import com.wireguard.android.model.ObservableTunnel; +import com.wireguard.android.backend.Tunnel.State; +import com.wireguard.android.ui.EdgeToEdge; +import com.wireguard.crypto.Key; + +import java.util.Timer; +import java.util.TimerTask; + +/** + * Fragment that shows details about a specific tunnel. + */ + +public class TunnelDetailFragment extends BaseFragment { + @Nullable private TunnelDetailFragmentBinding binding; + @Nullable private Timer timer; + @Nullable private State lastState = State.TOGGLE; + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setHasOptionsMenu(true); + } + + @Override + public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { + inflater.inflate(R.menu.tunnel_detail, menu); + } + + @Override + public void onStop() { + super.onStop(); + if (timer != null) { + timer.cancel(); + timer = null; + } + } + + @Override + public void onResume() { + super.onResume(); + timer = new Timer(); + timer.scheduleAtFixedRate(new TimerTask() { + @Override + public void run() { + updateStats(); + } + }, 0, 1000); + } + + @Override + public View onCreateView(final LayoutInflater inflater, @Nullable final ViewGroup container, + @Nullable final Bundle savedInstanceState) { + super.onCreateView(inflater, container, savedInstanceState); + binding = TunnelDetailFragmentBinding.inflate(inflater, container, false); + binding.executePendingBindings(); + EdgeToEdge.setUpRoot((ViewGroup) binding.getRoot()); + EdgeToEdge.setUpScrollingContent((ViewGroup) binding.getRoot(), null); + return binding.getRoot(); + } + + @Override + public void onDestroyView() { + binding = null; + super.onDestroyView(); + } + + @Override + public void onSelectedTunnelChanged(@Nullable final ObservableTunnel oldTunnel, @Nullable final ObservableTunnel newTunnel) { + if (binding == null) + return; + binding.setTunnel(newTunnel); + if (newTunnel == null) + binding.setConfig(null); + else + newTunnel.getConfigAsync().thenAccept(binding::setConfig); + lastState = State.TOGGLE; + updateStats(); + } + + @Override + public void onViewStateRestored(@Nullable final Bundle savedInstanceState) { + if (binding == null) { + return; + } + + binding.setFragment(this); + onSelectedTunnelChanged(null, getSelectedTunnel()); + super.onViewStateRestored(savedInstanceState); + } + + private String formatBytes(final long bytes) { + if (bytes < 1024) + return requireContext().getString(R.string.transfer_bytes, bytes); + else if (bytes < 1024*1024) + return requireContext().getString(R.string.transfer_kibibytes, bytes/1024.0); + else if (bytes < 1024*1024*1024) + return requireContext().getString(R.string.transfer_mibibytes, bytes/(1024.0*1024.0)); + else if (bytes < 1024*1024*1024*1024) + return requireContext().getString(R.string.transfer_gibibytes, bytes/(1024.0*1024.0*1024.0)); + return requireContext().getString(R.string.transfer_tibibytes, bytes/(1024.0*1024.0*1024.0)/1024.0); + } + + private void updateStats() { + if (binding == null || !isResumed()) + return; + final ObservableTunnel tunnel = binding.getTunnel(); + if (tunnel == null) + return; + final State state = tunnel.getState(); + if (state != State.UP && lastState == state) + return; + lastState = state; + tunnel.getStatisticsAsync().whenComplete((statistics, throwable) -> { + if (throwable != null) { + for (int i = 0; i < binding.peersLayout.getChildCount(); ++i) { + final TunnelDetailPeerBinding peer = DataBindingUtil.getBinding(binding.peersLayout.getChildAt(i)); + if (peer == null) + continue; + peer.transferLabel.setVisibility(View.GONE); + peer.transferText.setVisibility(View.GONE); + } + return; + } + for (int i = 0; i < binding.peersLayout.getChildCount(); ++i) { + final TunnelDetailPeerBinding peer = DataBindingUtil.getBinding(binding.peersLayout.getChildAt(i)); + if (peer == null) + continue; + final Key publicKey = peer.getItem().getPublicKey(); + final long rx = statistics.peerRx(publicKey); + final long tx = statistics.peerTx(publicKey); + if (rx == 0 && tx == 0) { + peer.transferLabel.setVisibility(View.GONE); + peer.transferText.setVisibility(View.GONE); + continue; + } + peer.transferText.setText(requireContext().getString(R.string.transfer_rx_tx, formatBytes(rx), formatBytes(tx))); + peer.transferLabel.setVisibility(View.VISIBLE); + peer.transferText.setVisibility(View.VISIBLE); + } + }); + } +} diff --git a/ui/src/main/java/com/wireguard/android/fragment/TunnelEditorFragment.java b/ui/src/main/java/com/wireguard/android/fragment/TunnelEditorFragment.java new file mode 100644 index 00000000..92aeb52a --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/fragment/TunnelEditorFragment.java @@ -0,0 +1,264 @@ +/* + * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.fragment; + +import android.app.Activity; +import android.content.Context; +import androidx.databinding.ObservableList; +import android.os.Bundle; +import androidx.annotation.Nullable; +import com.google.android.material.snackbar.Snackbar; + +import android.util.Log; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.view.inputmethod.InputMethodManager; +import android.widget.Toast; + +import com.wireguard.android.Application; +import com.wireguard.android.R; +import com.wireguard.android.databinding.TunnelEditorFragmentBinding; +import com.wireguard.android.fragment.AppListDialogFragment.AppExclusionListener; +import com.wireguard.android.model.ObservableTunnel; +import com.wireguard.android.model.TunnelManager; +import com.wireguard.android.ui.EdgeToEdge; +import com.wireguard.android.util.ErrorMessages; +import com.wireguard.android.viewmodel.ConfigProxy; +import com.wireguard.config.Config; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Fragment for editing a WireGuard configuration. + */ + +public class TunnelEditorFragment extends BaseFragment implements AppExclusionListener { + private static final String KEY_LOCAL_CONFIG = "local_config"; + private static final String KEY_ORIGINAL_NAME = "original_name"; + private static final String TAG = "WireGuard/" + TunnelEditorFragment.class.getSimpleName(); + + @Nullable private TunnelEditorFragmentBinding binding; + @Nullable private ObservableTunnel tunnel; + + private void onConfigLoaded(final Config config) { + if (binding != null) { + binding.setConfig(new ConfigProxy(config)); + } + } + + private void onConfigSaved(final ObservableTunnel savedTunnel, + @Nullable final Throwable throwable) { + final String message; + if (throwable == null) { + message = getString(R.string.config_save_success, savedTunnel.getName()); + Log.d(TAG, message); + Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show(); + onFinished(); + } else { + final String error = ErrorMessages.get(throwable); + message = getString(R.string.config_save_error, savedTunnel.getName(), error); + Log.e(TAG, message, throwable); + if (binding != null) { + Snackbar.make(binding.mainContainer, message, Snackbar.LENGTH_LONG).show(); + } + } + } + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setHasOptionsMenu(true); + } + + @Override + public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { + inflater.inflate(R.menu.config_editor, menu); + } + + @Override + public View onCreateView(final LayoutInflater inflater, @Nullable final ViewGroup container, + @Nullable final Bundle savedInstanceState) { + super.onCreateView(inflater, container, savedInstanceState); + binding = TunnelEditorFragmentBinding.inflate(inflater, container, false); + binding.executePendingBindings(); + EdgeToEdge.setUpRoot((ViewGroup) binding.getRoot()); + EdgeToEdge.setUpScrollingContent(binding.mainContainer, null); + return binding.getRoot(); + } + + @Override + public void onDestroyView() { + binding = null; + super.onDestroyView(); + } + + @Override + public void onExcludedAppsSelected(final List<String> excludedApps) { + Objects.requireNonNull(binding, "Tried to set excluded apps while no view was loaded"); + final ObservableList<String> excludedApplications = + binding.getConfig().getInterface().getExcludedApplications(); + excludedApplications.clear(); + excludedApplications.addAll(excludedApps); + } + + private void onFinished() { + // Hide the keyboard; it rarely goes away on its own. + final Activity activity = getActivity(); + if (activity == null) return; + final View focusedView = activity.getCurrentFocus(); + if (focusedView != null) { + final Object service = activity.getSystemService(Context.INPUT_METHOD_SERVICE); + final InputMethodManager inputManager = (InputMethodManager) service; + if (inputManager != null) + inputManager.hideSoftInputFromWindow(focusedView.getWindowToken(), + InputMethodManager.HIDE_NOT_ALWAYS); + } + // Tell the activity to finish itself or go back to the detail view. + getActivity().runOnUiThread(() -> { + // TODO(smaeul): Remove this hack when fixing the Config ViewModel + // The selected tunnel has to actually change, but we have to remember this one. + final ObservableTunnel savedTunnel = tunnel; + if (savedTunnel == getSelectedTunnel()) + setSelectedTunnel(null); + setSelectedTunnel(savedTunnel); + }); + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + switch (item.getItemId()) { + case R.id.menu_action_save: + if (binding == null) + return false; + final Config newConfig; + try { + newConfig = binding.getConfig().resolve(); + } catch (final Exception e) { + final String error = ErrorMessages.get(e); + final String tunnelName = tunnel == null ? binding.getName() : tunnel.getName(); + final String message = getString(R.string.config_save_error, tunnelName, error); + Log.e(TAG, message, e); + Snackbar.make(binding.mainContainer, error, Snackbar.LENGTH_LONG).show(); + return false; + } + if (tunnel == null) { + Log.d(TAG, "Attempting to create new tunnel " + binding.getName()); + final TunnelManager manager = Application.getTunnelManager(); + manager.create(binding.getName(), newConfig) + .whenComplete(this::onTunnelCreated); + } else if (!tunnel.getName().equals(binding.getName())) { + Log.d(TAG, "Attempting to rename tunnel to " + binding.getName()); + tunnel.setName(binding.getName()) + .whenComplete((a, b) -> onTunnelRenamed(tunnel, newConfig, b)); + } else { + Log.d(TAG, "Attempting to save config of " + tunnel.getName()); + tunnel.setConfig(newConfig) + .whenComplete((a, b) -> onConfigSaved(tunnel, b)); + } + return true; + default: + return super.onOptionsItemSelected(item); + } + } + + public void onRequestSetExcludedApplications(@SuppressWarnings("unused") final View view) { + if (binding != null) { + final ArrayList<String> excludedApps = new ArrayList<>(binding.getConfig().getInterface().getExcludedApplications()); + final AppListDialogFragment fragment = AppListDialogFragment.newInstance(excludedApps, this); + fragment.show(getParentFragmentManager(), null); + } + } + + @Override + public void onSaveInstanceState(final Bundle outState) { + if (binding != null) + outState.putParcelable(KEY_LOCAL_CONFIG, binding.getConfig()); + outState.putString(KEY_ORIGINAL_NAME, tunnel == null ? null : tunnel.getName()); + super.onSaveInstanceState(outState); + } + + @Override + public void onSelectedTunnelChanged(@Nullable final ObservableTunnel oldTunnel, + @Nullable final ObservableTunnel newTunnel) { + tunnel = newTunnel; + if (binding == null) + return; + binding.setConfig(new ConfigProxy()); + if (tunnel != null) { + binding.setName(tunnel.getName()); + tunnel.getConfigAsync().thenAccept(this::onConfigLoaded); + } else { + binding.setName(""); + } + } + + private void onTunnelCreated(final ObservableTunnel newTunnel, @Nullable final Throwable throwable) { + final String message; + if (throwable == null) { + tunnel = newTunnel; + message = getString(R.string.tunnel_create_success, tunnel.getName()); + Log.d(TAG, message); + Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show(); + onFinished(); + } else { + final String error = ErrorMessages.get(throwable); + message = getString(R.string.tunnel_create_error, error); + Log.e(TAG, message, throwable); + if (binding != null) { + Snackbar.make(binding.mainContainer, message, Snackbar.LENGTH_LONG).show(); + } + } + } + + private void onTunnelRenamed(final ObservableTunnel renamedTunnel, final Config newConfig, + @Nullable final Throwable throwable) { + final String message; + if (throwable == null) { + message = getString(R.string.tunnel_rename_success, renamedTunnel.getName()); + Log.d(TAG, message); + // Now save the rest of configuration changes. + Log.d(TAG, "Attempting to save config of renamed tunnel " + tunnel.getName()); + renamedTunnel.setConfig(newConfig).whenComplete((a, b) -> onConfigSaved(renamedTunnel, b)); + } else { + final String error = ErrorMessages.get(throwable); + message = getString(R.string.tunnel_rename_error, error); + Log.e(TAG, message, throwable); + if (binding != null) { + Snackbar.make(binding.mainContainer, message, Snackbar.LENGTH_LONG).show(); + } + } + } + + @Override + public void onViewStateRestored(@Nullable final Bundle savedInstanceState) { + if (binding == null) { + return; + } + + binding.setFragment(this); + + if (savedInstanceState == null) { + onSelectedTunnelChanged(null, getSelectedTunnel()); + } else { + tunnel = getSelectedTunnel(); + final ConfigProxy config = savedInstanceState.getParcelable(KEY_LOCAL_CONFIG); + final String originalName = savedInstanceState.getString(KEY_ORIGINAL_NAME); + if (tunnel != null && !tunnel.getName().equals(originalName)) + onSelectedTunnelChanged(null, tunnel); + else + binding.setConfig(config); + } + + super.onViewStateRestored(savedInstanceState); + } + +} diff --git a/ui/src/main/java/com/wireguard/android/fragment/TunnelListFragment.java b/ui/src/main/java/com/wireguard/android/fragment/TunnelListFragment.java new file mode 100644 index 00000000..21618e60 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/fragment/TunnelListFragment.java @@ -0,0 +1,449 @@ +/* + * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.fragment; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.ContentResolver; +import android.content.Intent; +import android.content.res.Resources; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import android.provider.OpenableColumns; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.google.android.material.snackbar.Snackbar; + +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.view.ActionMode; +import androidx.recyclerview.widget.RecyclerView; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; + +import com.google.zxing.integration.android.IntentIntegrator; +import com.google.zxing.integration.android.IntentResult; +import com.wireguard.android.Application; +import com.wireguard.android.R; +import com.wireguard.android.activity.TunnelCreatorActivity; +import com.wireguard.android.databinding.ObservableKeyedRecyclerViewAdapter; +import com.wireguard.android.databinding.TunnelListFragmentBinding; +import com.wireguard.android.databinding.TunnelListItemBinding; +import com.wireguard.android.model.ObservableTunnel; +import com.wireguard.android.ui.EdgeToEdge; +import com.wireguard.android.util.ErrorMessages; +import com.wireguard.android.widget.MultiselectableRelativeLayout; +import com.wireguard.config.BadConfigException; +import com.wireguard.config.Config; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import java9.util.concurrent.CompletableFuture; +import java9.util.stream.StreamSupport; + +/** + * Fragment containing a list of known WireGuard tunnels. It allows creating and deleting tunnels. + */ + +public class TunnelListFragment extends BaseFragment { + public static final int REQUEST_IMPORT = 1; + private static final int REQUEST_TARGET_FRAGMENT = 2; + private static final String TAG = "WireGuard/" + TunnelListFragment.class.getSimpleName(); + + private final ActionModeListener actionModeListener = new ActionModeListener(); + @Nullable private ActionMode actionMode; + @Nullable private TunnelListFragmentBinding binding; + + private void importTunnel(@NonNull final String configText) { + try { + // Ensure the config text is parseable before proceeding… + Config.parse(new ByteArrayInputStream(configText.getBytes(StandardCharsets.UTF_8))); + + // Config text is valid, now create the tunnel… + ConfigNamingDialogFragment.newInstance(configText).show(getParentFragmentManager(), null); + } catch (final BadConfigException | IOException e) { + onTunnelImportFinished(Collections.emptyList(), Collections.singletonList(e)); + } + } + + private void importTunnel(@Nullable final Uri uri) { + final Activity activity = getActivity(); + if (activity == null || uri == null) + return; + final ContentResolver contentResolver = activity.getContentResolver(); + + final Collection<CompletableFuture<ObservableTunnel>> futureTunnels = new ArrayList<>(); + final List<Throwable> throwables = new ArrayList<>(); + Application.getAsyncWorker().supplyAsync(() -> { + final String[] columns = {OpenableColumns.DISPLAY_NAME}; + String name = null; + try (Cursor cursor = contentResolver.query(uri, columns, + null, null, null)) { + if (cursor != null && cursor.moveToFirst() && !cursor.isNull(0)) + name = cursor.getString(0); + } + if (name == null) + name = Uri.decode(uri.getLastPathSegment()); + int idx = name.lastIndexOf('/'); + if (idx >= 0) { + if (idx >= name.length() - 1) + throw new IllegalArgumentException(getResources().getString(R.string.illegal_filename_error, name)); + name = name.substring(idx + 1); + } + boolean isZip = name.toLowerCase(Locale.ENGLISH).endsWith(".zip"); + if (name.toLowerCase(Locale.ENGLISH).endsWith(".conf")) + name = name.substring(0, name.length() - ".conf".length()); + else if (!isZip) + throw new IllegalArgumentException(getResources().getString(R.string.bad_extension_error)); + + if (isZip) { + try (ZipInputStream zip = new ZipInputStream(contentResolver.openInputStream(uri)); + BufferedReader reader = new BufferedReader(new InputStreamReader(zip))) { + ZipEntry entry; + while ((entry = zip.getNextEntry()) != null) { + if (entry.isDirectory()) + continue; + name = entry.getName(); + idx = name.lastIndexOf('/'); + if (idx >= 0) { + if (idx >= name.length() - 1) + continue; + name = name.substring(name.lastIndexOf('/') + 1); + } + if (name.toLowerCase(Locale.ENGLISH).endsWith(".conf")) + name = name.substring(0, name.length() - ".conf".length()); + else + continue; + Config config = null; + try { + config = Config.parse(reader); + } catch (Exception e) { + throwables.add(e); + } + if (config != null) + futureTunnels.add(Application.getTunnelManager().create(name, config).toCompletableFuture()); + } + } + } else { + futureTunnels.add(Application.getTunnelManager().create(name, + Config.parse(contentResolver.openInputStream(uri))).toCompletableFuture()); + } + + if (futureTunnels.isEmpty()) { + if (throwables.size() == 1) + throw throwables.get(0); + else if (throwables.isEmpty()) + throw new IllegalArgumentException(getResources().getString(R.string.no_configs_error)); + } + + return CompletableFuture.allOf(futureTunnels.toArray(new CompletableFuture[futureTunnels.size()])); + }).whenComplete((future, exception) -> { + if (exception != null) { + onTunnelImportFinished(Collections.emptyList(), Collections.singletonList(exception)); + } else { + future.whenComplete((ignored1, ignored2) -> { + final List<ObservableTunnel> tunnels = new ArrayList<>(futureTunnels.size()); + for (final CompletableFuture<ObservableTunnel> futureTunnel : futureTunnels) { + ObservableTunnel tunnel = null; + try { + tunnel = futureTunnel.getNow(null); + } catch (final Exception e) { + throwables.add(e); + } + if (tunnel != null) + tunnels.add(tunnel); + } + onTunnelImportFinished(tunnels, throwables); + }); + } + }); + } + + @Override + public void onActivityCreated(@Nullable final Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + if (savedInstanceState != null) { + final Collection<Integer> checkedItems = savedInstanceState.getIntegerArrayList("CHECKED_ITEMS"); + if (checkedItems != null) { + for (final Integer i : checkedItems) + actionModeListener.setItemChecked(i, true); + } + } + } + + @Override + public void onActivityResult(final int requestCode, final int resultCode, @Nullable final Intent data) { + switch (requestCode) { + case REQUEST_IMPORT: + if (resultCode == Activity.RESULT_OK && data != null) + importTunnel(data.getData()); + return; + case IntentIntegrator.REQUEST_CODE: + final IntentResult result = IntentIntegrator.parseActivityResult(requestCode, resultCode, data); + if (result != null && result.getContents() != null) { + importTunnel(result.getContents()); + } + return; + default: + super.onActivityResult(requestCode, resultCode, data); + } + } + + @SuppressLint("ClickableViewAccessibility") + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, + @Nullable final Bundle savedInstanceState) { + super.onCreateView(inflater, container, savedInstanceState); + binding = TunnelListFragmentBinding.inflate(inflater, container, false); + binding.createFab.setOnClickListener(v -> { + final AddTunnelsSheet bottomSheet = new AddTunnelsSheet(); + bottomSheet.setTargetFragment(this, REQUEST_TARGET_FRAGMENT); + bottomSheet.show(getParentFragmentManager(), "BOTTOM_SHEET"); + }); + binding.executePendingBindings(); + EdgeToEdge.setUpRoot((ViewGroup) binding.getRoot()); + EdgeToEdge.setUpFAB(binding.createFab); + EdgeToEdge.setUpScrollingContent(binding.tunnelList, binding.createFab); + return binding.getRoot(); + } + + @Override + public void onDestroyView() { + binding = null; + super.onDestroyView(); + } + + @Override + public void onPause() { + super.onPause(); + } + + public void onRequestCreateConfig(@SuppressWarnings("unused") final View view) { + startActivity(new Intent(getActivity(), TunnelCreatorActivity.class)); + } + + @Override + public void onSaveInstanceState(final Bundle outState) { + super.onSaveInstanceState(outState); + + outState.putIntegerArrayList("CHECKED_ITEMS", actionModeListener.getCheckedItems()); + } + + @Override + public void onSelectedTunnelChanged(@Nullable final ObservableTunnel oldTunnel, @Nullable final ObservableTunnel newTunnel) { + if (binding == null) + return; + Application.getTunnelManager().getTunnels().thenAccept(tunnels -> { + if (newTunnel != null) + viewForTunnel(newTunnel, tunnels).setSingleSelected(true); + if (oldTunnel != null) + viewForTunnel(oldTunnel, tunnels).setSingleSelected(false); + }); + } + + private void showSnackbar(final CharSequence message) { + if (binding != null) { + final Snackbar snackbar = Snackbar.make(binding.mainContainer, message, Snackbar.LENGTH_LONG); + snackbar.setAnchorView(binding.createFab); + snackbar.show(); + } + } + + private void onTunnelDeletionFinished(final Integer count, @Nullable final Throwable throwable) { + final String message; + if (throwable == null) { + message = getResources().getQuantityString(R.plurals.delete_success, count, count); + } else { + final String error = ErrorMessages.get(throwable); + message = getResources().getQuantityString(R.plurals.delete_error, count, count, error); + Log.e(TAG, message, throwable); + } + showSnackbar(message); + } + + private void onTunnelImportFinished(final List<ObservableTunnel> tunnels, final Collection<Throwable> throwables) { + String message = null; + + for (final Throwable throwable : throwables) { + final String error = ErrorMessages.get(throwable); + message = getString(R.string.import_error, error); + Log.e(TAG, message, throwable); + } + + if (tunnels.size() == 1 && throwables.isEmpty()) + message = getString(R.string.import_success, tunnels.get(0).getName()); + else if (tunnels.isEmpty() && throwables.size() == 1) + /* Use the exception message from above. */ ; + else if (throwables.isEmpty()) + message = getResources().getQuantityString(R.plurals.import_total_success, + tunnels.size(), tunnels.size()); + else if (!throwables.isEmpty()) + message = getResources().getQuantityString(R.plurals.import_partial_success, + tunnels.size() + throwables.size(), + tunnels.size(), tunnels.size() + throwables.size()); + + showSnackbar(message); + } + + @Override + public void onViewStateRestored(@Nullable final Bundle savedInstanceState) { + super.onViewStateRestored(savedInstanceState); + + if (binding == null) { + return; + } + + binding.setFragment(this); + Application.getTunnelManager().getTunnels().thenAccept(binding::setTunnels); + binding.setRowConfigurationHandler((ObservableKeyedRecyclerViewAdapter.RowConfigurationHandler<TunnelListItemBinding, ObservableTunnel>) (binding, tunnel, position) -> { + binding.setFragment(this); + binding.getRoot().setOnClickListener(clicked -> { + if (actionMode == null) { + setSelectedTunnel(tunnel); + } else { + actionModeListener.toggleItemChecked(position); + } + }); + binding.getRoot().setOnLongClickListener(clicked -> { + actionModeListener.toggleItemChecked(position); + return true; + }); + + if (actionMode != null) + ((MultiselectableRelativeLayout) binding.getRoot()).setMultiSelected(actionModeListener.checkedItems.contains(position)); + else + ((MultiselectableRelativeLayout) binding.getRoot()).setSingleSelected(getSelectedTunnel() == tunnel); + }); + } + + private MultiselectableRelativeLayout viewForTunnel(final ObservableTunnel tunnel, final List tunnels) { + return (MultiselectableRelativeLayout) binding.tunnelList.findViewHolderForAdapterPosition(tunnels.indexOf(tunnel)).itemView; + } + + private final class ActionModeListener implements ActionMode.Callback { + private final Collection<Integer> checkedItems = new HashSet<>(); + + @Nullable private Resources resources; + + public ArrayList<Integer> getCheckedItems() { + return new ArrayList<>(checkedItems); + } + + @Override + public boolean onActionItemClicked(final ActionMode mode, final MenuItem item) { + switch (item.getItemId()) { + case R.id.menu_action_delete: + final Iterable<Integer> copyCheckedItems = new HashSet<>(checkedItems); + Application.getTunnelManager().getTunnels().thenAccept(tunnels -> { + final Collection<ObservableTunnel> tunnelsToDelete = new ArrayList<>(); + for (final Integer position : copyCheckedItems) + tunnelsToDelete.add(tunnels.get(position)); + + final CompletableFuture[] futures = StreamSupport.stream(tunnelsToDelete) + .map(ObservableTunnel::delete) + .toArray(CompletableFuture[]::new); + CompletableFuture.allOf(futures) + .thenApply(x -> futures.length) + .whenComplete(TunnelListFragment.this::onTunnelDeletionFinished); + + }); + checkedItems.clear(); + mode.finish(); + return true; + case R.id.menu_action_select_all: + Application.getTunnelManager().getTunnels().thenAccept(tunnels -> { + for (int i = 0; i < tunnels.size(); ++i) { + setItemChecked(i, true); + } + }); + return true; + default: + return false; + } + } + + @Override + public boolean onCreateActionMode(final ActionMode mode, final Menu menu) { + actionMode = mode; + if (getActivity() != null) { + resources = getActivity().getResources(); + } + mode.getMenuInflater().inflate(R.menu.tunnel_list_action_mode, menu); + binding.tunnelList.getAdapter().notifyDataSetChanged(); + return true; + } + + @Override + public void onDestroyActionMode(final ActionMode mode) { + actionMode = null; + resources = null; + checkedItems.clear(); + binding.tunnelList.getAdapter().notifyDataSetChanged(); + } + + @Override + public boolean onPrepareActionMode(final ActionMode mode, final Menu menu) { + updateTitle(mode); + return false; + } + + void setItemChecked(final int position, final boolean checked) { + if (checked) { + checkedItems.add(position); + } else { + checkedItems.remove(position); + } + + final RecyclerView.Adapter adapter = binding == null ? null : binding.tunnelList.getAdapter(); + + if (actionMode == null && !checkedItems.isEmpty() && getActivity() != null) { + ((AppCompatActivity) getActivity()).startSupportActionMode(this); + } else if (actionMode != null && checkedItems.isEmpty()) { + actionMode.finish(); + } + + if (adapter != null) + adapter.notifyItemChanged(position); + + updateTitle(actionMode); + } + + void toggleItemChecked(final int position) { + setItemChecked(position, !checkedItems.contains(position)); + } + + private void updateTitle(@Nullable final ActionMode mode) { + if (mode == null) { + return; + } + + final int count = checkedItems.size(); + if (count == 0) { + mode.setTitle(""); + } else { + mode.setTitle(resources.getQuantityString(R.plurals.delete_title, count, count)); + } + } + } + +} diff --git a/ui/src/main/java/com/wireguard/android/model/ApplicationData.java b/ui/src/main/java/com/wireguard/android/model/ApplicationData.java new file mode 100644 index 00000000..65edff90 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/model/ApplicationData.java @@ -0,0 +1,54 @@ +/* + * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.model; + +import androidx.databinding.BaseObservable; +import androidx.databinding.Bindable; +import android.graphics.drawable.Drawable; + +import com.wireguard.android.BR; +import com.wireguard.util.Keyed; + +public class ApplicationData extends BaseObservable implements Keyed<String> { + private final Drawable icon; + private final String name; + private final String packageName; + private boolean excludedFromTunnel; + + public ApplicationData(final Drawable icon, final String name, final String packageName, final boolean excludedFromTunnel) { + this.icon = icon; + this.name = name; + this.packageName = packageName; + this.excludedFromTunnel = excludedFromTunnel; + } + + public Drawable getIcon() { + return icon; + } + + @Override + public String getKey() { + return name; + } + + public String getName() { + return name; + } + + public String getPackageName() { + return packageName; + } + + @Bindable + public boolean isExcludedFromTunnel() { + return excludedFromTunnel; + } + + public void setExcludedFromTunnel(final boolean excludedFromTunnel) { + this.excludedFromTunnel = excludedFromTunnel; + notifyPropertyChanged(BR.excludedFromTunnel); + } +} diff --git a/ui/src/main/java/com/wireguard/android/model/ObservableTunnel.java b/ui/src/main/java/com/wireguard/android/model/ObservableTunnel.java new file mode 100644 index 00000000..ce3197f2 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/model/ObservableTunnel.java @@ -0,0 +1,142 @@ +/* + * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.model; + +import androidx.databinding.BaseObservable; +import androidx.databinding.Bindable; +import androidx.annotation.Nullable; + +import com.wireguard.android.BR; +import com.wireguard.android.backend.Statistics; +import com.wireguard.android.backend.Tunnel; +import com.wireguard.android.util.ExceptionLoggers; +import com.wireguard.config.Config; +import com.wireguard.util.Keyed; + +import java9.util.concurrent.CompletableFuture; +import java9.util.concurrent.CompletionStage; + +/** + * Encapsulates the volatile and nonvolatile state of a WireGuard tunnel. + */ + +public class ObservableTunnel extends BaseObservable implements Keyed<String>, Tunnel { + private final TunnelManager manager; + @Nullable private Config config; + private State state; + private String name; + @Nullable private Statistics statistics; + + ObservableTunnel(final TunnelManager manager, final String name, + @Nullable final Config config, final State state) { + this.name = name; + this.manager = manager; + this.config = config; + this.state = state; + } + + public CompletionStage<Void> delete() { + return manager.delete(this); + } + + @Bindable + @Nullable + public Config getConfig() { + if (config == null) + manager.getTunnelConfig(this).whenComplete(ExceptionLoggers.E); + return config; + } + + public CompletionStage<Config> getConfigAsync() { + if (config == null) + return manager.getTunnelConfig(this); + return CompletableFuture.completedFuture(config); + } + + @Override + public String getKey() { + return name; + } + + @Override + @Bindable + public String getName() { + return name; + } + + @Bindable + public State getState() { + return state; + } + + public CompletionStage<State> getStateAsync() { + return TunnelManager.getTunnelState(this); + } + + @Bindable + @Nullable + public Statistics getStatistics() { + if (statistics == null || statistics.isStale()) + TunnelManager.getTunnelStatistics(this).whenComplete(ExceptionLoggers.E); + return statistics; + } + + public CompletionStage<Statistics> getStatisticsAsync() { + if (statistics == null || statistics.isStale()) + return TunnelManager.getTunnelStatistics(this); + return CompletableFuture.completedFuture(statistics); + } + + Config onConfigChanged(final Config config) { + this.config = config; + notifyPropertyChanged(BR.config); + return config; + } + + String onNameChanged(final String name) { + this.name = name; + notifyPropertyChanged(BR.name); + return name; + } + + State onStateChanged(final State state) { + if (state != State.UP) + onStatisticsChanged(null); + this.state = state; + notifyPropertyChanged(BR.state); + return state; + } + + @Override + public void onStateChange(final State newState) { + onStateChanged(state); + } + + @Nullable + Statistics onStatisticsChanged(@Nullable final Statistics statistics) { + this.statistics = statistics; + notifyPropertyChanged(BR.statistics); + return statistics; + } + + public CompletionStage<Config> setConfig(final Config config) { + if (!config.equals(this.config)) + return manager.setTunnelConfig(this, config); + return CompletableFuture.completedFuture(this.config); + } + + public CompletionStage<String> setName(final String name) { + if (!name.equals(this.name)) + return manager.setTunnelName(this, name); + return CompletableFuture.completedFuture(this.name); + } + + public CompletionStage<State> setState(final State state) { + if (state != this.state) + return manager.setTunnelState(this, state); + return CompletableFuture.completedFuture(this.state); + } +} diff --git a/ui/src/main/java/com/wireguard/android/model/TunnelManager.java b/ui/src/main/java/com/wireguard/android/model/TunnelManager.java new file mode 100644 index 00000000..35d56c81 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/model/TunnelManager.java @@ -0,0 +1,301 @@ +/* + * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.model; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import androidx.databinding.BaseObservable; +import androidx.databinding.Bindable; +import androidx.annotation.Nullable; + +import com.wireguard.android.Application; +import com.wireguard.android.BR; +import com.wireguard.android.R; +import com.wireguard.android.configStore.ConfigStore; +import com.wireguard.android.backend.Tunnel; +import com.wireguard.android.backend.Tunnel.State; +import com.wireguard.android.backend.Statistics; +import com.wireguard.android.util.ExceptionLoggers; +import com.wireguard.android.util.ObservableSortedKeyedArrayList; +import com.wireguard.android.util.ObservableSortedKeyedList; +import com.wireguard.config.Config; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.Set; + +import java9.util.Comparators; +import java9.util.concurrent.CompletableFuture; +import java9.util.concurrent.CompletionStage; +import java9.util.stream.Collectors; +import java9.util.stream.StreamSupport; + +/** + * Maintains and mediates changes to the set of available WireGuard tunnels, + */ + +public final class TunnelManager extends BaseObservable { + private static final Comparator<String> COMPARATOR = Comparators.<String>thenComparing( + String.CASE_INSENSITIVE_ORDER, Comparators.naturalOrder()); + private static final String KEY_LAST_USED_TUNNEL = "last_used_tunnel"; + private static final String KEY_RESTORE_ON_BOOT = "restore_on_boot"; + private static final String KEY_RUNNING_TUNNELS = "enabled_configs"; + + private final CompletableFuture<ObservableSortedKeyedList<String, ObservableTunnel>> completableTunnels = new CompletableFuture<>(); + private final ConfigStore configStore; + private final Context context = Application.get(); + private final ArrayList<CompletableFuture<Void>> delayedLoadRestoreTunnels = new ArrayList<>(); + private final ObservableSortedKeyedList<String, ObservableTunnel> tunnels = new ObservableSortedKeyedArrayList<>(COMPARATOR); + private boolean haveLoaded; + @Nullable private ObservableTunnel lastUsedTunnel; + + public TunnelManager(final ConfigStore configStore) { + this.configStore = configStore; + } + + static CompletionStage<State> getTunnelState(final ObservableTunnel tunnel) { + return Application.getAsyncWorker().supplyAsync(() -> Application.getBackend().getState(tunnel)) + .thenApply(tunnel::onStateChanged); + } + + static CompletionStage<Statistics> getTunnelStatistics(final ObservableTunnel tunnel) { + return Application.getAsyncWorker().supplyAsync(() -> Application.getBackend().getStatistics(tunnel)) + .thenApply(tunnel::onStatisticsChanged); + } + + private ObservableTunnel addToList(final String name, @Nullable final Config config, final State state) { + final ObservableTunnel tunnel = new ObservableTunnel(this, name, config, state); + tunnels.add(tunnel); + return tunnel; + } + + public CompletionStage<ObservableTunnel> create(final String name, @Nullable final Config config) { + if (Tunnel.isNameInvalid(name)) + return CompletableFuture.failedFuture(new IllegalArgumentException(context.getString(R.string.tunnel_error_invalid_name))); + if (tunnels.containsKey(name)) { + final String message = context.getString(R.string.tunnel_error_already_exists, name); + return CompletableFuture.failedFuture(new IllegalArgumentException(message)); + } + return Application.getAsyncWorker().supplyAsync(() -> configStore.create(name, config)) + .thenApply(savedConfig -> addToList(name, savedConfig, State.DOWN)); + } + + CompletionStage<Void> delete(final ObservableTunnel tunnel) { + final State originalState = tunnel.getState(); + final boolean wasLastUsed = tunnel == lastUsedTunnel; + // Make sure nothing touches the tunnel. + if (wasLastUsed) + setLastUsedTunnel(null); + tunnels.remove(tunnel); + return Application.getAsyncWorker().runAsync(() -> { + if (originalState == State.UP) + Application.getBackend().setState(tunnel, State.DOWN, null); + try { + configStore.delete(tunnel.getName()); + } catch (final Exception e) { + if (originalState == State.UP) + Application.getBackend().setState(tunnel, State.UP, tunnel.getConfig()); + // Re-throw the exception to fail the completion. + throw e; + } + }).whenComplete((x, e) -> { + if (e == null) + return; + // Failure, put the tunnel back. + tunnels.add(tunnel); + if (wasLastUsed) + setLastUsedTunnel(tunnel); + }); + } + + @Bindable + @Nullable + public ObservableTunnel getLastUsedTunnel() { + return lastUsedTunnel; + } + + CompletionStage<Config> getTunnelConfig(final ObservableTunnel tunnel) { + return Application.getAsyncWorker().supplyAsync(() -> configStore.load(tunnel.getName())) + .thenApply(tunnel::onConfigChanged); + } + + public CompletableFuture<ObservableSortedKeyedList<String, ObservableTunnel>> getTunnels() { + return completableTunnels; + } + + public void onCreate() { + Application.getAsyncWorker().supplyAsync(configStore::enumerate) + .thenAcceptBoth(Application.getAsyncWorker().supplyAsync(() -> Application.getBackend().getRunningTunnelNames()), this::onTunnelsLoaded) + .whenComplete(ExceptionLoggers.E); + } + + @SuppressWarnings("unchecked") + private void onTunnelsLoaded(final Iterable<String> present, final Collection<String> running) { + for (final String name : present) + addToList(name, null, running.contains(name) ? State.UP : State.DOWN); + final String lastUsedName = Application.getSharedPreferences().getString(KEY_LAST_USED_TUNNEL, null); + if (lastUsedName != null) + setLastUsedTunnel(tunnels.get(lastUsedName)); + final CompletableFuture<Void>[] toComplete; + synchronized (delayedLoadRestoreTunnels) { + haveLoaded = true; + toComplete = delayedLoadRestoreTunnels.toArray(new CompletableFuture[delayedLoadRestoreTunnels.size()]); + delayedLoadRestoreTunnels.clear(); + } + restoreState(true).whenComplete((v, t) -> { + for (final CompletableFuture<Void> f : toComplete) { + if (t == null) + f.complete(v); + else + f.completeExceptionally(t); + } + }); + + completableTunnels.complete(tunnels); + } + + public void refreshTunnelStates() { + Application.getAsyncWorker().supplyAsync(() -> Application.getBackend().getRunningTunnelNames()) + .thenAccept(running -> { + for (final ObservableTunnel tunnel : tunnels) + tunnel.onStateChanged(running.contains(tunnel.getName()) ? State.UP : State.DOWN); + }) + .whenComplete(ExceptionLoggers.E); + } + + public CompletionStage<Void> restoreState(final boolean force) { + if (!force && !Application.getSharedPreferences().getBoolean(KEY_RESTORE_ON_BOOT, false)) + return CompletableFuture.completedFuture(null); + synchronized (delayedLoadRestoreTunnels) { + if (!haveLoaded) { + final CompletableFuture<Void> f = new CompletableFuture<>(); + delayedLoadRestoreTunnels.add(f); + return f; + } + } + final Set<String> previouslyRunning = Application.getSharedPreferences().getStringSet(KEY_RUNNING_TUNNELS, null); + if (previouslyRunning == null) + return CompletableFuture.completedFuture(null); + return CompletableFuture.allOf(StreamSupport.stream(tunnels) + .filter(tunnel -> previouslyRunning.contains(tunnel.getName())) + .map(tunnel -> setTunnelState(tunnel, State.UP)) + .toArray(CompletableFuture[]::new)); + } + + public void saveState() { + final Set<String> runningTunnels = StreamSupport.stream(tunnels) + .filter(tunnel -> tunnel.getState() == State.UP) + .map(ObservableTunnel::getName) + .collect(Collectors.toUnmodifiableSet()); + Application.getSharedPreferences().edit().putStringSet(KEY_RUNNING_TUNNELS, runningTunnels).apply(); + } + + private void setLastUsedTunnel(@Nullable final ObservableTunnel tunnel) { + if (tunnel == lastUsedTunnel) + return; + lastUsedTunnel = tunnel; + notifyPropertyChanged(BR.lastUsedTunnel); + if (tunnel != null) + Application.getSharedPreferences().edit().putString(KEY_LAST_USED_TUNNEL, tunnel.getName()).apply(); + else + Application.getSharedPreferences().edit().remove(KEY_LAST_USED_TUNNEL).apply(); + } + + CompletionStage<Config> setTunnelConfig(final ObservableTunnel tunnel, final Config config) { + return Application.getAsyncWorker().supplyAsync(() -> { + Application.getBackend().setState(tunnel, tunnel.getState(), config); + return configStore.save(tunnel.getName(), config); + }).thenApply(tunnel::onConfigChanged); + } + + CompletionStage<String> setTunnelName(final ObservableTunnel tunnel, final String name) { + if (Tunnel.isNameInvalid(name)) + return CompletableFuture.failedFuture(new IllegalArgumentException(context.getString(R.string.tunnel_error_invalid_name))); + if (tunnels.containsKey(name)) { + final String message = context.getString(R.string.tunnel_error_already_exists, name); + return CompletableFuture.failedFuture(new IllegalArgumentException(message)); + } + final State originalState = tunnel.getState(); + final boolean wasLastUsed = tunnel == lastUsedTunnel; + // Make sure nothing touches the tunnel. + if (wasLastUsed) + setLastUsedTunnel(null); + tunnels.remove(tunnel); + return Application.getAsyncWorker().supplyAsync(() -> { + if (originalState == State.UP) + Application.getBackend().setState(tunnel, State.DOWN, null); + configStore.rename(tunnel.getName(), name); + final String newName = tunnel.onNameChanged(name); + if (originalState == State.UP) + Application.getBackend().setState(tunnel, State.UP, tunnel.getConfig()); + return newName; + }).whenComplete((newName, e) -> { + // On failure, we don't know what state the tunnel might be in. Fix that. + if (e != null) + getTunnelState(tunnel); + // Add the tunnel back to the manager, under whatever name it thinks it has. + tunnels.add(tunnel); + if (wasLastUsed) + setLastUsedTunnel(tunnel); + }); + } + + CompletionStage<State> setTunnelState(final ObservableTunnel tunnel, final State state) { + // Ensure the configuration is loaded before trying to use it. + return tunnel.getConfigAsync().thenCompose(config -> + Application.getAsyncWorker().supplyAsync(() -> Application.getBackend().setState(tunnel, state, config)) + ).whenComplete((newState, e) -> { + // Ensure onStateChanged is always called (failure or not), and with the correct state. + tunnel.onStateChanged(e == null ? newState : tunnel.getState()); + if (e == null && newState == State.UP) + setLastUsedTunnel(tunnel); + saveState(); + }); + } + + public static final class IntentReceiver extends BroadcastReceiver { + @Override + public void onReceive(final Context context, @Nullable final Intent intent) { + final TunnelManager manager = Application.getTunnelManager(); + if (intent == null) + return; + final String action = intent.getAction(); + if (action == null) + return; + + if ("com.wireguard.android.action.REFRESH_TUNNEL_STATES".equals(action)) { + manager.refreshTunnelStates(); + return; + } + + /* We disable the below, for now, as the security model of allowing this + * might take a bit more consideration. + */ + if (true) + return; + + final State state; + if ("com.wireguard.android.action.SET_TUNNEL_UP".equals(action)) + state = State.UP; + else if ("com.wireguard.android.action.SET_TUNNEL_DOWN".equals(action)) + state = State.DOWN; + else + return; + + final String tunnelName = intent.getStringExtra("tunnel"); + if (tunnelName == null) + return; + manager.getTunnels().thenAccept(tunnels -> { + final ObservableTunnel tunnel = tunnels.get(tunnelName); + if (tunnel == null) + return; + manager.setTunnelState(tunnel, state); + }); + } + } +} diff --git a/ui/src/main/java/com/wireguard/android/preference/LogExporterPreference.java b/ui/src/main/java/com/wireguard/android/preference/LogExporterPreference.java new file mode 100644 index 00000000..565854b4 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/preference/LogExporterPreference.java @@ -0,0 +1,112 @@ +/* + * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.preference; + +import android.Manifest; +import android.content.Context; +import android.content.pm.PackageManager; +import androidx.annotation.Nullable; +import com.google.android.material.snackbar.Snackbar; +import androidx.preference.Preference; + +import android.util.AttributeSet; +import android.util.Log; + +import com.wireguard.android.Application; +import com.wireguard.android.R; +import com.wireguard.android.util.DownloadsFileSaver; +import com.wireguard.android.util.DownloadsFileSaver.DownloadsFile; +import com.wireguard.android.util.ErrorMessages; +import com.wireguard.android.util.FragmentUtils; + +import java.io.BufferedReader; +import java.io.InputStreamReader; + +/** + * Preference implementing a button that asynchronously exports logs. + */ + +public class LogExporterPreference extends Preference { + private static final String TAG = "WireGuard/" + LogExporterPreference.class.getSimpleName(); + + @Nullable private String exportedFilePath; + + public LogExporterPreference(final Context context, final AttributeSet attrs) { + super(context, attrs); + } + + private void exportLog() { + Application.getAsyncWorker().supplyAsync(() -> { + DownloadsFile outputFile = DownloadsFileSaver.save(getContext(), "wireguard-log.txt", "text/plain", true); + try { + final Process process = Runtime.getRuntime().exec(new String[]{ + "logcat", "-b", "all", "-d", "-v", "threadtime", "*:V"}); + try (final BufferedReader stdout = new BufferedReader(new InputStreamReader(process.getInputStream())); + final BufferedReader stderr = new BufferedReader(new InputStreamReader(process.getErrorStream()))) + { + String line; + while ((line = stdout.readLine()) != null) { + outputFile.getOutputStream().write(line.getBytes()); + outputFile.getOutputStream().write('\n'); + } + outputFile.getOutputStream().close(); + stdout.close(); + if (process.waitFor() != 0) { + final StringBuilder errors = new StringBuilder(); + errors.append(R.string.logcat_error); + while ((line = stderr.readLine()) != null) + errors.append(line); + throw new Exception(errors.toString()); + } + } + } catch (final Exception e) { + outputFile.delete(); + throw e; + } + return outputFile.getFileName(); + }).whenComplete(this::exportLogComplete); + } + + private void exportLogComplete(final String filePath, @Nullable final Throwable throwable) { + if (throwable != null) { + final String error = ErrorMessages.get(throwable); + final String message = getContext().getString(R.string.log_export_error, error); + Log.e(TAG, message, throwable); + Snackbar.make( + FragmentUtils.getPrefActivity(this).findViewById(android.R.id.content), + message, Snackbar.LENGTH_LONG).show(); + setEnabled(true); + } else { + exportedFilePath = filePath; + notifyChanged(); + } + } + + @Override + public CharSequence getSummary() { + return exportedFilePath == null ? + getContext().getString(R.string.log_export_summary) : + getContext().getString(R.string.log_export_success, exportedFilePath); + } + + @Override + public CharSequence getTitle() { + return getContext().getString(R.string.log_export_title); + } + + @Override + protected void onClick() { + FragmentUtils.getPrefActivity(this).ensurePermissions( + new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, + (permissions, granted) -> { + if (granted.length > 0 && granted[0] == PackageManager.PERMISSION_GRANTED) { + setEnabled(false); + exportLog(); + } + }); + } + +} diff --git a/ui/src/main/java/com/wireguard/android/preference/ModuleDownloaderPreference.java b/ui/src/main/java/com/wireguard/android/preference/ModuleDownloaderPreference.java new file mode 100644 index 00000000..aac649dd --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/preference/ModuleDownloaderPreference.java @@ -0,0 +1,92 @@ +/* + * Copyright © 2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.preference; + +import android.content.Context; +import android.content.Intent; +import android.system.OsConstants; +import android.util.AttributeSet; +import android.widget.Toast; + +import com.wireguard.android.Application; +import com.wireguard.android.R; +import com.wireguard.android.util.ErrorMessages; +import com.wireguard.android.util.ModuleLoader; +import com.wireguard.android.util.ToolsInstaller; + +import androidx.annotation.Nullable; +import androidx.preference.Preference; + +public class ModuleDownloaderPreference extends Preference { + private State state = State.INITIAL; + + public ModuleDownloaderPreference(final Context context, final AttributeSet attrs) { + super(context, attrs); + } + + @Override + public CharSequence getSummary() { + return getContext().getString(state.messageResourceId); + } + + @Override + public CharSequence getTitle() { + return getContext().getString(R.string.module_installer_title); + } + + @Override + protected void onClick() { + setState(State.WORKING); + Application.getAsyncWorker().supplyAsync(Application.getModuleLoader()::download).whenComplete(this::onDownloadResult); + } + + private void onDownloadResult(final Integer result, @Nullable final Throwable throwable) { + if (throwable != null) { + setState(State.FAILURE); + Toast.makeText(getContext(), ErrorMessages.get(throwable), Toast.LENGTH_LONG).show(); + } else if (result == OsConstants.ENOENT) + setState(State.NOTFOUND); + else if (result == OsConstants.EXIT_SUCCESS) { + setState(State.SUCCESS); + Application.getAsyncWorker().runAsync(() -> { + Thread.sleep(1000 * 5); + Intent i = getContext().getPackageManager().getLaunchIntentForPackage(getContext().getPackageName()); + if (i == null) + return; + i.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + Application.get().startActivity(i); + System.exit(0); + }); + } else + setState(State.FAILURE); + } + + private void setState(final State state) { + if (this.state == state) + return; + this.state = state; + if (isEnabled() != state.shouldEnableView) + setEnabled(state.shouldEnableView); + notifyChanged(); + } + + private enum State { + INITIAL(R.string.module_installer_initial, true), + FAILURE(R.string.module_installer_error, true), + WORKING(R.string.module_installer_working, false), + SUCCESS(R.string.module_installer_success, false), + NOTFOUND(R.string.module_installer_not_found, false); + + private final int messageResourceId; + private final boolean shouldEnableView; + + State(final int messageResourceId, final boolean shouldEnableView) { + this.messageResourceId = messageResourceId; + this.shouldEnableView = shouldEnableView; + } + } +} diff --git a/ui/src/main/java/com/wireguard/android/preference/ToolsInstallerPreference.java b/ui/src/main/java/com/wireguard/android/preference/ToolsInstallerPreference.java new file mode 100644 index 00000000..78a7497b --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/preference/ToolsInstallerPreference.java @@ -0,0 +1,102 @@ +/* + * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.preference; + +import android.content.Context; +import androidx.annotation.Nullable; +import androidx.preference.Preference; +import android.util.AttributeSet; + +import com.wireguard.android.Application; +import com.wireguard.android.R; +import com.wireguard.android.util.ToolsInstaller; + +/** + * Preference implementing a button that asynchronously runs {@code ToolsInstaller} and displays the + * result as the preference summary. + */ + +public class ToolsInstallerPreference extends Preference { + private State state = State.INITIAL; + + public ToolsInstallerPreference(final Context context, final AttributeSet attrs) { + super(context, attrs); + } + + @Override + public CharSequence getSummary() { + return getContext().getString(state.messageResourceId); + } + + @Override + public CharSequence getTitle() { + return getContext().getString(R.string.tools_installer_title); + } + + @Override + public void onAttached() { + super.onAttached(); + Application.getAsyncWorker().supplyAsync(Application.getToolsInstaller()::areInstalled).whenComplete(this::onCheckResult); + } + + private void onCheckResult(final int state, @Nullable final Throwable throwable) { + if (throwable != null || state == ToolsInstaller.ERROR) + setState(State.INITIAL); + else if ((state & ToolsInstaller.YES) == ToolsInstaller.YES) + setState(State.ALREADY); + else if ((state & (ToolsInstaller.MAGISK | ToolsInstaller.NO)) == (ToolsInstaller.MAGISK | ToolsInstaller.NO)) + setState(State.INITIAL_MAGISK); + else if ((state & (ToolsInstaller.SYSTEM | ToolsInstaller.NO)) == (ToolsInstaller.SYSTEM | ToolsInstaller.NO)) + setState(State.INITIAL_SYSTEM); + else + setState(State.INITIAL); + } + + @Override + protected void onClick() { + setState(State.WORKING); + Application.getAsyncWorker().supplyAsync(Application.getToolsInstaller()::install).whenComplete(this::onInstallResult); + } + + private void onInstallResult(final Integer result, @Nullable final Throwable throwable) { + if (throwable != null) + setState(State.FAILURE); + else if ((result & (ToolsInstaller.YES | ToolsInstaller.MAGISK)) == (ToolsInstaller.YES | ToolsInstaller.MAGISK)) + setState(State.SUCCESS_MAGISK); + else if ((result & (ToolsInstaller.YES | ToolsInstaller.SYSTEM)) == (ToolsInstaller.YES | ToolsInstaller.SYSTEM)) + setState(State.SUCCESS_SYSTEM); + else + setState(State.FAILURE); + } + + private void setState(final State state) { + if (this.state == state) + return; + this.state = state; + if (isEnabled() != state.shouldEnableView) + setEnabled(state.shouldEnableView); + notifyChanged(); + } + + private enum State { + INITIAL(R.string.tools_installer_initial, true), + ALREADY(R.string.tools_installer_already, false), + FAILURE(R.string.tools_installer_failure, true), + WORKING(R.string.tools_installer_working, false), + INITIAL_SYSTEM(R.string.tools_installer_initial_system, true), + SUCCESS_SYSTEM(R.string.tools_installer_success_system, false), + INITIAL_MAGISK(R.string.tools_installer_initial_magisk, true), + SUCCESS_MAGISK(R.string.tools_installer_success_magisk, false); + + private final int messageResourceId; + private final boolean shouldEnableView; + + State(final int messageResourceId, final boolean shouldEnableView) { + this.messageResourceId = messageResourceId; + this.shouldEnableView = shouldEnableView; + } + } +} diff --git a/ui/src/main/java/com/wireguard/android/preference/VersionPreference.java b/ui/src/main/java/com/wireguard/android/preference/VersionPreference.java new file mode 100644 index 00000000..7e95a8ae --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/preference/VersionPreference.java @@ -0,0 +1,71 @@ +/* + * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.preference; + +import android.content.ActivityNotFoundException; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import androidx.annotation.Nullable; +import androidx.preference.Preference; +import android.util.AttributeSet; + +import com.wireguard.android.Application; +import com.wireguard.android.BuildConfig; +import com.wireguard.android.R; +import com.wireguard.android.backend.Backend; +import com.wireguard.android.backend.GoBackend; +import com.wireguard.android.backend.WgQuickBackend; + +import java.util.Locale; + +public class VersionPreference extends Preference { + @Nullable private String versionSummary; + + private String getBackendPrettyName(final Context context, final Backend backend) { + if (backend instanceof GoBackend) + return context.getString(R.string.type_name_kernel_module); + if (backend instanceof WgQuickBackend) + return context.getString(R.string.type_name_go_userspace); + return ""; + } + + public VersionPreference(final Context context, final AttributeSet attrs) { + super(context, attrs); + + Application.getBackendAsync().thenAccept(backend -> { + versionSummary = getContext().getString(R.string.version_summary_checking, getBackendPrettyName(context, backend).toLowerCase(Locale.ENGLISH)); + Application.getAsyncWorker().supplyAsync(backend::getVersion).whenComplete((version, exception) -> { + versionSummary = exception == null + ? getContext().getString(R.string.version_summary, getBackendPrettyName(context, backend), version) + : getContext().getString(R.string.version_summary_unknown, getBackendPrettyName(context, backend).toLowerCase(Locale.ENGLISH)); + notifyChanged(); + }); + }); + } + + @Nullable + @Override + public CharSequence getSummary() { + return versionSummary; + } + + @Override + public CharSequence getTitle() { + return getContext().getString(R.string.version_title, BuildConfig.VERSION_NAME); + } + + @Override + protected void onClick() { + final Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setData(Uri.parse("https://www.wireguard.com/")); + try { + getContext().startActivity(intent); + } catch (final ActivityNotFoundException ignored) { + } + } + +} diff --git a/ui/src/main/java/com/wireguard/android/preference/ZipExporterPreference.java b/ui/src/main/java/com/wireguard/android/preference/ZipExporterPreference.java new file mode 100644 index 00000000..3af412a5 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/preference/ZipExporterPreference.java @@ -0,0 +1,119 @@ +/* + * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.preference; + +import android.Manifest; +import android.content.Context; +import android.content.pm.PackageManager; +import androidx.annotation.Nullable; +import com.google.android.material.snackbar.Snackbar; +import androidx.preference.Preference; +import android.util.AttributeSet; +import android.util.Log; + +import com.wireguard.android.Application; +import com.wireguard.android.R; +import com.wireguard.android.model.ObservableTunnel; +import com.wireguard.android.util.DownloadsFileSaver; +import com.wireguard.android.util.DownloadsFileSaver.DownloadsFile; +import com.wireguard.android.util.ErrorMessages; +import com.wireguard.android.util.FragmentUtils; +import com.wireguard.config.Config; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import java9.util.concurrent.CompletableFuture; + +/** + * Preference implementing a button that asynchronously exports config zips. + */ + +public class ZipExporterPreference extends Preference { + private static final String TAG = "WireGuard/" + ZipExporterPreference.class.getSimpleName(); + + @Nullable private String exportedFilePath; + + public ZipExporterPreference(final Context context, final AttributeSet attrs) { + super(context, attrs); + } + + private void exportZip() { + Application.getTunnelManager().getTunnels().thenAccept(this::exportZip); + } + + private void exportZip(final List<ObservableTunnel> tunnels) { + final List<CompletableFuture<Config>> futureConfigs = new ArrayList<>(tunnels.size()); + for (final ObservableTunnel tunnel : tunnels) + futureConfigs.add(tunnel.getConfigAsync().toCompletableFuture()); + if (futureConfigs.isEmpty()) { + exportZipComplete(null, new IllegalArgumentException( + getContext().getString(R.string.no_tunnels_error))); + return; + } + CompletableFuture.allOf(futureConfigs.toArray(new CompletableFuture[futureConfigs.size()])) + .whenComplete((ignored1, exception) -> Application.getAsyncWorker().supplyAsync(() -> { + if (exception != null) + throw exception; + DownloadsFile outputFile = DownloadsFileSaver.save(getContext(), "wireguard-export.zip", "application/zip", true); + try (ZipOutputStream zip = new ZipOutputStream(outputFile.getOutputStream())) { + for (int i = 0; i < futureConfigs.size(); ++i) { + zip.putNextEntry(new ZipEntry(tunnels.get(i).getName() + ".conf")); + zip.write(futureConfigs.get(i).getNow(null). + toWgQuickString().getBytes(StandardCharsets.UTF_8)); + } + zip.closeEntry(); + } catch (final Exception e) { + outputFile.delete(); + throw e; + } + return outputFile.getFileName(); + }).whenComplete(this::exportZipComplete)); + } + + private void exportZipComplete(@Nullable final String filePath, @Nullable final Throwable throwable) { + if (throwable != null) { + final String error = ErrorMessages.get(throwable); + final String message = getContext().getString(R.string.zip_export_error, error); + Log.e(TAG, message, throwable); + Snackbar.make( + FragmentUtils.getPrefActivity(this).findViewById(android.R.id.content), + message, Snackbar.LENGTH_LONG).show(); + setEnabled(true); + } else { + exportedFilePath = filePath; + notifyChanged(); + } + } + + @Override + public CharSequence getSummary() { + return exportedFilePath == null ? + getContext().getString(R.string.zip_export_summary) : + getContext().getString(R.string.zip_export_success, exportedFilePath); + } + + @Override + public CharSequence getTitle() { + return getContext().getString(R.string.zip_export_title); + } + + @Override + protected void onClick() { + FragmentUtils.getPrefActivity(this).ensurePermissions( + new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, + (permissions, granted) -> { + if (granted.length > 0 && granted[0] == PackageManager.PERMISSION_GRANTED) { + setEnabled(false); + exportZip(); + } + }); + } + +} diff --git a/ui/src/main/java/com/wireguard/android/ui/EdgeToEdge.kt b/ui/src/main/java/com/wireguard/android/ui/EdgeToEdge.kt new file mode 100644 index 00000000..52a19657 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/ui/EdgeToEdge.kt @@ -0,0 +1,67 @@ +/* + * Copyright © 2017-2020 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.ui + +import android.view.View +import android.view.ViewGroup +import androidx.core.view.marginBottom +import androidx.core.view.marginLeft +import androidx.core.view.marginRight +import androidx.core.view.marginTop +import androidx.core.view.updateLayoutParams +import androidx.core.view.updatePadding +import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton + +/** + * A utility for edge-to-edge display. It provides several features needed to make the app + * displayed edge-to-edge on Android Q with gestural navigation. + */ + +object EdgeToEdge { + + @JvmStatic + fun setUpRoot(root: ViewGroup) { + root.systemUiVisibility = + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_STABLE + } + + @JvmStatic + fun setUpScrollingContent(scrollingContent: ViewGroup, fab: ExtendedFloatingActionButton?) { + val originalPaddingLeft = scrollingContent.paddingLeft + val originalPaddingRight = scrollingContent.paddingRight + val originalPaddingBottom = scrollingContent.paddingBottom + + val fabPaddingBottom = fab?.height ?: 0 + + val originalMarginTop = scrollingContent.marginTop + + scrollingContent.setOnApplyWindowInsetsListener { _, windowInsets -> + scrollingContent.updatePadding( + left = originalPaddingLeft + windowInsets.systemWindowInsetLeft, + right = originalPaddingRight + windowInsets.systemWindowInsetRight, + bottom = originalPaddingBottom + fabPaddingBottom + windowInsets.systemWindowInsetBottom + ) + scrollingContent.updateLayoutParams<ViewGroup.MarginLayoutParams> { + topMargin = originalMarginTop + windowInsets.systemWindowInsetTop + } + windowInsets + } + } + + @JvmStatic + fun setUpFAB(fab: ExtendedFloatingActionButton) { + val originalMarginLeft = fab.marginLeft + val originalMarginRight = fab.marginRight + val originalMarginBottom = fab.marginBottom + fab.setOnApplyWindowInsetsListener { _, windowInsets -> + fab.updateLayoutParams<ViewGroup.MarginLayoutParams> { + leftMargin = originalMarginLeft + windowInsets.systemWindowInsetLeft + rightMargin = originalMarginRight + windowInsets.systemWindowInsetRight + bottomMargin = originalMarginBottom + windowInsets.systemWindowInsetBottom + } + windowInsets + } + } +} diff --git a/ui/src/main/java/com/wireguard/android/util/ClipboardUtils.java b/ui/src/main/java/com/wireguard/android/util/ClipboardUtils.java new file mode 100644 index 00000000..0df5e96a --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/util/ClipboardUtils.java @@ -0,0 +1,37 @@ +/* + * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.util; + +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import com.google.android.material.snackbar.Snackbar; +import android.view.View; +import android.widget.TextView; + +/** + * Standalone utilities for interacting with the system clipboard. + */ + +public final class ClipboardUtils { + private ClipboardUtils() { + // Prevent instantiation + } + + public static void copyTextView(final View view) { + if (!(view instanceof TextView)) + return; + final CharSequence text = ((TextView) view).getText(); + if (text == null || text.length() == 0) + return; + final Object service = view.getContext().getSystemService(Context.CLIPBOARD_SERVICE); + if (!(service instanceof ClipboardManager)) + return; + final CharSequence description = view.getContentDescription(); + ((ClipboardManager) service).setPrimaryClip(ClipData.newPlainText(description, text)); + Snackbar.make(view, description + " copied to clipboard", Snackbar.LENGTH_LONG).show(); + } +} diff --git a/ui/src/main/java/com/wireguard/android/util/DownloadsFileSaver.java b/ui/src/main/java/com/wireguard/android/util/DownloadsFileSaver.java new file mode 100644 index 00000000..7db46fa9 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/util/DownloadsFileSaver.java @@ -0,0 +1,98 @@ +/* + * Copyright © 2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.util; + +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.provider.MediaStore; +import android.provider.MediaStore.MediaColumns; + +import com.wireguard.android.R; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; + +public class DownloadsFileSaver { + + public static class DownloadsFile { + private Context context; + private OutputStream outputStream; + private String fileName; + private Uri uri; + + private DownloadsFile(final Context context, final OutputStream outputStream, final String fileName, final Uri uri) { + this.context = context; + this.outputStream = outputStream; + this.fileName = fileName; + this.uri = uri; + } + + public OutputStream getOutputStream() { return outputStream; } + public String getFileName() { return fileName; } + + public void delete() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) + context.getContentResolver().delete(uri, null, null); + else + new File(fileName).delete(); + } + } + + public static DownloadsFile save(final Context context, final String name, final String mimeType, final boolean overwriteExisting) throws Exception { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + final ContentResolver contentResolver = context.getContentResolver(); + if (overwriteExisting) + contentResolver.delete(MediaStore.Downloads.EXTERNAL_CONTENT_URI, String.format("%s = ?", MediaColumns.DISPLAY_NAME), new String[]{name}); + final ContentValues contentValues = new ContentValues(); + contentValues.put(MediaColumns.DISPLAY_NAME, name); + contentValues.put(MediaColumns.MIME_TYPE, mimeType); + final Uri contentUri = contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues); + if (contentUri == null) + throw new IOException(context.getString(R.string.create_downloads_file_error)); + final OutputStream contentStream = contentResolver.openOutputStream(contentUri); + if (contentStream == null) + throw new IOException(context.getString(R.string.create_downloads_file_error)); + @SuppressWarnings("deprecation") + Cursor cursor = contentResolver.query(contentUri, new String[]{MediaColumns.DATA}, null, null, null); + String path = null; + if (cursor != null) { + try { + if (cursor.moveToFirst()) + path = cursor.getString(0); + } finally { + cursor.close(); + } + } + if (path == null) { + path = "Download/"; + cursor = contentResolver.query(contentUri, new String[]{MediaColumns.DISPLAY_NAME}, null, null, null); + if (cursor != null) { + try { + if (cursor.moveToFirst()) + path += cursor.getString(0); + } finally { + cursor.close(); + } + } + } + return new DownloadsFile(context, contentStream, path, contentUri); + } else { + @SuppressWarnings("deprecation") + final File path = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); + final File file = new File(path, name); + if (!path.isDirectory() && !path.mkdirs()) + throw new IOException(context.getString(R.string.create_output_dir_error)); + return new DownloadsFile(context, new FileOutputStream(file), file.getAbsolutePath(), null); + } + } +} diff --git a/ui/src/main/java/com/wireguard/android/util/ErrorMessages.java b/ui/src/main/java/com/wireguard/android/util/ErrorMessages.java new file mode 100644 index 00000000..481a6ffb --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/util/ErrorMessages.java @@ -0,0 +1,160 @@ +/* + * Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.util; + +import android.content.res.Resources; +import android.os.RemoteException; + +import androidx.annotation.Nullable; + +import com.wireguard.android.Application; +import com.wireguard.android.R; +import com.wireguard.android.backend.BackendException; +import com.wireguard.android.util.RootShell.RootShellException; +import com.wireguard.config.BadConfigException; +import com.wireguard.config.BadConfigException.Location; +import com.wireguard.config.InetEndpoint; +import com.wireguard.config.InetNetwork; +import com.wireguard.config.ParseException; +import com.wireguard.crypto.Key.Format; +import com.wireguard.crypto.KeyFormatException; +import com.wireguard.crypto.KeyFormatException.Type; + +import java.net.InetAddress; +import java.util.EnumMap; +import java.util.Map; + +import java9.util.Maps; + +public final class ErrorMessages { + private static final Map<BadConfigException.Reason, Integer> BCE_REASON_MAP = new EnumMap<>(Maps.of( + BadConfigException.Reason.INVALID_KEY, R.string.bad_config_reason_invalid_key, + BadConfigException.Reason.INVALID_NUMBER, R.string.bad_config_reason_invalid_number, + BadConfigException.Reason.INVALID_VALUE, R.string.bad_config_reason_invalid_value, + BadConfigException.Reason.MISSING_ATTRIBUTE, R.string.bad_config_reason_missing_attribute, + BadConfigException.Reason.MISSING_SECTION, R.string.bad_config_reason_missing_section, + BadConfigException.Reason.MISSING_VALUE, R.string.bad_config_reason_missing_value, + BadConfigException.Reason.SYNTAX_ERROR, R.string.bad_config_reason_syntax_error, + BadConfigException.Reason.UNKNOWN_ATTRIBUTE, R.string.bad_config_reason_unknown_attribute, + BadConfigException.Reason.UNKNOWN_SECTION, R.string.bad_config_reason_unknown_section + )); + private static final Map<BackendException.Reason, Integer> BE_REASON_MAP = new EnumMap<>(Maps.of( + BackendException.Reason.UNKNOWN_KERNEL_MODULE_NAME, R.string.module_version_error, + BackendException.Reason.WG_QUICK_CONFIG_ERROR_CODE, R.string.tunnel_config_error, + BackendException.Reason.TUNNEL_MISSING_CONFIG, R.string.no_config_error, + BackendException.Reason.VPN_NOT_AUTHORIZED, R.string.vpn_not_authorized_error, + BackendException.Reason.UNABLE_TO_START_VPN, R.string.vpn_start_error, + BackendException.Reason.TUN_CREATION_ERROR, R.string.tun_create_error, + BackendException.Reason.GO_ACTIVATION_ERROR_CODE, R.string.tunnel_on_error + )); + private static final Map<RootShellException.Reason, Integer> RSE_REASON_MAP = new EnumMap<>(Maps.of( + RootShellException.Reason.NO_ROOT_ACCESS, R.string.error_root, + RootShellException.Reason.SHELL_MARKER_COUNT_ERROR, R.string.shell_marker_count_error, + RootShellException.Reason.SHELL_EXIT_STATUS_READ_ERROR, R.string.shell_exit_status_read_error, + RootShellException.Reason.SHELL_START_ERROR, R.string.shell_start_error, + RootShellException.Reason.CREATE_BIN_DIR_ERROR, R.string.create_bin_dir_error, + RootShellException.Reason.CREATE_TEMP_DIR_ERROR, R.string.create_temp_dir_error + )); + private static final Map<Format, Integer> KFE_FORMAT_MAP = new EnumMap<>(Maps.of( + Format.BASE64, R.string.key_length_explanation_base64, + Format.BINARY, R.string.key_length_explanation_binary, + Format.HEX, R.string.key_length_explanation_hex + )); + private static final Map<Type, Integer> KFE_TYPE_MAP = new EnumMap<>(Maps.of( + Type.CONTENTS, R.string.key_contents_error, + Type.LENGTH, R.string.key_length_error + )); + private static final Map<Class, Integer> PE_CLASS_MAP = Maps.of( + InetAddress.class, R.string.parse_error_inet_address, + InetEndpoint.class, R.string.parse_error_inet_endpoint, + InetNetwork.class, R.string.parse_error_inet_network, + Integer.class, R.string.parse_error_integer + ); + + private ErrorMessages() { + // Prevent instantiation + } + + public static String get(@Nullable final Throwable throwable) { + final Resources resources = Application.get().getResources(); + if (throwable == null) + return resources.getString(R.string.unknown_error); + final Throwable rootCause = rootCause(throwable); + final String message; + if (rootCause instanceof BadConfigException) { + final BadConfigException bce = (BadConfigException) rootCause; + final String reason = getBadConfigExceptionReason(resources, bce); + final String context = bce.getLocation() == Location.TOP_LEVEL ? + resources.getString(R.string.bad_config_context_top_level, + bce.getSection().getName()) : + resources.getString(R.string.bad_config_context, + bce.getSection().getName(), + bce.getLocation().getName()); + final String explanation = getBadConfigExceptionExplanation(resources, bce); + message = resources.getString(R.string.bad_config_error, reason, context) + explanation; + } else if (rootCause instanceof BackendException) { + final BackendException be = (BackendException) rootCause; + message = resources.getString(BE_REASON_MAP.get(be.getReason()), be.getFormat()); + } else if (rootCause instanceof RootShellException) { + final RootShellException rse = (RootShellException) rootCause; + message = resources.getString(RSE_REASON_MAP.get(rse.getReason()), rse.getFormat()); + } else if (rootCause.getMessage() != null) { + message = rootCause.getMessage(); + } else { + final String errorType = rootCause.getClass().getSimpleName(); + message = resources.getString(R.string.generic_error, errorType); + } + return message; + } + + private static String getBadConfigExceptionExplanation(final Resources resources, + final BadConfigException bce) { + if (bce.getCause() instanceof KeyFormatException) { + final KeyFormatException kfe = (KeyFormatException) bce.getCause(); + if (kfe.getType() == Type.LENGTH) + return resources.getString(KFE_FORMAT_MAP.get(kfe.getFormat())); + } else if (bce.getCause() instanceof ParseException) { + final ParseException pe = (ParseException) bce.getCause(); + if (pe.getMessage() != null) + return ": " + pe.getMessage(); + } else if (bce.getLocation() == Location.LISTEN_PORT) { + return resources.getString(R.string.bad_config_explanation_udp_port); + } else if (bce.getLocation() == Location.MTU) { + return resources.getString(R.string.bad_config_explanation_positive_number); + } else if (bce.getLocation() == Location.PERSISTENT_KEEPALIVE) { + return resources.getString(R.string.bad_config_explanation_pka); + } + return ""; + } + + private static String getBadConfigExceptionReason(final Resources resources, + final BadConfigException bce) { + if (bce.getCause() instanceof KeyFormatException) { + final KeyFormatException kfe = (KeyFormatException) bce.getCause(); + return resources.getString(KFE_TYPE_MAP.get(kfe.getType())); + } else if (bce.getCause() instanceof ParseException) { + final ParseException pe = (ParseException) bce.getCause(); + final String type = resources.getString(PE_CLASS_MAP.containsKey(pe.getParsingClass()) ? + PE_CLASS_MAP.get(pe.getParsingClass()) : R.string.parse_error_generic); + return resources.getString(R.string.parse_error_reason, type, pe.getText()); + } + return resources.getString(BCE_REASON_MAP.get(bce.getReason()), bce.getText()); + } + + private static Throwable rootCause(final Throwable throwable) { + Throwable cause = throwable; + while (cause.getCause() != null) { + if (cause instanceof BadConfigException || cause instanceof BackendException || + cause instanceof RootShellException) + break; + final Throwable nextCause = cause.getCause(); + if (nextCause instanceof RemoteException) + break; + cause = nextCause; + } + return cause; + } +} diff --git a/ui/src/main/java/com/wireguard/android/util/ExceptionLoggers.java b/ui/src/main/java/com/wireguard/android/util/ExceptionLoggers.java new file mode 100644 index 00000000..5c7a38c0 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/util/ExceptionLoggers.java @@ -0,0 +1,36 @@ +/* + * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.util; + +import androidx.annotation.Nullable; +import android.util.Log; + +import java9.util.function.BiConsumer; + +/** + * Helpers for logging exceptions from asynchronous tasks. These can be passed to + * {@code CompletionStage.whenComplete()} at the end of an asynchronous future chain. + */ + +public enum ExceptionLoggers implements BiConsumer<Object, Throwable> { + D(Log.DEBUG), + E(Log.ERROR); + + private static final String TAG = "WireGuard/" + ExceptionLoggers.class.getSimpleName(); + private final int priority; + + ExceptionLoggers(final int priority) { + this.priority = priority; + } + + @Override + public void accept(final Object result, @Nullable final Throwable throwable) { + if (throwable != null) + Log.println(Log.ERROR, TAG, Log.getStackTraceString(throwable)); + else if (priority <= Log.DEBUG) + Log.println(priority, TAG, "Future completed successfully"); + } +} diff --git a/ui/src/main/java/com/wireguard/android/util/Extensions.kt b/ui/src/main/java/com/wireguard/android/util/Extensions.kt new file mode 100644 index 00000000..6b528a85 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/util/Extensions.kt @@ -0,0 +1,16 @@ +/* + * Copyright © 2020 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.util + +import android.content.Context +import android.util.TypedValue +import androidx.annotation.AttrRes + +fun Context.resolveAttribute(@AttrRes attrRes: Int): Int { + val typedValue = TypedValue() + theme.resolveAttribute(attrRes, typedValue, true) + return typedValue.data +} diff --git a/ui/src/main/java/com/wireguard/android/util/FragmentUtils.java b/ui/src/main/java/com/wireguard/android/util/FragmentUtils.java new file mode 100644 index 00000000..5fb9a3bc --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/util/FragmentUtils.java @@ -0,0 +1,27 @@ +/* + * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.util; + +import android.content.Context; +import androidx.preference.Preference; +import android.view.ContextThemeWrapper; + +import com.wireguard.android.activity.SettingsActivity; + +public final class FragmentUtils { + private FragmentUtils() { + // Prevent instantiation + } + + public static SettingsActivity getPrefActivity(final Preference preference) { + final Context context = preference.getContext(); + if (context instanceof ContextThemeWrapper) { + if (context instanceof SettingsActivity) { + return ((SettingsActivity) context); + } + } + return null; + } +} diff --git a/ui/src/main/java/com/wireguard/android/util/ModuleLoader.java b/ui/src/main/java/com/wireguard/android/util/ModuleLoader.java new file mode 100644 index 00000000..bf094a5e --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/util/ModuleLoader.java @@ -0,0 +1,186 @@ +/* + * Copyright © 2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.util; + +import android.content.Context; +import android.system.OsConstants; +import android.util.Base64; + +import com.wireguard.android.util.RootShell.RootShellException; + +import net.i2p.crypto.eddsa.EdDSAEngine; +import net.i2p.crypto.eddsa.EdDSAPublicKey; +import net.i2p.crypto.eddsa.spec.EdDSANamedCurveTable; +import net.i2p.crypto.eddsa.spec.EdDSAParameterSpec; +import net.i2p.crypto.eddsa.spec.EdDSAPublicKeySpec; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.security.InvalidParameterException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.Signature; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.annotation.Nullable; + +public class ModuleLoader { + private static final String MODULE_PUBLIC_KEY_BASE64 = "RWRmHuT9PSqtwfsLtEx+QS06BJtLgFYteL9WCNjH7yuyu5Y1DieSN7If"; + private static final String MODULE_LIST_URL = "https://download.wireguard.com/android-module/modules.txt.sig"; + private static final String MODULE_URL = "https://download.wireguard.com/android-module/%s"; + private static final String MODULE_NAME = "wireguard-%s.ko"; + + private final RootShell rootShell; + private final String userAgent; + private final File moduleDir; + private final File tmpDir; + + public ModuleLoader(final Context context, final RootShell rootShell, final String userAgent) { + moduleDir = new File(context.getCacheDir(), "kmod"); + tmpDir = new File(context.getCacheDir(), "tmp"); + this.rootShell = rootShell; + this.userAgent = userAgent; + } + + public boolean moduleMightExist() { + return moduleDir.exists() && moduleDir.isDirectory(); + } + + public void loadModule() throws IOException, RootShellException { + rootShell.run(null, String.format("insmod \"%s/wireguard-$(sha256sum /proc/version|cut -d ' ' -f 1).ko\"", moduleDir.getAbsolutePath())); + } + + public static boolean isModuleLoaded() { + return new File("/sys/module/wireguard").exists(); + } + + private static final class Sha256Digest { + private byte[] bytes; + private Sha256Digest(final String hex) { + if (hex.length() != 64) + throw new InvalidParameterException("SHA256 hashes must be 32 bytes long"); + bytes = new byte[32]; + for (int i = 0; i < 32; ++i) + bytes[i] = (byte)Integer.parseInt(hex.substring(i * 2, i * 2 + 2), 16); + } + } + + @Nullable + private Map<String, Sha256Digest> verifySignedHashes(final String signifyDigest) { + final byte[] publicKeyBytes = Base64.decode(MODULE_PUBLIC_KEY_BASE64, Base64.DEFAULT); + + if (publicKeyBytes == null || publicKeyBytes.length != 32 + 10 || publicKeyBytes[0] != 'E' || publicKeyBytes[1] != 'd') + return null; + + final String[] lines = signifyDigest.split("\n", 3); + if (lines.length != 3) + return null; + if (!lines[0].startsWith("untrusted comment: ")) + return null; + + final byte[] signatureBytes = Base64.decode(lines[1], Base64.DEFAULT); + if (signatureBytes == null || signatureBytes.length != 64 + 10) + return null; + for (int i = 0; i < 10; ++i) { + if (signatureBytes[i] != publicKeyBytes[i]) + return null; + } + + try { + EdDSAParameterSpec parameterSpec = EdDSANamedCurveTable.getByName(EdDSANamedCurveTable.ED_25519); + Signature signature = new EdDSAEngine(MessageDigest.getInstance(parameterSpec.getHashAlgorithm())); + byte[] rawPublicKeyBytes = new byte[32]; + System.arraycopy(publicKeyBytes, 10, rawPublicKeyBytes, 0, 32); + signature.initVerify(new EdDSAPublicKey(new EdDSAPublicKeySpec(rawPublicKeyBytes, parameterSpec))); + signature.update(lines[2].getBytes(StandardCharsets.UTF_8)); + if (!signature.verify(signatureBytes, 10, 64)) + return null; + } catch (final Exception ignored) { + return null; + } + + Map<String, Sha256Digest> hashes = new HashMap<>(); + for (final String line : lines[2].split("\n")) { + final String[] components = line.split(" ", 2); + if (components.length != 2) + return null; + try { + hashes.put(components[1], new Sha256Digest(components[0])); + } catch (final Exception ignored) { + return null; + } + } + return hashes; + } + + public Integer download() throws IOException, RootShellException, NoSuchAlgorithmException { + final List<String> output = new ArrayList<>(); + rootShell.run(output, "sha256sum /proc/version|cut -d ' ' -f 1"); + if (output.size() != 1 || output.get(0).length() != 64) + throw new InvalidParameterException("Invalid sha256 of /proc/version"); + final String moduleName = String.format(MODULE_NAME, output.get(0)); + HttpURLConnection connection = (HttpURLConnection)new URL(MODULE_LIST_URL).openConnection(); + connection.setRequestProperty("User-Agent", userAgent); + connection.connect(); + if (connection.getResponseCode() != HttpURLConnection.HTTP_OK) + throw new IOException("Hash list could not be found"); + byte[] input = new byte[1024 * 1024 * 3 /* 3MiB */]; + int len; + try (final InputStream inputStream = connection.getInputStream()) { + len = inputStream.read(input); + } + if (len <= 0) + throw new IOException("Hash list was empty"); + final Map<String, Sha256Digest> modules = verifySignedHashes(new String(input, 0, len, StandardCharsets.UTF_8)); + if (modules == null) + throw new InvalidParameterException("The signature did not verify or invalid hash list format"); + if (!modules.containsKey(moduleName)) + return OsConstants.ENOENT; + connection = (HttpURLConnection)new URL(String.format(MODULE_URL, moduleName)).openConnection(); + connection.setRequestProperty("User-Agent", userAgent); + connection.connect(); + if (connection.getResponseCode() != HttpURLConnection.HTTP_OK) + throw new IOException("Module file could not be found, despite being on hash list"); + + tmpDir.mkdirs(); + moduleDir.mkdir(); + File tempFile = null; + try { + tempFile = File.createTempFile("UNVERIFIED-", null, tmpDir); + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + try (final InputStream inputStream = connection.getInputStream(); + final FileOutputStream outputStream = new FileOutputStream(tempFile)) { + int total = 0; + while ((len = inputStream.read(input)) > 0) { + total += len; + if (total > 1024 * 1024 * 15 /* 15 MiB */) + throw new IOException("File too big"); + outputStream.write(input, 0, len); + digest.update(input, 0, len); + } + outputStream.getFD().sync(); + } + if (!Arrays.equals(digest.digest(), modules.get(moduleName).bytes)) + throw new IOException("Incorrect file hash"); + + if (!tempFile.renameTo(new File(moduleDir, moduleName))) + throw new IOException("Unable to rename to final destination"); + } finally { + if (tempFile != null) + tempFile.delete(); + } + return OsConstants.EXIT_SUCCESS; + } +} diff --git a/ui/src/main/java/com/wireguard/android/util/ObservableKeyedArrayList.java b/ui/src/main/java/com/wireguard/android/util/ObservableKeyedArrayList.java new file mode 100644 index 00000000..0ba02184 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/util/ObservableKeyedArrayList.java @@ -0,0 +1,109 @@ +/* + * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.util; + +import androidx.databinding.ObservableArrayList; +import androidx.annotation.Nullable; + +import com.wireguard.util.Keyed; + +import java.util.Collection; +import java.util.ListIterator; +import java.util.Objects; + +/** + * ArrayList that allows looking up elements by some key property. As the key property must always + * be retrievable, this list cannot hold {@code null} elements. Because this class places no + * restrictions on the order or duplication of keys, lookup by key, as well as all list modification + * operations, require O(n) time. + */ + +public class ObservableKeyedArrayList<K, E extends Keyed<? extends K>> + extends ObservableArrayList<E> implements ObservableKeyedList<K, E> { + @Override + public boolean add(@Nullable final E e) { + if (e == null) + throw new NullPointerException("Trying to add a null element"); + return super.add(e); + } + + @Override + public void add(final int index, @Nullable final E e) { + if (e == null) + throw new NullPointerException("Trying to add a null element"); + super.add(index, e); + } + + @Override + public boolean addAll(final Collection<? extends E> c) { + if (c.contains(null)) + throw new NullPointerException("Trying to add a collection with null element(s)"); + return super.addAll(c); + } + + @Override + public boolean addAll(final int index, final Collection<? extends E> c) { + if (c.contains(null)) + throw new NullPointerException("Trying to add a collection with null element(s)"); + return super.addAll(index, c); + } + + @Override + public boolean containsAllKeys(final Collection<K> keys) { + for (final K key : keys) + if (!containsKey(key)) + return false; + return true; + } + + @Override + public boolean containsKey(final K key) { + return indexOfKey(key) >= 0; + } + + @Nullable + @Override + public E get(final K key) { + final int index = indexOfKey(key); + return index >= 0 ? get(index) : null; + } + + @Nullable + @Override + public E getLast(final K key) { + final int index = lastIndexOfKey(key); + return index >= 0 ? get(index) : null; + } + + @Override + public int indexOfKey(final K key) { + final ListIterator<E> iterator = listIterator(); + while (iterator.hasNext()) { + final int index = iterator.nextIndex(); + if (Objects.equals(iterator.next().getKey(), key)) + return index; + } + return -1; + } + + @Override + public int lastIndexOfKey(final K key) { + final ListIterator<E> iterator = listIterator(size()); + while (iterator.hasPrevious()) { + final int index = iterator.previousIndex(); + if (Objects.equals(iterator.previous().getKey(), key)) + return index; + } + return -1; + } + + @Override + public E set(final int index, @Nullable final E e) { + if (e == null) + throw new NullPointerException("Trying to set a null key"); + return super.set(index, e); + } +} diff --git a/ui/src/main/java/com/wireguard/android/util/ObservableKeyedList.java b/ui/src/main/java/com/wireguard/android/util/ObservableKeyedList.java new file mode 100644 index 00000000..be8ceb9b --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/util/ObservableKeyedList.java @@ -0,0 +1,19 @@ +/* + * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.util; + +import androidx.databinding.ObservableList; + +import com.wireguard.util.Keyed; +import com.wireguard.util.KeyedList; + +/** + * A list that is both keyed and observable. + */ + +public interface ObservableKeyedList<K, E extends Keyed<? extends K>> + extends KeyedList<K, E>, ObservableList<E> { +} diff --git a/ui/src/main/java/com/wireguard/android/util/ObservableSortedKeyedArrayList.java b/ui/src/main/java/com/wireguard/android/util/ObservableSortedKeyedArrayList.java new file mode 100644 index 00000000..1d585856 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/util/ObservableSortedKeyedArrayList.java @@ -0,0 +1,198 @@ +/* + * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.util; + +import androidx.annotation.Nullable; + +import com.wireguard.util.Keyed; +import com.wireguard.util.SortedKeyedList; + +import java.util.AbstractList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Set; +import java.util.Spliterator; + +/** + * KeyedArrayList that enforces uniqueness and sorted order across the set of keys. This class uses + * binary search to improve lookup and replacement times to O(log(n)). However, due to the + * array-based nature of this class, insertion and removal of elements with anything but the largest + * key still require O(n) time. + */ + +public class ObservableSortedKeyedArrayList<K, E extends Keyed<? extends K>> + extends ObservableKeyedArrayList<K, E> implements ObservableSortedKeyedList<K, E> { + @Nullable private final Comparator<? super K> comparator; + private final transient KeyList<K, E> keyList = new KeyList<>(this); + + @SuppressWarnings("WeakerAccess") + public ObservableSortedKeyedArrayList() { + comparator = null; + } + + public ObservableSortedKeyedArrayList(final Comparator<? super K> comparator) { + this.comparator = comparator; + } + + public ObservableSortedKeyedArrayList(final Collection<? extends E> c) { + this(); + addAll(c); + } + + public ObservableSortedKeyedArrayList(final SortedKeyedList<K, E> other) { + this(other.comparator()); + addAll(other); + } + + @Override + public boolean add(final E e) { + final int insertionPoint = getInsertionPoint(e); + if (insertionPoint < 0) { + // Skipping insertion is non-destructive if the new and existing objects are the same. + if (e == get(-insertionPoint - 1)) + return false; + throw new IllegalArgumentException("Element with same key already exists in list"); + } + super.add(insertionPoint, e); + return true; + } + + @Override + public void add(final int index, final E e) { + final int insertionPoint = getInsertionPoint(e); + if (insertionPoint < 0) + throw new IllegalArgumentException("Element with same key already exists in list"); + if (insertionPoint != index) + throw new IndexOutOfBoundsException("Wrong index given for element"); + super.add(index, e); + } + + @Override + public boolean addAll(final Collection<? extends E> c) { + boolean didChange = false; + for (final E e : c) + if (add(e)) + didChange = true; + return didChange; + } + + @Override + public boolean addAll(int index, final Collection<? extends E> c) { + for (final E e : c) + add(index++, e); + return true; + } + + @Nullable + @Override + public Comparator<? super K> comparator() { + return comparator; + } + + @Override + public K firstKey() { + if (isEmpty()) + // The parameter in the exception is only to shut + // lint up, we never care for the exception message. + throw new NoSuchElementException("Empty set"); + return get(0).getKey(); + } + + private int getInsertionPoint(final E e) { + if (comparator != null) { + return -Collections.binarySearch(keyList, e.getKey(), comparator) - 1; + } else { + @SuppressWarnings("unchecked") final List<Comparable<? super K>> list = + (List<Comparable<? super K>>) keyList; + return -Collections.binarySearch(list, e.getKey()) - 1; + } + } + + @Override + public int indexOfKey(final K key) { + final int index; + if (comparator != null) { + index = Collections.binarySearch(keyList, key, comparator); + } else { + @SuppressWarnings("unchecked") final List<Comparable<? super K>> list = + (List<Comparable<? super K>>) keyList; + index = Collections.binarySearch(list, key); + } + return index >= 0 ? index : -1; + } + + @Override + public Set<K> keySet() { + return keyList; + } + + @Override + public int lastIndexOfKey(final K key) { + // There can never be more than one element with the same key in the list. + return indexOfKey(key); + } + + @Override + public K lastKey() { + if (isEmpty()) + // The parameter in the exception is only to shut + // lint up, we never care for the exception message. + throw new NoSuchElementException("Empty set"); + return get(size() - 1).getKey(); + } + + @Override + public E set(final int index, final E e) { + final int order; + if (comparator != null) { + order = comparator.compare(e.getKey(), get(index).getKey()); + } else { + @SuppressWarnings("unchecked") final Comparable<? super K> key = + (Comparable<? super K>) e.getKey(); + order = key.compareTo(get(index).getKey()); + } + if (order != 0) { + // Allow replacement if the new key would be inserted adjacent to the replaced element. + final int insertionPoint = getInsertionPoint(e); + if (insertionPoint < index || insertionPoint > index + 1) + throw new IndexOutOfBoundsException("Wrong index given for element"); + } + return super.set(index, e); + } + + @Override + public Collection<E> values() { + return this; + } + + private static final class KeyList<K, E extends Keyed<? extends K>> + extends AbstractList<K> implements Set<K> { + private final ObservableSortedKeyedArrayList<K, E> list; + + private KeyList(final ObservableSortedKeyedArrayList<K, E> list) { + this.list = list; + } + + @Override + public K get(final int index) { + return list.get(index).getKey(); + } + + @Override + public int size() { + return list.size(); + } + + @Override + @SuppressWarnings("EmptyMethod") + public Spliterator<K> spliterator() { + return super.spliterator(); + } + } +} diff --git a/ui/src/main/java/com/wireguard/android/util/ObservableSortedKeyedList.java b/ui/src/main/java/com/wireguard/android/util/ObservableSortedKeyedList.java new file mode 100644 index 00000000..d796704e --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/util/ObservableSortedKeyedList.java @@ -0,0 +1,17 @@ +/* + * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.util; + +import com.wireguard.util.Keyed; +import com.wireguard.util.SortedKeyedList; + +/** + * A list that is both sorted/keyed and observable. + */ + +public interface ObservableSortedKeyedList<K, E extends Keyed<? extends K>> + extends ObservableKeyedList<K, E>, SortedKeyedList<K, E> { +} diff --git a/ui/src/main/java/com/wireguard/android/viewmodel/ConfigProxy.java b/ui/src/main/java/com/wireguard/android/viewmodel/ConfigProxy.java new file mode 100644 index 00000000..bcfe14e3 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/viewmodel/ConfigProxy.java @@ -0,0 +1,93 @@ +/* + * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.viewmodel; + +import androidx.databinding.ObservableArrayList; +import androidx.databinding.ObservableList; +import android.os.Parcel; +import android.os.Parcelable; + +import com.wireguard.config.BadConfigException; +import com.wireguard.config.Config; +import com.wireguard.config.Peer; + +import java.util.ArrayList; +import java.util.Collection; + +public class ConfigProxy implements Parcelable { + public static final Parcelable.Creator<ConfigProxy> CREATOR = new ConfigProxyCreator(); + + private final InterfaceProxy interfaze; + private final ObservableList<PeerProxy> peers = new ObservableArrayList<>(); + + private ConfigProxy(final Parcel in) { + interfaze = in.readParcelable(InterfaceProxy.class.getClassLoader()); + in.readTypedList(peers, PeerProxy.CREATOR); + for (final PeerProxy proxy : peers) + proxy.bind(this); + } + + public ConfigProxy(final Config other) { + interfaze = new InterfaceProxy(other.getInterface()); + for (final Peer peer : other.getPeers()) { + final PeerProxy proxy = new PeerProxy(peer); + peers.add(proxy); + proxy.bind(this); + } + } + + public ConfigProxy() { + interfaze = new InterfaceProxy(); + } + + public PeerProxy addPeer() { + final PeerProxy proxy = new PeerProxy(); + peers.add(proxy); + proxy.bind(this); + return proxy; + } + + @Override + public int describeContents() { + return 0; + } + + public InterfaceProxy getInterface() { + return interfaze; + } + + public ObservableList<PeerProxy> getPeers() { + return peers; + } + + public Config resolve() throws BadConfigException { + final Collection<Peer> resolvedPeers = new ArrayList<>(); + for (final PeerProxy proxy : peers) + resolvedPeers.add(proxy.resolve()); + return new Config.Builder() + .setInterface(interfaze.resolve()) + .addPeers(resolvedPeers) + .build(); + } + + @Override + public void writeToParcel(final Parcel dest, final int flags) { + dest.writeParcelable(interfaze, flags); + dest.writeTypedList(peers); + } + + private static class ConfigProxyCreator implements Parcelable.Creator<ConfigProxy> { + @Override + public ConfigProxy createFromParcel(final Parcel in) { + return new ConfigProxy(in); + } + + @Override + public ConfigProxy[] newArray(final int size) { + return new ConfigProxy[size]; + } + } +} diff --git a/ui/src/main/java/com/wireguard/android/viewmodel/InterfaceProxy.java b/ui/src/main/java/com/wireguard/android/viewmodel/InterfaceProxy.java new file mode 100644 index 00000000..cc9f2dd8 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/viewmodel/InterfaceProxy.java @@ -0,0 +1,190 @@ +/* + * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.viewmodel; + +import androidx.databinding.BaseObservable; +import androidx.databinding.Bindable; +import androidx.databinding.ObservableArrayList; +import androidx.databinding.ObservableList; +import android.os.Parcel; +import android.os.Parcelable; + +import com.wireguard.android.BR; +import com.wireguard.config.Attribute; +import com.wireguard.config.BadConfigException; +import com.wireguard.config.Interface; +import com.wireguard.crypto.Key; +import com.wireguard.crypto.KeyFormatException; +import com.wireguard.crypto.KeyPair; + +import java.net.InetAddress; +import java.util.List; + +import java9.util.stream.Collectors; +import java9.util.stream.StreamSupport; + +public class InterfaceProxy extends BaseObservable implements Parcelable { + public static final Parcelable.Creator<InterfaceProxy> CREATOR = new InterfaceProxyCreator(); + + private final ObservableList<String> excludedApplications = new ObservableArrayList<>(); + private String addresses; + private String dnsServers; + private String listenPort; + private String mtu; + private String privateKey; + private String publicKey; + + private InterfaceProxy(final Parcel in) { + addresses = in.readString(); + dnsServers = in.readString(); + in.readStringList(excludedApplications); + listenPort = in.readString(); + mtu = in.readString(); + privateKey = in.readString(); + publicKey = in.readString(); + } + + public InterfaceProxy(final Interface other) { + addresses = Attribute.join(other.getAddresses()); + final List<String> dnsServerStrings = StreamSupport.stream(other.getDnsServers()) + .map(InetAddress::getHostAddress) + .collect(Collectors.toUnmodifiableList()); + dnsServers = Attribute.join(dnsServerStrings); + excludedApplications.addAll(other.getExcludedApplications()); + listenPort = other.getListenPort().map(String::valueOf).orElse(""); + mtu = other.getMtu().map(String::valueOf).orElse(""); + final KeyPair keyPair = other.getKeyPair(); + privateKey = keyPair.getPrivateKey().toBase64(); + publicKey = keyPair.getPublicKey().toBase64(); + } + + public InterfaceProxy() { + addresses = ""; + dnsServers = ""; + listenPort = ""; + mtu = ""; + privateKey = ""; + publicKey = ""; + } + + @Override + public int describeContents() { + return 0; + } + + public void generateKeyPair() { + final KeyPair keyPair = new KeyPair(); + privateKey = keyPair.getPrivateKey().toBase64(); + publicKey = keyPair.getPublicKey().toBase64(); + notifyPropertyChanged(BR.privateKey); + notifyPropertyChanged(BR.publicKey); + } + + @Bindable + public String getAddresses() { + return addresses; + } + + @Bindable + public String getDnsServers() { + return dnsServers; + } + + public ObservableList<String> getExcludedApplications() { + return excludedApplications; + } + + @Bindable + public String getListenPort() { + return listenPort; + } + + @Bindable + public String getMtu() { + return mtu; + } + + @Bindable + public String getPrivateKey() { + return privateKey; + } + + @Bindable + public String getPublicKey() { + return publicKey; + } + + public Interface resolve() throws BadConfigException { + final Interface.Builder builder = new Interface.Builder(); + if (!addresses.isEmpty()) + builder.parseAddresses(addresses); + if (!dnsServers.isEmpty()) + builder.parseDnsServers(dnsServers); + if (!excludedApplications.isEmpty()) + builder.excludeApplications(excludedApplications); + if (!listenPort.isEmpty()) + builder.parseListenPort(listenPort); + if (!mtu.isEmpty()) + builder.parseMtu(mtu); + if (!privateKey.isEmpty()) + builder.parsePrivateKey(privateKey); + return builder.build(); + } + + public void setAddresses(final String addresses) { + this.addresses = addresses; + notifyPropertyChanged(BR.addresses); + } + + public void setDnsServers(final String dnsServers) { + this.dnsServers = dnsServers; + notifyPropertyChanged(BR.dnsServers); + } + + public void setListenPort(final String listenPort) { + this.listenPort = listenPort; + notifyPropertyChanged(BR.listenPort); + } + + public void setMtu(final String mtu) { + this.mtu = mtu; + notifyPropertyChanged(BR.mtu); + } + + public void setPrivateKey(final String privateKey) { + this.privateKey = privateKey; + try { + publicKey = new KeyPair(Key.fromBase64(privateKey)).getPublicKey().toBase64(); + } catch (final KeyFormatException ignored) { + publicKey = ""; + } + notifyPropertyChanged(BR.privateKey); + notifyPropertyChanged(BR.publicKey); + } + + @Override + public void writeToParcel(final Parcel dest, final int flags) { + dest.writeString(addresses); + dest.writeString(dnsServers); + dest.writeStringList(excludedApplications); + dest.writeString(listenPort); + dest.writeString(mtu); + dest.writeString(privateKey); + dest.writeString(publicKey); + } + + private static class InterfaceProxyCreator implements Parcelable.Creator<InterfaceProxy> { + @Override + public InterfaceProxy createFromParcel(final Parcel in) { + return new InterfaceProxy(in); + } + + @Override + public InterfaceProxy[] newArray(final int size) { + return new InterfaceProxy[size]; + } + } +} diff --git a/ui/src/main/java/com/wireguard/android/viewmodel/PeerProxy.java b/ui/src/main/java/com/wireguard/android/viewmodel/PeerProxy.java new file mode 100644 index 00000000..7dc50f09 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/viewmodel/PeerProxy.java @@ -0,0 +1,380 @@ +/* + * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.viewmodel; + +import androidx.databinding.BaseObservable; +import androidx.databinding.Bindable; +import androidx.databinding.Observable; +import androidx.databinding.ObservableList; +import android.os.Parcel; +import android.os.Parcelable; +import androidx.annotation.Nullable; + +import com.wireguard.android.BR; +import com.wireguard.config.Attribute; +import com.wireguard.config.BadConfigException; +import com.wireguard.config.InetEndpoint; +import com.wireguard.config.Peer; +import com.wireguard.crypto.Key; + +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import java9.util.Lists; +import java9.util.Sets; +import java9.util.stream.Collectors; +import java9.util.stream.Stream; + +public class PeerProxy extends BaseObservable implements Parcelable { + public static final Parcelable.Creator<PeerProxy> CREATOR = new PeerProxyCreator(); + private static final Set<String> IPV4_PUBLIC_NETWORKS = new LinkedHashSet<>(Lists.of( + "0.0.0.0/5", "8.0.0.0/7", "11.0.0.0/8", "12.0.0.0/6", "16.0.0.0/4", "32.0.0.0/3", + "64.0.0.0/2", "128.0.0.0/3", "160.0.0.0/5", "168.0.0.0/6", "172.0.0.0/12", + "172.32.0.0/11", "172.64.0.0/10", "172.128.0.0/9", "173.0.0.0/8", "174.0.0.0/7", + "176.0.0.0/4", "192.0.0.0/9", "192.128.0.0/11", "192.160.0.0/13", "192.169.0.0/16", + "192.170.0.0/15", "192.172.0.0/14", "192.176.0.0/12", "192.192.0.0/10", + "193.0.0.0/8", "194.0.0.0/7", "196.0.0.0/6", "200.0.0.0/5", "208.0.0.0/4" + )); + private static final Set<String> IPV4_WILDCARD = Sets.of("0.0.0.0/0"); + + private final List<String> dnsRoutes = new ArrayList<>(); + private String allowedIps; + private AllowedIpsState allowedIpsState = AllowedIpsState.INVALID; + private String endpoint; + @Nullable private InterfaceDnsListener interfaceDnsListener; + @Nullable private ConfigProxy owner; + @Nullable private PeerListListener peerListListener; + private String persistentKeepalive; + private String preSharedKey; + private String publicKey; + private int totalPeers; + + private PeerProxy(final Parcel in) { + allowedIps = in.readString(); + endpoint = in.readString(); + persistentKeepalive = in.readString(); + preSharedKey = in.readString(); + publicKey = in.readString(); + } + + public PeerProxy(final Peer other) { + allowedIps = Attribute.join(other.getAllowedIps()); + endpoint = other.getEndpoint().map(InetEndpoint::toString).orElse(""); + persistentKeepalive = other.getPersistentKeepalive().map(String::valueOf).orElse(""); + preSharedKey = other.getPreSharedKey().map(Key::toBase64).orElse(""); + publicKey = other.getPublicKey().toBase64(); + } + + public PeerProxy() { + allowedIps = ""; + endpoint = ""; + persistentKeepalive = ""; + preSharedKey = ""; + publicKey = ""; + } + + public void bind(final ConfigProxy owner) { + final InterfaceProxy interfaze = owner.getInterface(); + final ObservableList<PeerProxy> peers = owner.getPeers(); + if (interfaceDnsListener == null) + interfaceDnsListener = new InterfaceDnsListener(this); + interfaze.addOnPropertyChangedCallback(interfaceDnsListener); + setInterfaceDns(interfaze.getDnsServers()); + if (peerListListener == null) + peerListListener = new PeerListListener(this); + peers.addOnListChangedCallback(peerListListener); + setTotalPeers(peers.size()); + this.owner = owner; + } + + private void calculateAllowedIpsState() { + final AllowedIpsState newState; + if (totalPeers == 1) { + // String comparison works because we only care if allowedIps is a superset of one of + // the above sets of (valid) *networks*. We are not checking for a superset based on + // the individual addresses in each set. + final Collection<String> networkStrings = getAllowedIpsSet(); + // If allowedIps contains both the wildcard and the public networks, then private + // networks aren't excluded! + if (networkStrings.containsAll(IPV4_WILDCARD)) + newState = AllowedIpsState.CONTAINS_IPV4_WILDCARD; + else if (networkStrings.containsAll(IPV4_PUBLIC_NETWORKS)) + newState = AllowedIpsState.CONTAINS_IPV4_PUBLIC_NETWORKS; + else + newState = AllowedIpsState.OTHER; + } else { + newState = AllowedIpsState.INVALID; + } + if (newState != allowedIpsState) { + allowedIpsState = newState; + notifyPropertyChanged(BR.ableToExcludePrivateIps); + notifyPropertyChanged(BR.excludingPrivateIps); + } + } + + @Override + public int describeContents() { + return 0; + } + + @Bindable + public String getAllowedIps() { + return allowedIps; + } + + private Set<String> getAllowedIpsSet() { + return new LinkedHashSet<>(Lists.of(Attribute.split(allowedIps))); + } + + @Bindable + public String getEndpoint() { + return endpoint; + } + + @Bindable + public String getPersistentKeepalive() { + return persistentKeepalive; + } + + @Bindable + public String getPreSharedKey() { + return preSharedKey; + } + + @Bindable + public String getPublicKey() { + return publicKey; + } + + @Bindable + public boolean isAbleToExcludePrivateIps() { + return allowedIpsState == AllowedIpsState.CONTAINS_IPV4_PUBLIC_NETWORKS + || allowedIpsState == AllowedIpsState.CONTAINS_IPV4_WILDCARD; + } + + @Bindable + public boolean isExcludingPrivateIps() { + return allowedIpsState == AllowedIpsState.CONTAINS_IPV4_PUBLIC_NETWORKS; + } + + public Peer resolve() throws BadConfigException { + final Peer.Builder builder = new Peer.Builder(); + if (!allowedIps.isEmpty()) + builder.parseAllowedIPs(allowedIps); + if (!endpoint.isEmpty()) + builder.parseEndpoint(endpoint); + if (!persistentKeepalive.isEmpty()) + builder.parsePersistentKeepalive(persistentKeepalive); + if (!preSharedKey.isEmpty()) + builder.parsePreSharedKey(preSharedKey); + if (!publicKey.isEmpty()) + builder.parsePublicKey(publicKey); + return builder.build(); + } + + public void setAllowedIps(final String allowedIps) { + this.allowedIps = allowedIps; + notifyPropertyChanged(BR.allowedIps); + calculateAllowedIpsState(); + } + + public void setEndpoint(final String endpoint) { + this.endpoint = endpoint; + notifyPropertyChanged(BR.endpoint); + } + + public void setExcludingPrivateIps(final boolean excludingPrivateIps) { + if (!isAbleToExcludePrivateIps() || isExcludingPrivateIps() == excludingPrivateIps) + return; + final Set<String> oldNetworks = excludingPrivateIps ? IPV4_WILDCARD : IPV4_PUBLIC_NETWORKS; + final Set<String> newNetworks = excludingPrivateIps ? IPV4_PUBLIC_NETWORKS : IPV4_WILDCARD; + final Collection<String> input = getAllowedIpsSet(); + final int outputSize = input.size() - oldNetworks.size() + newNetworks.size(); + final Collection<String> output = new LinkedHashSet<>(outputSize); + boolean replaced = false; + // Replace the first instance of the wildcard with the public network list, or vice versa. + for (final String network : input) { + if (oldNetworks.contains(network)) { + if (!replaced) { + for (final String replacement : newNetworks) + if (!output.contains(replacement)) + output.add(replacement); + replaced = true; + } + } else if (!output.contains(network)) { + output.add(network); + } + } + // DNS servers only need to handled specially when we're excluding private IPs. + if (excludingPrivateIps) + output.addAll(dnsRoutes); + else + output.removeAll(dnsRoutes); + allowedIps = Attribute.join(output); + allowedIpsState = excludingPrivateIps ? + AllowedIpsState.CONTAINS_IPV4_PUBLIC_NETWORKS : AllowedIpsState.CONTAINS_IPV4_WILDCARD; + notifyPropertyChanged(BR.allowedIps); + notifyPropertyChanged(BR.excludingPrivateIps); + } + + private void setInterfaceDns(final CharSequence dnsServers) { + final List<String> newDnsRoutes = Stream.of(Attribute.split(dnsServers)) + .filter(server -> !server.contains(":")) + .map(server -> server + "/32") + .collect(Collectors.toUnmodifiableList()); + if (allowedIpsState == AllowedIpsState.CONTAINS_IPV4_PUBLIC_NETWORKS) { + final Collection<String> input = getAllowedIpsSet(); + final Collection<String> output = new LinkedHashSet<>(input.size() + 1); + // Yes, this is quadratic in the number of DNS servers, but most users have 1 or 2. + for (final String network : input) + if (!dnsRoutes.contains(network) || newDnsRoutes.contains(network)) + output.add(network); + // Since output is a Set, this does the Right Thing™ (it does not duplicate networks). + output.addAll(newDnsRoutes); + // None of the public networks are /32s, so this cannot change the AllowedIPs state. + allowedIps = Attribute.join(output); + notifyPropertyChanged(BR.allowedIps); + } + dnsRoutes.clear(); + dnsRoutes.addAll(newDnsRoutes); + } + + public void setPersistentKeepalive(final String persistentKeepalive) { + this.persistentKeepalive = persistentKeepalive; + notifyPropertyChanged(BR.persistentKeepalive); + } + + public void setPreSharedKey(final String preSharedKey) { + this.preSharedKey = preSharedKey; + notifyPropertyChanged(BR.preSharedKey); + } + + public void setPublicKey(final String publicKey) { + this.publicKey = publicKey; + notifyPropertyChanged(BR.publicKey); + } + + private void setTotalPeers(final int totalPeers) { + if (this.totalPeers == totalPeers) + return; + this.totalPeers = totalPeers; + calculateAllowedIpsState(); + } + + public void unbind() { + if (owner == null) + return; + final InterfaceProxy interfaze = owner.getInterface(); + final ObservableList<PeerProxy> peers = owner.getPeers(); + if (interfaceDnsListener != null) + interfaze.removeOnPropertyChangedCallback(interfaceDnsListener); + if (peerListListener != null) + peers.removeOnListChangedCallback(peerListListener); + peers.remove(this); + setInterfaceDns(""); + setTotalPeers(0); + owner = null; + } + + @Override + public void writeToParcel(final Parcel dest, final int flags) { + dest.writeString(allowedIps); + dest.writeString(endpoint); + dest.writeString(persistentKeepalive); + dest.writeString(preSharedKey); + dest.writeString(publicKey); + } + + private enum AllowedIpsState { + CONTAINS_IPV4_PUBLIC_NETWORKS, + CONTAINS_IPV4_WILDCARD, + INVALID, + OTHER + } + + private static final class InterfaceDnsListener extends Observable.OnPropertyChangedCallback { + private final WeakReference<PeerProxy> weakPeerProxy; + + private InterfaceDnsListener(final PeerProxy peerProxy) { + weakPeerProxy = new WeakReference<>(peerProxy); + } + + @Override + public void onPropertyChanged(final Observable sender, final int propertyId) { + @Nullable final PeerProxy peerProxy = weakPeerProxy.get(); + if (peerProxy == null) { + sender.removeOnPropertyChangedCallback(this); + return; + } + // This shouldn't be possible, but try to avoid a ClassCastException anyway. + if (!(sender instanceof InterfaceProxy)) + return; + if (!(propertyId == BR._all || propertyId == BR.dnsServers)) + return; + peerProxy.setInterfaceDns(((InterfaceProxy) sender).getDnsServers()); + } + } + + private static final class PeerListListener + extends ObservableList.OnListChangedCallback<ObservableList<PeerProxy>> { + private final WeakReference<PeerProxy> weakPeerProxy; + + private PeerListListener(final PeerProxy peerProxy) { + weakPeerProxy = new WeakReference<>(peerProxy); + } + + @Override + public void onChanged(final ObservableList<PeerProxy> sender) { + @Nullable final PeerProxy peerProxy = weakPeerProxy.get(); + if (peerProxy == null) { + sender.removeOnListChangedCallback(this); + return; + } + peerProxy.setTotalPeers(sender.size()); + } + + @Override + public void onItemRangeChanged(final ObservableList<PeerProxy> sender, + final int positionStart, final int itemCount) { + // Do nothing. + } + + @Override + public void onItemRangeInserted(final ObservableList<PeerProxy> sender, + final int positionStart, final int itemCount) { + onChanged(sender); + } + + @Override + public void onItemRangeMoved(final ObservableList<PeerProxy> sender, + final int fromPosition, final int toPosition, + final int itemCount) { + // Do nothing. + } + + @Override + public void onItemRangeRemoved(final ObservableList<PeerProxy> sender, + final int positionStart, final int itemCount) { + onChanged(sender); + } + } + + private static class PeerProxyCreator implements Parcelable.Creator<PeerProxy> { + @Override + public PeerProxy createFromParcel(final Parcel in) { + return new PeerProxy(in); + } + + @Override + public PeerProxy[] newArray(final int size) { + return new PeerProxy[size]; + } + } +} diff --git a/ui/src/main/java/com/wireguard/android/widget/KeyInputFilter.java b/ui/src/main/java/com/wireguard/android/widget/KeyInputFilter.java new file mode 100644 index 00000000..79572aa3 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/widget/KeyInputFilter.java @@ -0,0 +1,54 @@ +/* + * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.widget; + +import androidx.annotation.Nullable; +import android.text.InputFilter; +import android.text.SpannableStringBuilder; +import android.text.Spanned; + +import com.wireguard.crypto.Key; + +/** + * InputFilter for entering WireGuard private/public keys encoded with base64. + */ + +public class KeyInputFilter implements InputFilter { + private static boolean isAllowed(final char c) { + return Character.isLetterOrDigit(c) || c == '+' || c == '/'; + } + + public static InputFilter newInstance() { + return new KeyInputFilter(); + } + + @Nullable + @Override + public CharSequence filter(final CharSequence source, + final int sStart, final int sEnd, + final Spanned dest, + final int dStart, final int dEnd) { + SpannableStringBuilder replacement = null; + int rIndex = 0; + final int dLength = dest.length(); + for (int sIndex = sStart; sIndex < sEnd; ++sIndex) { + final char c = source.charAt(sIndex); + final int dIndex = dStart + (sIndex - sStart); + // Restrict characters to the base64 character set. + // Ensure adding this character does not push the length over the limit. + if (((dIndex + 1 < Key.Format.BASE64.getLength() && isAllowed(c)) || + (dIndex + 1 == Key.Format.BASE64.getLength() && c == '=')) && + dLength + (sIndex - sStart) < Key.Format.BASE64.getLength()) { + ++rIndex; + } else { + if (replacement == null) + replacement = new SpannableStringBuilder(source, sStart, sEnd); + replacement.delete(rIndex, rIndex + 1); + } + } + return replacement; + } +} diff --git a/ui/src/main/java/com/wireguard/android/widget/MultiselectableRelativeLayout.java b/ui/src/main/java/com/wireguard/android/widget/MultiselectableRelativeLayout.java new file mode 100644 index 00000000..2fe9c924 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/widget/MultiselectableRelativeLayout.java @@ -0,0 +1,59 @@ +/* + * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.widget; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.RelativeLayout; + +import com.wireguard.android.R; + +public class MultiselectableRelativeLayout extends RelativeLayout { + private static final int[] STATE_MULTISELECTED = {R.attr.state_multiselected}; + private boolean multiselected; + + public MultiselectableRelativeLayout(final Context context) { + super(context); + } + + public MultiselectableRelativeLayout(final Context context, final AttributeSet attrs) { + super(context, attrs); + } + + public MultiselectableRelativeLayout(final Context context, final AttributeSet attrs, final int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public MultiselectableRelativeLayout(final Context context, final AttributeSet attrs, final int defStyleAttr, final int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + @Override + protected int[] onCreateDrawableState(final int extraSpace) { + if (multiselected) { + final int[] drawableState = super.onCreateDrawableState(extraSpace + 1); + mergeDrawableStates(drawableState, STATE_MULTISELECTED); + return drawableState; + } + return super.onCreateDrawableState(extraSpace); + } + + public void setMultiSelected(final boolean on) { + if (!multiselected) { + multiselected = true; + refreshDrawableState(); + } + setActivated(on); + } + + public void setSingleSelected(final boolean on) { + if (multiselected) { + multiselected = false; + refreshDrawableState(); + } + setActivated(on); + } +} diff --git a/ui/src/main/java/com/wireguard/android/widget/NameInputFilter.java b/ui/src/main/java/com/wireguard/android/widget/NameInputFilter.java new file mode 100644 index 00000000..030be25a --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/widget/NameInputFilter.java @@ -0,0 +1,53 @@ +/* + * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.widget; + +import androidx.annotation.Nullable; +import android.text.InputFilter; +import android.text.SpannableStringBuilder; +import android.text.Spanned; + +import com.wireguard.android.backend.Tunnel; + +/** + * InputFilter for entering WireGuard configuration names (Linux interface names). + */ + +public class NameInputFilter implements InputFilter { + private static boolean isAllowed(final char c) { + return Character.isLetterOrDigit(c) || "_=+.-".indexOf(c) >= 0; + } + + public static InputFilter newInstance() { + return new NameInputFilter(); + } + + @Nullable + @Override + public CharSequence filter(final CharSequence source, + final int sStart, final int sEnd, + final Spanned dest, + final int dStart, final int dEnd) { + SpannableStringBuilder replacement = null; + int rIndex = 0; + final int dLength = dest.length(); + for (int sIndex = sStart; sIndex < sEnd; ++sIndex) { + final char c = source.charAt(sIndex); + final int dIndex = dStart + (sIndex - sStart); + // Restrict characters to those valid in interfaces. + // Ensure adding this character does not push the length over the limit. + if ((dIndex < Tunnel.NAME_MAX_LENGTH && isAllowed(c)) && + dLength + (sIndex - sStart) < Tunnel.NAME_MAX_LENGTH) { + ++rIndex; + } else { + if (replacement == null) + replacement = new SpannableStringBuilder(source, sStart, sEnd); + replacement.delete(rIndex, rIndex + 1); + } + } + return replacement; + } +} diff --git a/ui/src/main/java/com/wireguard/android/widget/SlashDrawable.java b/ui/src/main/java/com/wireguard/android/widget/SlashDrawable.java new file mode 100644 index 00000000..e020aa81 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/widget/SlashDrawable.java @@ -0,0 +1,217 @@ +/* + * Copyright © 2018 The Android Open Source Project + * Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.widget; + +import android.animation.ObjectAnimator; +import android.animation.ValueAnimator; +import android.content.res.ColorStateList; +import android.graphics.Canvas; +import android.graphics.ColorFilter; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.Path.Direction; +import android.graphics.PixelFormat; +import android.graphics.PorterDuff.Mode; +import android.graphics.Rect; +import android.graphics.RectF; +import android.graphics.Region; +import android.graphics.drawable.Drawable; +import android.os.Build; +import androidx.annotation.ColorInt; +import androidx.annotation.IntRange; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import android.util.FloatProperty; + +@RequiresApi(Build.VERSION_CODES.N) +public class SlashDrawable extends Drawable { + + private static final float CENTER_X = 10.65f; + private static final float CENTER_Y = 11.869239f; + private static final float CORNER_RADIUS = Build.VERSION.SDK_INT < Build.VERSION_CODES.O ? 0f : 1f; + // Draw the slash washington-monument style; rotate to no-u-turn style + private static final float DEFAULT_ROTATION = -45f; + private static final long QS_ANIM_LENGTH = 350; + private static final float SCALE = 24f; + private static final float SLASH_HEIGHT = 28f; + // These values are derived in un-rotated (vertical) orientation + private static final float SLASH_WIDTH = 1.8384776f; + // Bottom is derived during animation + private static final float LEFT = (CENTER_X - (SLASH_WIDTH / 2)) / SCALE; + private static final float RIGHT = (CENTER_X + (SLASH_WIDTH / 2)) / SCALE; + private static final float TOP = (CENTER_Y - (SLASH_HEIGHT / 2)) / SCALE; + private static final FloatProperty mSlashLengthProp = new FloatProperty<SlashDrawable>("slashLength") { + @Override + public Float get(final SlashDrawable object) { + return object.mCurrentSlashLength; + } + + @Override + public void setValue(final SlashDrawable object, final float value) { + object.mCurrentSlashLength = value; + } + }; + private final Drawable mDrawable; + private final Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Path mPath = new Path(); + private final RectF mSlashRect = new RectF(0, 0, 0, 0); + private boolean mAnimationEnabled = true; + // Animate this value on change + private float mCurrentSlashLength; + private float mRotation; + private boolean mSlashed; + + public SlashDrawable(final Drawable d) { + mDrawable = d; + } + + @SuppressWarnings("deprecation") + @Override + public void draw(final Canvas canvas) { + canvas.save(); + final Matrix m = new Matrix(); + final int width = getBounds().width(); + final int height = getBounds().height(); + final float radiusX = scale(CORNER_RADIUS, width); + final float radiusY = scale(CORNER_RADIUS, height); + updateRect( + scale(LEFT, width), + scale(TOP, height), + scale(RIGHT, width), + scale(TOP + mCurrentSlashLength, height) + ); + + mPath.reset(); + // Draw the slash vertically + mPath.addRoundRect(mSlashRect, radiusX, radiusY, Direction.CW); + // Rotate -45 + desired rotation + m.setRotate(mRotation + DEFAULT_ROTATION, width / 2, height / 2); + mPath.transform(m); + canvas.drawPath(mPath, mPaint); + + // Rotate back to vertical + m.setRotate(-mRotation - DEFAULT_ROTATION, width / 2, height / 2); + mPath.transform(m); + + // Draw another rect right next to the first, for clipping + m.setTranslate(mSlashRect.width(), 0); + mPath.transform(m); + mPath.addRoundRect(mSlashRect, 1.0f * width, 1.0f * height, Direction.CW); + m.setRotate(mRotation + DEFAULT_ROTATION, width / 2, height / 2); + mPath.transform(m); + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) + canvas.clipPath(mPath, Region.Op.DIFFERENCE); + else + canvas.clipOutPath(mPath); + + mDrawable.draw(canvas); + canvas.restore(); + } + + @Override + public int getIntrinsicHeight() { + return mDrawable.getIntrinsicHeight(); + } + + @Override + public int getIntrinsicWidth() { + return mDrawable.getIntrinsicWidth(); + } + + @SuppressWarnings("deprecation") + @Override + public int getOpacity() { + return PixelFormat.OPAQUE; + } + + @Override + protected void onBoundsChange(final Rect bounds) { + super.onBoundsChange(bounds); + mDrawable.setBounds(bounds); + } + + private float scale(final float frac, final int width) { + return frac * width; + } + + @Override + public void setAlpha(@IntRange(from = 0, to = 255) final int alpha) { + mDrawable.setAlpha(alpha); + mPaint.setAlpha(alpha); + } + + public void setAnimationEnabled(final boolean enabled) { + mAnimationEnabled = enabled; + } + + @Override + public void setColorFilter(@Nullable final ColorFilter colorFilter) { + mDrawable.setColorFilter(colorFilter); + mPaint.setColorFilter(colorFilter); + } + + private void setDrawableTintList(@Nullable final ColorStateList tint) { + mDrawable.setTintList(tint); + } + + public void setRotation(final float rotation) { + if (mRotation == rotation) + return; + mRotation = rotation; + invalidateSelf(); + } + + @SuppressWarnings("unchecked") + public void setSlashed(final boolean slashed) { + if (mSlashed == slashed) return; + + mSlashed = slashed; + + final float end = mSlashed ? SLASH_HEIGHT / SCALE : 0f; + final float start = mSlashed ? 0f : SLASH_HEIGHT / SCALE; + + if (mAnimationEnabled) { + final ObjectAnimator anim = ObjectAnimator.ofFloat(this, mSlashLengthProp, start, end); + anim.addUpdateListener((ValueAnimator valueAnimator) -> invalidateSelf()); + anim.setDuration(QS_ANIM_LENGTH); + anim.start(); + } else { + mCurrentSlashLength = end; + invalidateSelf(); + } + } + + @Override + public void setTint(@ColorInt final int tintColor) { + super.setTint(tintColor); + mDrawable.setTint(tintColor); + mPaint.setColor(tintColor); + } + + @Override + public void setTintList(@Nullable final ColorStateList tint) { + super.setTintList(tint); + setDrawableTintList(tint); + mPaint.setColor(tint == null ? 0 : tint.getDefaultColor()); + invalidateSelf(); + } + + @Override + public void setTintMode(final Mode tintMode) { + super.setTintMode(tintMode); + mDrawable.setTintMode(tintMode); + } + + private void updateRect(final float left, final float top, final float right, final float bottom) { + mSlashRect.left = left; + mSlashRect.top = top; + mSlashRect.right = right; + mSlashRect.bottom = bottom; + } +} diff --git a/ui/src/main/java/com/wireguard/android/widget/ToggleSwitch.java b/ui/src/main/java/com/wireguard/android/widget/ToggleSwitch.java new file mode 100644 index 00000000..dcb9aceb --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/widget/ToggleSwitch.java @@ -0,0 +1,59 @@ +/* + * Copyright © 2013 The Android Open Source Project + * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.widget; + +import android.content.Context; +import android.os.Parcelable; +import androidx.annotation.Nullable; +import android.util.AttributeSet; +import android.widget.Switch; + +public class ToggleSwitch extends Switch { + private boolean isRestoringState; + @Nullable private OnBeforeCheckedChangeListener listener; + + public ToggleSwitch(final Context context) { + this(context, null); + } + + @SuppressWarnings({"SameParameterValue", "WeakerAccess"}) + public ToggleSwitch(final Context context, @Nullable final AttributeSet attrs) { + super(context, attrs); + } + + @Override + public void onRestoreInstanceState(final Parcelable state) { + isRestoringState = true; + super.onRestoreInstanceState(state); + isRestoringState = false; + } + + @Override + public void setChecked(final boolean checked) { + if (checked == isChecked()) + return; + if (isRestoringState || listener == null) { + super.setChecked(checked); + return; + } + setEnabled(false); + listener.onBeforeCheckedChanged(this, checked); + } + + public void setCheckedInternal(final boolean checked) { + super.setChecked(checked); + setEnabled(true); + } + + public void setOnBeforeCheckedChangeListener(final OnBeforeCheckedChangeListener listener) { + this.listener = listener; + } + + public interface OnBeforeCheckedChangeListener { + void onBeforeCheckedChanged(ToggleSwitch toggleSwitch, boolean checked); + } +} |