diff --git a/cmd/skywirevisormobile/android/README.md b/cmd/skywirevisormobile/android/README.md
new file mode 100644
index 0000000000..59bacc615a
--- /dev/null
+++ b/cmd/skywirevisormobile/android/README.md
@@ -0,0 +1,66 @@
+# Skywire VPN draft (Android)
+
+## Prerequisites
+This project uses Skywire mobile library (`skywiremob`) to use all the needed Skywire infrastructure. In order to build
+and use this library, one needs to install `gomobile` (https://github.com/golang/mobile).
+
+## Building and Running
+To build the library you need to use `gomobile`. Main `Makefile` already contains target `build-android` which
+can be used.
+IMPORTANT: regardless of go modules and other great stuff done by the Go team, in order to
+use `gomobile` you need to put Skywire code according to `GOPATH`. Otherwise you'll get all kinds of errors.
+The output file (`.aar` for Android) may be used straight from the mobile app code.
+
+## Skywire Mobile API
+- `PrintString(string)`: Logs string argument using info log level. May be useful to use it instead of standard logging features of mobile apps for debugging.
+All the output strings are prefixed with `GoLog`, so printing logs with this func one may grep all the logs both from `skywiremob`
+internal and mobile application;
+- `IsPKValid(string) string`: Checks if passed pub key is valid. Returns non-empty string with error in case of failure;
+- `GetMTU() int`: Gets VPN connection MTU;
+- `GetTUNIPPrefix() int`: Gets netmask prefix of TUN IP address;
+- `IsVPNReady() bool`: Checks whether VPN client is ready on the Go side. Once it is, the mobile application is free to start
+forwarding packets. Starts returning `true` after the `ServerVPN` call;
+- `PrepareVisor() string`: Creates and runs visor instance. Returns non-empty string with error in case of failure;
+- `NextDmsgSocket() int`: Returns file descriptor of the Dmsg socket. There may be more than one socket in use by the dmsg client, so
+this function should be called repeatedly until next call returns 0.
+- `PrepareVPNClient(string, string) string`: Creates VPN client instance. First string argument is remote VPN server pub key, second one is passcode to
+authenticate within the server. Returns non-empty string with error in case of failure;
+- `ShakeHands() string`: Requires `PrepareVPNClient` to be called first. Performs handshake between the client and the server.
+Returns non-empty string with error in case of failure;
+- `TUNIP() string`: Requires `ShakeHands` to be called first. Returns the assigned TUN IP;
+- `TUNGateway() string`: Requires `ShakeHands` to be called first. Returns the assigned TUN gateway;
+- `StopVisor() string`: Stops currently running visor. Returns non-empty string with error in case of failure;
+- `SetMobileAppAddr(string)`: Passes address of the UDP connection opened on the mobile application side;
+- `ServeVPN()`: Starts off the goroutine serving VPN connection. After this call `IsVPNReady` starts returning `true`;
+- `StartListeningUDP() string`: Opens UDP listener on the Go side. Returns non-empty string with error in case of failure;
+- `IsVisorStarting() bool`: Checks if visor is starting. Will get `false` when it's fully functional;
+- `IsVisorRunning() bool`: Checks if visor is running. Will get `true` whn visor is fully functional;
+- `WaitVisorReady() string`: Blocks until visor gets fully initialized. Returns non-empty error string in case of failure;
+- `StopVPNClient`: Stops VPN client without stopping visor itself;
+- `StopListeningUDP`: Closes UDP socket;
+- `VPNBandwidthSent`: Returns amount of bandwidth sent over VPN (bytes);
+- `VPNBandwidthReceived`: Returns amount of bandwidth received over VPN (bytes);
+- `VPNLatency`: Returns latency (ms);
+- `VPNThroughput`: Returns throughput (bytes/s).
+
+
+## Mobile/Go Communication
+API may seem a bit complicated at first. Currently tested for Android devices, should be used with caution on iOS.
+Mobile app communicates with the Go part via UDP. All the packets are sent to the Go part via UDP and then get resent
+to the Skywire network.
+
+To setup the Go side properly you need to call at least:
+- `PrepareVisor` to run the visor;
+- `PrepareVPNClient` to run the VPN client;
+- `ShakeHands` to perform handshake with the server;
+- `StartListeningUDP` to open the UDP listener on the Go side;
+- `ServeVPN` to start forwarding traffic.
+
+All other calls should be done as needed.
+
+### Android
+Consult this page: https://developer.android.com/guide/topics/connectivity/vpn
+
+In the example mobile app communicates with the remote server via `DatagramChannel`. Socket opened to the server gets protected
+with the `protect` method. We do the same here. But instead of a remote server we open the `DatagramChannel` to the Go part of the app.
+We protect not only the tunnel socket, but also we need to protect all the sockets used for `Dmsg` communication to let traffic go back and forth freely.
diff --git a/cmd/skywirevisormobile/android/app/build.gradle b/cmd/skywirevisormobile/android/app/build.gradle
new file mode 100644
index 0000000000..debee173bc
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/build.gradle
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2015 The Go Authors. All rights reserved.
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file.
+ */
+apply plugin: 'com.android.application'
+
+repositories {
+ flatDir {
+ dirs '.'
+ }
+ maven { url 'https://jitpack.io' }
+}
+
+android {
+ compileSdkVersion 29
+
+ defaultConfig {
+ applicationId "com.skywire.skycoin.vpn"
+ minSdkVersion 21
+ targetSdkVersion 29
+ versionCode 1
+ versionName "1.0"
+ }
+ buildTypes {
+ release {
+ minifyEnabled false
+ }
+ }
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+}
+
+
+dependencies {
+ // Appcompat.
+ implementation "androidx.appcompat:appcompat:1.2.0"
+ implementation 'com.google.android.material:material:1.2.1'
+ implementation "androidx.preference:preference:1.1.1"
+ implementation "androidx.recyclerview:recyclerview:1.1.0"
+ implementation "androidx.viewpager2:viewpager2:1.0.0"
+
+ // Skywire lib.
+ implementation(name:'skywire', ext:'aar')
+
+ // RxJava.
+ implementation 'io.reactivex.rxjava3:rxandroid:3.0.0'
+ implementation 'io.reactivex.rxjava3:rxjava:3.0.0'
+
+ // Retrofit.
+ implementation 'com.google.code.gson:gson:2.8.5'
+ implementation 'com.squareup.retrofit2:retrofit:2.9.0'
+ implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
+ implementation 'com.squareup.retrofit2:adapter-rxjava3:2.9.0'
+ implementation 'com.squareup.retrofit2:converter-scalars:2.9.0'
+
+ // MPAndroidChart.
+ implementation 'com.github.PhilJay:MPAndroidChart:v3.1.0'
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/AndroidManifest.xml b/cmd/skywirevisormobile/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..f0a71f93fb
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/App.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/App.java
new file mode 100644
index 0000000000..e3627d5d3a
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/App.java
@@ -0,0 +1,118 @@
+package com.skywire.skycoin.vpn;
+
+import android.app.Activity;
+import android.app.Application;
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.content.Context;
+import android.os.Build;
+import android.os.Bundle;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.skywire.skycoin.vpn.helpers.HelperFunctions;
+import com.skywire.skycoin.vpn.helpers.Notifications;
+import com.skywire.skycoin.vpn.vpn.VPNCoordinator;
+
+import io.reactivex.rxjava3.plugins.RxJavaPlugins;
+
+/**
+ * Class for the main app instance.
+ */
+public class App extends Application {
+ /**
+ * Class used internally to know when there are activities being displayed.
+ */
+ private static class ActivityLifecycleCallback implements Application.ActivityLifecycleCallbacks {
+
+ // How many activities are being shown.
+ private static int foregroundActivities = 0;
+
+ // Functions for knowing when activities start and stop being shown.
+ @Override
+ public void onActivityResumed(@NonNull final Activity activity) { foregroundActivities++; }
+ @Override
+ public void onActivityStopped(@NonNull final Activity activity) { foregroundActivities--; }
+
+ /**
+ * Returns if there is at least one activity being displayed.
+ */
+ public static boolean isApplicationInForeground() { return foregroundActivities > 0; }
+
+ // Other functions needed by the interface.
+ @Override
+ public void onActivityPaused(@NonNull Activity activity) { }
+ @Override
+ public void onActivitySaveInstanceState(@NonNull Activity activity, @NonNull Bundle outState) { }
+ @Override
+ public void onActivityDestroyed(@NonNull Activity activity) { }
+ @Override
+ public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState) { }
+ @Override
+ public void onActivityStarted(@NonNull Activity activity) { }
+ }
+
+ /**
+ * Reference to the current app instance.
+ */
+ private static Context appContext;
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ // Save the current app instance.
+ appContext = this;
+
+ // Ensure the singleton is initialized early.
+ VPNCoordinator.getInstance();
+
+ // Create the notification channels, but only on API 26+ because
+ // the NotificationChannel class is new and not in the support library
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ // Channel for the VPN service state updates.
+ NotificationChannel stateChannel = new NotificationChannel(
+ Notifications.NOTIFICATION_CHANNEL_ID,
+ getString(R.string.general_app_name),
+ NotificationManager.IMPORTANCE_DEFAULT
+ );
+ stateChannel.setDescription(getString(R.string.general_notification_channel_description));
+ stateChannel.setSound(null,null);
+ NotificationManager notificationManager = getSystemService(NotificationManager.class);
+ notificationManager.createNotificationChannel(stateChannel);
+
+ // Channel for alerts.
+ NotificationChannel alertsChannel = new NotificationChannel(
+ Notifications.ALERT_NOTIFICATION_CHANNEL_ID,
+ getString(R.string.general_alert_notification_name),
+ NotificationManager.IMPORTANCE_HIGH
+ );
+ alertsChannel.setDescription(getString(R.string.general_alert_notification_channel_description));
+ notificationManager.createNotificationChannel(alertsChannel);
+ }
+
+ // Code for precessing errors which were not caught by the normal error management
+ // procedures RxJava has. This prevents the app to be closed by unexpected errors, mainly
+ // code trying to report events in closed observables.
+ RxJavaPlugins.setErrorHandler(throwable -> {
+ HelperFunctions.logError("ERROR INSIDE RX: ", throwable);
+ });
+
+ // Detect when activities are started and stopped.
+ registerActivityLifecycleCallbacks(new ActivityLifecycleCallback());
+ }
+
+ /**
+ * Gets the current app context.
+ */
+ public static Context getContext(){
+ return appContext;
+ }
+
+ /**
+ * Gets if the UI is being displayed.
+ */
+ public static boolean displayingUI(){
+ return ActivityLifecycleCallback.isApplicationInForeground();
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/Receiver.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/Receiver.java
new file mode 100644
index 0000000000..99aae2322c
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/Receiver.java
@@ -0,0 +1,23 @@
+package com.skywire.skycoin.vpn;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+
+import com.skywire.skycoin.vpn.vpn.VPNCoordinator;
+import com.skywire.skycoin.vpn.vpn.VPNGeneralPersistentData;
+
+/**
+ * Class for receiving the system boot event broadcast.
+ */
+public class Receiver extends BroadcastReceiver {
+ public void onReceive(Context context, Intent intent) {
+ if (intent.getAction().equals(Intent.ACTION_BOOT_COMPLETED)) {
+ // If the option for starting the service automatically after booting the OS is active
+ // and the service is not currently running, start the service.
+ if (VPNGeneralPersistentData.getStartOnBoot() && !VPNCoordinator.getInstance().isServiceRunning()) {
+ VPNCoordinator.getInstance().activateAutostart();
+ }
+ }
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/apps/AppListButton.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/apps/AppListButton.java
new file mode 100644
index 0000000000..02f8ae5bf1
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/apps/AppListButton.java
@@ -0,0 +1,124 @@
+package com.skywire.skycoin.vpn.activities.apps;
+
+import android.content.Context;
+import android.content.pm.ResolveInfo;
+import android.graphics.drawable.RippleDrawable;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.MotionEvent;
+import android.view.View;
+import android.widget.CheckBox;
+import android.widget.FrameLayout;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.extensible.ListButtonBase;
+
+public class AppListButton extends ListButtonBase implements View.OnTouchListener {
+ public static final float APROX_HEIGHT_DP = 55;
+
+ private FrameLayout mainLayout;
+ private LinearLayout internalLayout;
+ private ImageView imageIcon;
+ private FrameLayout layoutSeparator;
+ private TextView textAppName;
+ private CheckBox checkSelected;
+ private View separator;
+
+ private RippleDrawable rippleDrawable;
+
+ private String appPackageName;
+
+ public AppListButton(Context context) {
+ super(context);
+ }
+ public AppListButton(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+ public AppListButton(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ }
+
+ @Override
+ protected void Initialize (Context context, AttributeSet attrs) {
+ LayoutInflater inflater = (LayoutInflater)context.getSystemService (Context.LAYOUT_INFLATER_SERVICE);
+ inflater.inflate(R.layout.view_app_list_item, this, true);
+
+ mainLayout = this.findViewById (R.id.mainLayout);
+ internalLayout = this.findViewById (R.id.internalLayout);
+ imageIcon = this.findViewById (R.id.imageIcon);
+ layoutSeparator = this.findViewById (R.id.layoutSeparator);
+ textAppName = this.findViewById (R.id.textAppName);
+ checkSelected = this.findViewById (R.id.checkSelected);
+ separator = this.findViewById (R.id.separator);
+
+ rippleDrawable = (RippleDrawable) mainLayout.getBackground();
+ setOnTouchListener(this);
+ setViewForCheckingClicks(this);
+
+ setUseBigFastClickPrevention(false);
+ }
+
+ public void setSeparatorVisibility(boolean visible) {
+ if (visible) {
+ separator.setVisibility(VISIBLE);
+ } else {
+ separator.setVisibility(GONE);
+ }
+ }
+
+ public void changeData(ResolveInfo appData) {
+ if (appData != null) {
+ appPackageName = appData.activityInfo.packageName;
+ imageIcon.setImageDrawable(appData.activityInfo.loadIcon(this.getContext().getPackageManager()));
+ textAppName.setText(appData.activityInfo.loadLabel(this.getContext().getPackageManager()));
+ imageIcon.setVisibility(VISIBLE);
+ layoutSeparator.setVisibility(VISIBLE);
+ setVisibility(VISIBLE);
+ } else {
+ setVisibility(INVISIBLE);
+ }
+ }
+
+ public void changeData(String appPackageName) {
+ imageIcon.setVisibility(GONE);
+ layoutSeparator.setVisibility(GONE);
+ if (appPackageName != null) {
+ this.appPackageName = appPackageName;
+ textAppName.setText(appPackageName);
+ setVisibility(VISIBLE);
+ } else {
+ setVisibility(INVISIBLE);
+ }
+ }
+
+ public String getAppPackageName() {
+ return appPackageName;
+ }
+
+ public void setChecked(boolean checked) {
+ checkSelected.setChecked(checked);
+ }
+
+ @Override
+ public void setEnabled(boolean enabled) {
+ super.setEnabled(enabled);
+
+ if (enabled) {
+ internalLayout.setAlpha(1f);
+ } else {
+ internalLayout.setAlpha(0.5f);
+ }
+ }
+
+ @Override
+ public boolean onTouch(View v, MotionEvent event) {
+ if (rippleDrawable != null) {
+ rippleDrawable.setHotspot(event.getX(), event.getY());
+ }
+
+ return false;
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/apps/AppListOptionButton.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/apps/AppListOptionButton.java
new file mode 100644
index 0000000000..52431579ff
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/apps/AppListOptionButton.java
@@ -0,0 +1,62 @@
+package com.skywire.skycoin.vpn.activities.apps;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.widget.RadioButton;
+import android.widget.TextView;
+
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.controls.BoxRowLayout;
+import com.skywire.skycoin.vpn.extensible.ListButtonBase;
+import com.skywire.skycoin.vpn.helpers.BoxRowTypes;
+
+public class AppListOptionButton extends ListButtonBase {
+ private BoxRowLayout mainLayout;
+ private TextView textOption;
+ private TextView textDescription;
+ private RadioButton radioSelected;
+
+ public AppListOptionButton(Context context) {
+ super(context);
+ }
+
+ @Override
+ protected void Initialize (Context context, AttributeSet attrs) {
+ LayoutInflater inflater = (LayoutInflater)context.getSystemService (Context.LAYOUT_INFLATER_SERVICE);
+ inflater.inflate(R.layout.view_app_list_selection_option, this, true);
+
+ mainLayout = this.findViewById (R.id.mainLayout);
+ textOption = this.findViewById (R.id.textOption);
+ textDescription = this.findViewById (R.id.textDescription);
+ radioSelected = this.findViewById (R.id.radioSelected);
+
+ radioSelected.setChecked(false);
+
+ setClickableBoxView(mainLayout);
+ }
+
+ public void setBoxRowType(BoxRowTypes type) {
+ mainLayout.setType(type);
+ }
+
+ public void changeData(int textResource, int descriptionResource) {
+ textOption.setText(textResource);
+ textDescription.setText(descriptionResource);
+ }
+
+ public void setChecked(boolean checked) {
+ radioSelected.setChecked(checked);
+ }
+
+ @Override
+ public void setEnabled(boolean enabled) {
+ super.setEnabled(enabled);
+
+ if (enabled) {
+ setAlpha(1f);
+ } else {
+ setAlpha(0.5f);
+ }
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/apps/AppListRow.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/apps/AppListRow.java
new file mode 100644
index 0000000000..1e6888c084
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/apps/AppListRow.java
@@ -0,0 +1,115 @@
+package com.skywire.skycoin.vpn.activities.apps;
+
+import android.content.Context;
+import android.content.pm.ResolveInfo;
+import android.view.LayoutInflater;
+import android.widget.FrameLayout;
+import android.widget.LinearLayout;
+
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.controls.BoxRowLayout;
+import com.skywire.skycoin.vpn.extensible.ClickWithIndexEvent;
+import com.skywire.skycoin.vpn.helpers.BoxRowTypes;
+
+public class AppListRow extends FrameLayout implements ClickWithIndexEvent {
+ private BoxRowLayout mainLayout;
+ private LinearLayout buttonsContainer;
+
+ private AppListButton[] buttons;
+ private ClickWithIndexEvent clickListener;
+
+ public AppListRow(Context context, int buttonsPerRow) {
+ super(context);
+
+ LayoutInflater inflater = (LayoutInflater)context.getSystemService (Context.LAYOUT_INFLATER_SERVICE);
+ inflater.inflate(R.layout.view_app_list_row, this, true);
+
+ mainLayout = this.findViewById(R.id.mainLayout);
+ buttonsContainer = this.findViewById(R.id.buttonsContainer);
+
+ buttonsContainer.setClipToOutline(true);
+
+ buttons = new AppListButton[buttonsPerRow];
+ LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT, 1f);
+ for (int i = 0; i < buttonsPerRow; i++) {
+ AppListButton btn = new AppListButton(context);
+ btn.setLayoutParams(layoutParams);
+ btn.setClickWithIndexEventListener(this);
+ buttons[i] = btn;
+ buttonsContainer.addView(btn);
+ }
+ }
+
+ public void setIndex(int index) {
+ for (int i = 0; i < buttons.length; i++) {
+ buttons[i].setIndex(index + i);
+ }
+ }
+
+ public void setClickWithIndexEventListener(ClickWithIndexEvent listener) {
+ clickListener = listener;
+ }
+
+ public void changeData(ResolveInfo[] appData) {
+ for (int i = 0; i < buttons.length; i++) {
+ buttons[i].changeData(appData[i]);
+ }
+ }
+
+ public void changeData(String[] appPackageName) {
+ for (int i = 0; i < buttons.length; i++) {
+ buttons[i].changeData(appPackageName[i]);
+ }
+ }
+
+ public void setBoxRowType(BoxRowTypes type) {
+ mainLayout.setType(type);
+
+ boolean showSeparator = true;
+ if (type == BoxRowTypes.TOP) {
+ buttonsContainer.setBackgroundResource(R.drawable.internal_box_row_rounded_box_1);
+ } else if (type == BoxRowTypes.MIDDLE) {
+ buttonsContainer.setBackgroundResource(R.drawable.internal_box_row_rounded_box_2);
+ } else if (type == BoxRowTypes.BOTTOM) {
+ buttonsContainer.setBackgroundResource(R.drawable.internal_box_row_rounded_box_3);
+ showSeparator = false;
+ } else {
+ buttonsContainer.setBackgroundResource(R.drawable.internal_box_row_rounded_box_4);
+ showSeparator = false;
+ }
+
+ for (int i = 0; i < buttons.length; i++) {
+ buttons[i].setSeparatorVisibility(showSeparator);
+ }
+ }
+
+ public void setChecked(String packageName, boolean checked) {
+ for (int i = 0; i < buttons.length; i++) {
+ if (buttons[i].getAppPackageName() != null && buttons[i].getAppPackageName().equals(packageName)) {
+ buttons[i].setChecked(checked);
+ }
+ }
+ }
+
+ public void setChecked(boolean[] checked) {
+ for (int i = 0; i < buttons.length; i++) {
+ buttons[i].setChecked(checked[i]);
+ }
+ }
+
+ @Override
+ public void setEnabled(boolean enabled) {
+ super.setEnabled(enabled);
+
+ for (int i = 0; i < buttons.length; i++) {
+ buttons[i].setEnabled(enabled);
+ }
+ }
+
+ @Override
+ public void onClickWithIndex(int index, Void data) {
+ if (clickListener != null) {
+ clickListener.onClickWithIndex(index, data);
+ }
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/apps/AppListSeparator.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/apps/AppListSeparator.java
new file mode 100644
index 0000000000..6c51f71837
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/apps/AppListSeparator.java
@@ -0,0 +1,29 @@
+package com.skywire.skycoin.vpn.activities.apps;
+
+import android.content.Context;
+import android.view.LayoutInflater;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.helpers.HelperFunctions;
+
+public class AppListSeparator extends LinearLayout {
+ private TextView textTitle;
+
+ public AppListSeparator(Context context) {
+ super(context);
+
+ LayoutInflater inflater = (LayoutInflater)context.getSystemService (Context.LAYOUT_INFLATER_SERVICE);
+ inflater.inflate(R.layout.view_app_list_separator, this, true);
+
+ textTitle = this.findViewById (R.id.textTitle);
+
+ int tabletExtraHorizontalPadding = HelperFunctions.getTabletExtraHorizontalPadding(getContext());
+ setPadding(tabletExtraHorizontalPadding, 0, tabletExtraHorizontalPadding, 0);
+ }
+
+ public void changeTitle(int title) {
+ textTitle.setText(title);
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/apps/AppsActivity.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/apps/AppsActivity.java
new file mode 100644
index 0000000000..c89673f172
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/apps/AppsActivity.java
@@ -0,0 +1,50 @@
+package com.skywire.skycoin.vpn.activities.apps;
+
+import android.os.Bundle;
+
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.helpers.HelperFunctions;
+
+public class AppsActivity extends AppCompatActivity implements AppsAdapter.AppListChangedListener {
+ public static final String READ_ONLY_EXTRA = "ReadOnly";
+
+ private RecyclerView recycler;
+
+ private boolean readOnly;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_app_list);
+
+ recycler = findViewById(R.id.recycler);
+
+ readOnly = getIntent().getBooleanExtra(READ_ONLY_EXTRA, false);
+
+ LinearLayoutManager layoutManager = new LinearLayoutManager(this);
+ recycler.setLayoutManager(layoutManager);
+ // This could be useful in the future.
+ // recycler.setHasFixedSize(true);
+
+ AppsAdapter adapter = new AppsAdapter(this, readOnly);
+ adapter.setAppListChangedEventListener(this);
+ recycler.setAdapter(adapter);
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ if (!readOnly) {
+ HelperFunctions.closeActivityIfServiceRunning(this);
+ }
+ }
+
+ @Override
+ public boolean onAppListChanged() {
+ return !HelperFunctions.closeActivityIfServiceRunning(this);
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/apps/AppsAdapter.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/apps/AppsAdapter.java
new file mode 100644
index 0000000000..72defac1c9
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/apps/AppsAdapter.java
@@ -0,0 +1,339 @@
+package com.skywire.skycoin.vpn.activities.apps;
+
+import android.content.Context;
+import android.content.pm.ResolveInfo;
+import android.content.res.Resources;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.helpers.BoxRowTypes;
+import com.skywire.skycoin.vpn.extensible.ClickWithIndexEvent;
+import com.skywire.skycoin.vpn.helpers.Globals;
+import com.skywire.skycoin.vpn.helpers.HelperFunctions;
+import com.skywire.skycoin.vpn.extensible.ListViewHolder;
+import com.skywire.skycoin.vpn.vpn.VPNGeneralPersistentData;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+
+public class AppsAdapter extends RecyclerView.Adapter> implements ClickWithIndexEvent {
+ public interface AppListChangedListener {
+ boolean onAppListChanged();
+ }
+
+ private final int installedAppsIndexExtra = 10;
+ private final int uninstalledAppsIndexExtra = 1000000;
+
+ private Context context;
+ private List appList;
+ private List uninstalledApps;
+ private AppListChangedListener appListChangedListener;
+
+ private HashSet selectedApps;
+ private Globals.AppFilteringModes selectedOption;
+
+ private int[] optionTexts = new int[3];
+ private int[] optionDescriptions = new int[3];
+ private ArrayList optionButtons = new ArrayList<>();
+ private ArrayList appRows = new ArrayList<>();
+
+ private ArrayList premadeRows = new ArrayList<>();
+ private int lastUsedPremadeRowIndex = 0;
+
+ private int elementsPerRow = 1;
+
+ private boolean readOnly;
+
+ public AppsAdapter(Context context, boolean readOnly) {
+ this.context = context;
+ this.readOnly = readOnly;
+
+ selectedApps = VPNGeneralPersistentData.getAppList(new HashSet<>());
+ changeSelectedOption(VPNGeneralPersistentData.getAppsSelectionMode());
+
+ appList = HelperFunctions.getDeviceAppsList();
+
+ HashSet filteredApps = HelperFunctions.filterAvailableApps(selectedApps);
+ if (filteredApps.size() != selectedApps.size()) {
+ uninstalledApps = new ArrayList<>();
+
+ for (String app : selectedApps) {
+ if (!filteredApps.contains(app)) {
+ uninstalledApps.add(app);
+ }
+ }
+ }
+
+ optionTexts[0] = R.string.tmp_select_apps_protect_all_button;
+ optionTexts[1] = R.string.tmp_select_apps_protect_selected_button;
+ optionTexts[2] = R.string.tmp_select_apps_unprotect_selected_button;
+
+ optionDescriptions[0] = R.string.tmp_select_apps_protect_all_button_desc;
+ optionDescriptions[1] = R.string.tmp_select_apps_protect_selected_button_desc;
+ optionDescriptions[2] = R.string.tmp_select_apps_unprotect_selected_button_desc;
+
+ int screenWidthInDP = (int)(Resources.getSystem().getDisplayMetrics().widthPixels / context.getResources().getDisplayMetrics().density);
+ elementsPerRow = Math.max(screenWidthInDP / 360, 1);
+
+ int screenHeightInDP = (int)(Resources.getSystem().getDisplayMetrics().heightPixels / context.getResources().getDisplayMetrics().density);
+ int aproxRowsToFillScreen = (int)Math.ceil((screenHeightInDP / AppListButton.APROX_HEIGHT_DP) * 1.3);
+
+ for (int i = 0; i < aproxRowsToFillScreen; i++) {
+ premadeRows.add(createNewRow());
+ }
+ }
+
+ public void setAppListChangedEventListener(AppListChangedListener listener) {
+ appListChangedListener = listener;
+ }
+
+ private int getInstalledAppsRowsCount() {
+ return (int)Math.ceil((double)appList.size() / (double)elementsPerRow);
+ }
+
+ private int getUninstalledAppsRowsCount() {
+ if (uninstalledApps == null) {
+ return 0;
+ }
+
+ return (int)Math.ceil((double)uninstalledApps.size() / (double)elementsPerRow);
+ }
+
+ @Override
+ public int getItemViewType(int position) {
+ if (position == 0 || position == 4 || position == 5 + getInstalledAppsRowsCount()) {
+ return 2;
+ }
+
+ if (position < 4) {
+ return 0;
+ }
+
+ return 1;
+ }
+
+ @NonNull
+ @Override
+ public ListViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ if (viewType == 0) {
+ AppListOptionButton view = new AppListOptionButton(context);
+ view.setClickWithIndexEventListener(this);
+ optionButtons.add(view);
+
+ if (readOnly) {
+ view.setEnabled(false);
+ }
+
+ return new ListViewHolder<>(view);
+ } else if (viewType == 1) {
+ AppListRow view;
+
+ if (lastUsedPremadeRowIndex < premadeRows.size()) {
+ view = premadeRows.get(lastUsedPremadeRowIndex);
+ lastUsedPremadeRowIndex += 1;
+ } else {
+ view = createNewRow();
+ }
+
+ return new ListViewHolder<>(view);
+ }
+
+ AppListSeparator view = new AppListSeparator(context);
+
+ return new ListViewHolder<>(view);
+ }
+
+ private AppListRow createNewRow() {
+ AppListRow view = new AppListRow(context, elementsPerRow);
+ view.setClickWithIndexEventListener(this);
+ view.setEnabled(selectedOption != Globals.AppFilteringModes.PROTECT_ALL);
+ appRows.add(view);
+
+ if (readOnly) {
+ view.setEnabled(false);
+ }
+
+ return view;
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull ListViewHolder holder, int position) {
+ if (holder.getItemViewType() == 0) {
+ boolean showChecked = false;
+ if (position == 1 && selectedOption == Globals.AppFilteringModes.PROTECT_ALL) { showChecked = true; }
+ if (position == 2 && selectedOption == Globals.AppFilteringModes.PROTECT_SELECTED) { showChecked = true; }
+ if (position == 3 && selectedOption == Globals.AppFilteringModes.IGNORE_SELECTED) { showChecked = true; }
+
+ ((AppListOptionButton)(holder.itemView)).setIndex(position);
+ ((AppListOptionButton)(holder.itemView)).changeData(optionTexts[position - 1], optionDescriptions[position - 1]);
+ ((AppListOptionButton)(holder.itemView)).setChecked(showChecked);
+
+ if (position == 1) {
+ ((AppListOptionButton)holder.itemView).setBoxRowType(BoxRowTypes.TOP);
+ } else if (position == 2) {
+ ((AppListOptionButton)holder.itemView).setBoxRowType(BoxRowTypes.MIDDLE);
+ } else {
+ ((AppListOptionButton)holder.itemView).setBoxRowType(BoxRowTypes.BOTTOM);
+ }
+
+ return;
+ } else if (holder.getItemViewType() == 2) {
+ if (position == 0) {
+ ((AppListSeparator)holder.itemView).changeTitle(R.string.tmp_select_apps_mode_title);
+ } else if (position == 4) {
+ if (this.uninstalledApps != null) {
+ ((AppListSeparator) holder.itemView).changeTitle(R.string.tmp_select_apps_installed_apps_title);
+ } else {
+ ((AppListSeparator) holder.itemView).changeTitle(R.string.tmp_select_apps_apps_title);
+ }
+ } else {
+ ((AppListSeparator)holder.itemView).changeTitle(R.string.tmp_select_apps_uninstalled_apps_title);
+ }
+
+ return;
+ }
+
+ int initialInstalledAppsRowIndex = 5;
+ if (position < initialInstalledAppsRowIndex + getInstalledAppsRowsCount()) {
+ int rowIndex = (position - initialInstalledAppsRowIndex);
+
+ ResolveInfo[] dataForRow = new ResolveInfo[elementsPerRow];
+ boolean[] checkedListForRow = new boolean[elementsPerRow];
+ for (int i = 0; i < elementsPerRow; i++){
+ int appIndex = (rowIndex * elementsPerRow) + i;
+ if (appIndex < appList.size()) {
+ dataForRow[i] = appList.get(appIndex);
+ checkedListForRow[i] = selectedApps.contains(appList.get(appIndex).activityInfo.packageName);
+ }
+ }
+
+ ((AppListRow) (holder.itemView)).setIndex(installedAppsIndexExtra + (rowIndex * elementsPerRow));
+ ((AppListRow) (holder.itemView)).changeData(dataForRow);
+ ((AppListRow) (holder.itemView)).setChecked(checkedListForRow);
+
+ if (getInstalledAppsRowsCount() == 1) {
+ ((AppListRow)holder.itemView).setBoxRowType(BoxRowTypes.SINGLE);
+ } else if (rowIndex == 0) {
+ ((AppListRow)holder.itemView).setBoxRowType(BoxRowTypes.TOP);
+ } else if (rowIndex == getInstalledAppsRowsCount() - 1) {
+ ((AppListRow)holder.itemView).setBoxRowType(BoxRowTypes.BOTTOM);
+ } else {
+ ((AppListRow)holder.itemView).setBoxRowType(BoxRowTypes.MIDDLE);
+ }
+ } else {
+ int initialUninstalledAppsRowIndex = initialInstalledAppsRowIndex + getInstalledAppsRowsCount() + 1;
+ int rowIndex = (position - initialUninstalledAppsRowIndex);
+
+ String[] dataForRow = new String[elementsPerRow];
+ boolean[] checkedListForRow = new boolean[elementsPerRow];
+ for (int i = 0; i < elementsPerRow; i++){
+ int appIndex = (rowIndex * elementsPerRow) + i;
+ if (appIndex < uninstalledApps.size()) {
+ dataForRow[i] = uninstalledApps.get(appIndex);
+ checkedListForRow[i] = selectedApps.contains(uninstalledApps.get(appIndex));
+ }
+ }
+
+ ((AppListRow) (holder.itemView)).setIndex(uninstalledAppsIndexExtra + (rowIndex * elementsPerRow));
+ ((AppListRow) (holder.itemView)).changeData(dataForRow);
+ ((AppListRow) (holder.itemView)).setChecked(checkedListForRow);
+
+ if (getUninstalledAppsRowsCount() == 1) {
+ ((AppListRow)holder.itemView).setBoxRowType(BoxRowTypes.SINGLE);
+ } else if (rowIndex == 0) {
+ ((AppListRow)holder.itemView).setBoxRowType(BoxRowTypes.TOP);
+ } else if (rowIndex == getUninstalledAppsRowsCount() - 1) {
+ ((AppListRow)holder.itemView).setBoxRowType(BoxRowTypes.BOTTOM);
+ } else {
+ ((AppListRow)holder.itemView).setBoxRowType(BoxRowTypes.MIDDLE);
+ }
+ }
+ }
+
+ @Override
+ public int getItemCount() {
+ int result = 3 + 2 + getInstalledAppsRowsCount();
+
+ if (getUninstalledAppsRowsCount() > 0) {
+ result += 1 + getUninstalledAppsRowsCount();
+ }
+
+ return result;
+ }
+
+ @Override
+ public void onClickWithIndex(int index, Void data) {
+ if (appListChangedListener != null) {
+ if (!appListChangedListener.onAppListChanged()) {
+ return;
+ }
+ }
+
+ if (index < installedAppsIndexExtra) {
+ if (index == 1) {
+ changeSelectedOption(Globals.AppFilteringModes.PROTECT_ALL);
+ } else if (index == 2) {
+ changeSelectedOption(Globals.AppFilteringModes.PROTECT_SELECTED);
+ } else if (index == 3) {
+ changeSelectedOption(Globals.AppFilteringModes.IGNORE_SELECTED);
+ }
+ } else {
+ processAppClicked(index);
+ }
+ }
+
+ private void changeSelectedOption(Globals.AppFilteringModes option) {
+ if (option != selectedOption) {
+ if (option == Globals.AppFilteringModes.PROTECT_ALL) {
+ for (AppListRow row : appRows) {
+ row.setEnabled(false);
+ }
+ } else if (selectedOption == Globals.AppFilteringModes.PROTECT_ALL) {
+ for (AppListRow row : appRows) {
+ row.setEnabled(true);
+ }
+ }
+
+ selectedOption = option;
+ VPNGeneralPersistentData.setAppsSelectionMode(selectedOption);
+
+ for (AppListOptionButton optionButton : optionButtons) {
+ optionButton.setChecked(
+ (optionButton.getIndex() == 1 && selectedOption == Globals.AppFilteringModes.PROTECT_ALL) ||
+ (optionButton.getIndex() == 2 && selectedOption == Globals.AppFilteringModes.PROTECT_SELECTED) ||
+ (optionButton.getIndex() == 3 && selectedOption == Globals.AppFilteringModes.IGNORE_SELECTED)
+ );
+ }
+ }
+ }
+
+ private void processAppClicked(int index) {
+ String app;
+
+ if (index < uninstalledAppsIndexExtra) {
+ app = appList.get(index - installedAppsIndexExtra).activityInfo.packageName;
+ } else {
+ app = uninstalledApps.get(index - uninstalledAppsIndexExtra);
+ }
+
+ boolean showAppChecked;
+ if (selectedApps.contains(app)) {
+ selectedApps.remove(app);
+ showAppChecked = false;
+ } else {
+ selectedApps.add(app);
+ showAppChecked = true;
+ }
+
+ for (AppListRow row : appRows) {
+ row.setChecked(app, showAppChecked);
+ }
+
+ VPNGeneralPersistentData.setAppList(selectedApps);
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/index/IndexActivity.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/index/IndexActivity.java
new file mode 100644
index 0000000000..dfa267d285
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/index/IndexActivity.java
@@ -0,0 +1,185 @@
+package com.skywire.skycoin.vpn.activities.index;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.view.View;
+import android.widget.FrameLayout;
+import android.widget.ImageView;
+
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.viewpager2.widget.ViewPager2;
+
+import com.google.android.material.tabs.TabLayout;
+import com.google.android.material.tabs.TabLayoutMediator;
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.controls.TabletTopBar;
+import com.skywire.skycoin.vpn.controls.TopBar;
+import com.skywire.skycoin.vpn.controls.TopTab;
+import com.skywire.skycoin.vpn.extensible.ClickWithIndexEvent;
+import com.skywire.skycoin.vpn.helpers.HelperFunctions;
+import com.skywire.skycoin.vpn.vpn.VPNCoordinator;
+
+public class IndexActivity extends AppCompatActivity implements IndexPageAdapter.RequestTabListener, ClickWithIndexEvent {
+ private ImageView imageBackground;
+ private ImageView imageTopBarShadow;
+ private ViewPager2 pager;
+ private TopBar topBar;
+ private TabletTopBar tabletTopBar;
+ private TabLayout tabs;
+
+ private TabLayoutMediator tabLayoutMediator;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_index);
+
+ imageBackground = findViewById(R.id.imageBackground);
+ imageTopBarShadow = findViewById(R.id.imageTopBarShadow);
+ pager = findViewById(R.id.pager);
+ topBar = findViewById(R.id.topBar);
+ tabletTopBar = findViewById(R.id.tabletTopBar);
+ tabs = findViewById(R.id.tabs);
+
+ if (HelperFunctions.showBackgroundForVerticalScreen()) {
+ imageBackground.setVisibility(View.GONE);
+ }
+
+ IndexPageAdapter adapter = new IndexPageAdapter(this);
+ adapter.setRequestTabListener(this);
+ pager.setAdapter(adapter);
+
+ tabLayoutMediator = new TabLayoutMediator(tabs, pager, (tab, position) -> {
+ if (position == 0) {
+ tab.setCustomView(new TopTab(this, R.string.tmp_status_page_title));
+ } else if (position == 1) {
+ tab.setCustomView(new TopTab(this, R.string.tmp_select_server_title));
+ } else {
+ tab.setCustomView(new TopTab(this, R.string.tmp_options_title));
+ }
+
+ if (position != 0) {
+ tab.getCustomView().setAlpha(0.4f);
+ }
+ });
+ tabLayoutMediator.attach();
+
+ pager.setOffscreenPageLimit(3);
+
+ if (HelperFunctions.getWidthType(this) == HelperFunctions.WidthTypes.SMALL) {
+ tabletTopBar.setVisibility(View.GONE);
+ tabletTopBar.close();
+
+ tabs.addOnTabSelectedListener(new TabLayout.OnTabSelectedListener() {
+ @Override
+ public void onTabSelected(TabLayout.Tab tab) {
+ tab.getCustomView().setAlpha(1f);
+ }
+ @Override
+ public void onTabUnselected(TabLayout.Tab tab) {
+ tab.getCustomView().setAlpha(0.4f);
+ }
+ @Override
+ public void onTabReselected(TabLayout.Tab tab) { }
+ });
+ } else {
+ topBar.setVisibility(View.GONE);
+ tabs.setVisibility(View.GONE);
+ imageTopBarShadow.setVisibility(View.GONE);
+
+ FrameLayout.LayoutParams params = (FrameLayout.LayoutParams)imageBackground.getLayoutParams();
+ params.topMargin = 0;
+ imageBackground.setLayoutParams(params);
+
+ params = (FrameLayout.LayoutParams)pager.getLayoutParams();
+ params.topMargin = (int)Math.round(getResources().getDimension(R.dimen.tablet_top_bar_height));
+ pager.setLayoutParams(params);
+
+ tabletTopBar.setSelectedTab(TabletTopBar.statusTabIndex);
+
+ pager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() {
+ @Override
+ public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
+ super.onPageScrolled(position, positionOffset, positionOffsetPixels);
+ }
+
+ @Override
+ public void onPageSelected(int position) {
+ super.onPageSelected(position);
+
+ tabletTopBar.setSelectedTab(position);
+ }
+
+ @Override
+ public void onPageScrollStateChanged(int state) {
+ super.onPageScrollStateChanged(state);
+ }
+ });
+
+ tabletTopBar.setClickWithIndexEventListener(this);
+ }
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+
+ if (tabletTopBar.getVisibility() != View.GONE) {
+ tabletTopBar.onResume();
+ }
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+
+ if (tabletTopBar.getVisibility() != View.GONE) {
+ tabletTopBar.onPause();
+ }
+ }
+
+ @Override
+ protected void onDestroy() {
+ super.onDestroy();
+
+ tabLayoutMediator.detach();
+ tabletTopBar.close();
+ }
+
+ @Override
+ public void onBackPressed() {
+ if (pager.getCurrentItem() != 0) {
+ pager.setCurrentItem(0);
+ } else {
+ super.onBackPressed();
+
+ if (VPNCoordinator.getInstance().isServiceRunning()) {
+ HelperFunctions.showToast(getString(R.string.general_service_running_notification), false);
+ }
+ }
+ }
+
+ @Override
+ public void onOpenStatusRequested() {
+ pager.setCurrentItem(0);
+ }
+
+ @Override
+ public void onOpenServerListRequested() {
+ pager.setCurrentItem(1);
+ }
+
+ @Override
+ protected void onActivityResult(int request, int result, Intent data) {
+ super.onActivityResult(request, result, data);
+
+ if (request == VPNCoordinator.VPN_PREPARATION_REQUEST_CODE) {
+ VPNCoordinator.getInstance().onActivityResult(request, result, data);
+ }
+ }
+
+ @Override
+ public void onClickWithIndex(int index, Void data) {
+ pager.setCurrentItem(index);
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/index/IndexPageAdapter.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/index/IndexPageAdapter.java
new file mode 100644
index 0000000000..08905acac3
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/index/IndexPageAdapter.java
@@ -0,0 +1,49 @@
+package com.skywire.skycoin.vpn.activities.index;
+
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.fragment.app.Fragment;
+import androidx.viewpager2.adapter.FragmentStateAdapter;
+
+import com.skywire.skycoin.vpn.activities.servers.ServersActivity;
+import com.skywire.skycoin.vpn.activities.settings.SettingsActivity;
+import com.skywire.skycoin.vpn.activities.start.StartActivity;
+
+public class IndexPageAdapter extends FragmentStateAdapter {
+ public interface RequestTabListener {
+ void onOpenStatusRequested();
+ void onOpenServerListRequested();
+ }
+
+ private StartActivity tab1 = new StartActivity();
+ private ServersActivity tab2 = new ServersActivity();
+ private SettingsActivity tab3 = new SettingsActivity();
+
+ public IndexPageAdapter(AppCompatActivity activity) {
+ super(activity);
+ }
+
+ public void setRequestTabListener(RequestTabListener listener) {
+ tab1.setRequestTabListener(listener);
+ tab2.setRequestTabListener(listener);
+ }
+
+ @Override
+ public Fragment createFragment(int position) {
+ Fragment response;
+
+ if (position == 0) {
+ response = tab1;
+ } else if (position == 1) {
+ response = tab2;
+ } else {
+ response = tab3;
+ }
+
+ return response;
+ }
+
+ @Override
+ public int getItemCount() {
+ return 3;
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/main/MainActivity.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/main/MainActivity.java
new file mode 100644
index 0000000000..222be391bf
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/main/MainActivity.java
@@ -0,0 +1,303 @@
+package com.skywire.skycoin.vpn.activities.main;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.view.View;
+import android.widget.Button;
+import android.widget.EditText;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.AppCompatActivity;
+
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.activities.settings.SettingsActivity;
+import com.skywire.skycoin.vpn.activities.start.StartActivity;
+import com.skywire.skycoin.vpn.helpers.Notifications;
+import com.skywire.skycoin.vpn.objects.LocalServerData;
+import com.skywire.skycoin.vpn.objects.ManualVpnServerData;
+import com.skywire.skycoin.vpn.vpn.VPNCoordinator;
+import com.skywire.skycoin.vpn.vpn.VPNServersPersistentData;
+import com.skywire.skycoin.vpn.vpn.VPNGeneralPersistentData;
+import com.skywire.skycoin.vpn.vpn.VPNStates;
+import com.skywire.skycoin.vpn.activities.apps.AppsActivity;
+import com.skywire.skycoin.vpn.activities.servers.ServersActivity;
+import com.skywire.skycoin.vpn.helpers.Globals;
+import com.skywire.skycoin.vpn.helpers.HelperFunctions;
+
+import java.util.HashSet;
+
+import io.reactivex.rxjava3.disposables.Disposable;
+import skywiremob.Skywiremob;
+
+public class MainActivity extends AppCompatActivity implements View.OnClickListener {
+
+ private EditText editTextRemotePK;
+ private EditText editTextPasscode;
+ private Button buttonStart;
+ private Button buttonStop;
+ private Button buttonSelect;
+ private Button buttonApps;
+ private Button buttonSettings;
+ private Button buttonStartPage;
+ private TextView textLastError1;
+ private TextView textLastError2;
+ private TextView textStatus;
+ private TextView textFinishAlert;
+ private TextView textStopAlert;
+
+ private Disposable serviceSubscription;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_main);
+
+ editTextRemotePK = findViewById(R.id.editTextRemotePK);
+ editTextPasscode = findViewById(R.id.editTextPasscode);
+ buttonStart = findViewById(R.id.buttonStart);
+ buttonStop = findViewById(R.id.buttonStop);
+ buttonSelect = findViewById(R.id.buttonSelect);
+ buttonApps = findViewById(R.id.buttonApps);
+ buttonSettings = findViewById(R.id.buttonSettings);
+ buttonStartPage = findViewById(R.id.buttonStartPage);
+ textStatus = findViewById(R.id.textStatus);
+ textFinishAlert = findViewById(R.id.textFinishAlert);
+ textLastError1 = findViewById(R.id.textLastError1);
+ textLastError2 = findViewById(R.id.textLastError2);
+ textStopAlert = findViewById(R.id.textStopAlert);
+
+ buttonStart.setOnClickListener(this);
+ buttonStop.setOnClickListener(this);
+ buttonSelect.setOnClickListener(this);
+ buttonApps.setOnClickListener(this);
+ buttonSettings.setOnClickListener(this);
+ buttonStartPage.setOnClickListener(this);
+
+ LocalServerData currentServer = VPNServersPersistentData.getInstance().getCurrentServer();
+ String savedPk = currentServer != null ? currentServer.pk : null;
+ String savedPassword = currentServer != null && currentServer.password != null ? currentServer.password : "";
+
+ if (savedPk != null && savedPassword != null) {
+ editTextRemotePK.setText(savedPk);
+ editTextPasscode.setText(savedPassword);
+ }
+ }
+
+ @Override
+ public void onRestoreInstanceState(Bundle savedInstanceState) {
+ editTextRemotePK.setText(savedInstanceState.getString("pk"));
+ editTextPasscode.setText(savedInstanceState.getString("password"));
+ }
+
+ @Override
+ public void onSaveInstanceState(@NonNull Bundle savedInstanceState) {
+ super.onSaveInstanceState(savedInstanceState);
+ savedInstanceState.putString("pk", editTextRemotePK.getText().toString());
+ savedInstanceState.putString("password", editTextPasscode.getText().toString());
+ }
+
+ @Override
+ protected void onStart() {
+ super.onStart();
+
+ Notifications.removeAllAlertNotifications();
+
+ displayInitialState();
+
+ serviceSubscription = VPNCoordinator.getInstance().getEventsObservable().subscribe(
+ state -> {
+ if (state.state.val() < 10) {
+ displayInitialState();
+ } else if (state.state != VPNStates.ERROR && state.state != VPNStates.BLOCKING_ERROR && state.state != VPNStates.DISCONNECTED) {
+ int stateText = VPNStates.getDescriptionForState(state.state);
+
+ displayWorkingState();
+
+ if (state.startedByTheSystem) {
+ this.buttonStop.setEnabled(false);
+ textStopAlert.setVisibility(View.VISIBLE);
+ }
+
+ if (state.stopRequested) {
+ this.buttonStop.setEnabled(false);
+ }
+
+ if (stateText != -1) {
+ textStatus.setText(stateText);
+ }
+ } else if (state.state == VPNStates.DISCONNECTED) {
+ textStatus.setText(R.string.vpn_state_disconnected);
+ displayInitialState();
+ } else {
+ textStatus.setText(VPNStates.getDescriptionForState(state.state));
+ displayErrorState(state.stopRequested);
+ }
+ }
+ );
+ }
+
+ @Override
+ protected void onStop() {
+ super.onStop();
+
+ serviceSubscription.dispose();
+ }
+
+ @Override
+ public void onClick(View view) {
+ switch (view.getId()) {
+ case R.id.buttonStart:
+ start();
+ break;
+ case R.id.buttonStop:
+ stop();
+ break;
+ case R.id.buttonSelect:
+ selectServer();
+ break;
+ case R.id.buttonApps:
+ selectApps();
+ break;
+ case R.id.buttonSettings:
+ openSettings();
+ break;
+ case R.id.buttonStartPage:
+ openStarPage();
+ break;
+ }
+ }
+
+ @Override
+ protected void onActivityResult(int request, int result, Intent data) {
+ super.onActivityResult(request, result, data);
+
+ if (request == VPNCoordinator.VPN_PREPARATION_REQUEST_CODE) {
+ VPNCoordinator.getInstance().onActivityResult(request, result, data);
+ } else if (request == 1 && data != null) {
+ String address = data.getStringExtra(ServersActivity.ADDRESS_DATA_PARAM);
+ if (address != null) {
+ editTextRemotePK.setText(address);
+ editTextPasscode.setText("");
+ }
+
+ start();
+ }
+ }
+
+ private void start() {
+ // Check if the pk is valid.
+ String remotePK = editTextRemotePK.getText().toString().trim();
+ long err = Skywiremob.isPKValid(remotePK).getCode();
+ if (err != Skywiremob.ErrCodeNoError) {
+ HelperFunctions.showToast(getString(R.string.vpn_coordinator_invalid_credentials_error) + remotePK, false);
+ return;
+ } else {
+ Skywiremob.printString("PK is correct");
+ }
+
+ Globals.AppFilteringModes selectedMode = VPNGeneralPersistentData.getAppsSelectionMode();
+ if (selectedMode != Globals.AppFilteringModes.PROTECT_ALL) {
+ HashSet selectedApps = HelperFunctions.filterAvailableApps(VPNGeneralPersistentData.getAppList(new HashSet<>()));
+
+ if (selectedApps.size() == 0) {
+ if (selectedMode == Globals.AppFilteringModes.PROTECT_SELECTED) {
+ HelperFunctions.showToast(getString(R.string.vpn_no_apps_to_protect_warning), false);
+ } else {
+ HelperFunctions.showToast(getString(R.string.vpn_no_apps_to_ignore_warning), false);
+ }
+ }
+ }
+
+ ManualVpnServerData intermediaryServerData = new ManualVpnServerData();
+ intermediaryServerData.pk = remotePK;
+ intermediaryServerData.password = editTextPasscode.getText().toString();
+ LocalServerData server = VPNServersPersistentData.getInstance().processFromManual(intermediaryServerData);
+
+ VPNCoordinator.getInstance().startVPN(
+ this,
+ server
+ );
+ }
+
+ private void stop() {
+ VPNCoordinator.getInstance().stopVPN();
+ }
+
+ private void selectServer() {
+ Intent intent = new Intent(this, ServersActivity.class);
+ startActivityForResult(intent, 1);
+ }
+
+ private void selectApps() {
+ Intent intent = new Intent(this, AppsActivity.class);
+ startActivity(intent);
+ }
+
+ private void openSettings() {
+ Intent intent = new Intent(this, SettingsActivity.class);
+ startActivity(intent);
+ }
+
+ private void openStarPage() {
+ Intent intent = new Intent(this, StartActivity.class);
+ startActivity(intent);
+ }
+
+ private void displayInitialState() {
+ textStatus.setText(R.string.vpn_state_details_off);
+
+ editTextRemotePK.setEnabled(true);
+ editTextPasscode.setEnabled(true);
+ buttonStart.setEnabled(true);
+ buttonStop.setEnabled(false);
+ buttonSelect.setEnabled(true);
+ buttonApps.setEnabled(true);
+ buttonSettings.setEnabled(true);
+ textFinishAlert.setVisibility(View.GONE);
+ textStopAlert.setVisibility(View.GONE);
+
+ String lastError = VPNGeneralPersistentData.getLastError(null);
+ if (lastError != null) {
+ textLastError1.setVisibility(View.VISIBLE);
+ textLastError2.setVisibility(View.VISIBLE);
+ textLastError2.setText(lastError);
+ } else {
+ textLastError1.setVisibility(View.GONE);
+ textLastError2.setVisibility(View.GONE);
+ }
+ }
+
+ private void displayWorkingState() {
+ editTextRemotePK.setEnabled(false);
+ editTextPasscode.setEnabled(false);
+ buttonStart.setEnabled(false);
+ buttonStop.setEnabled(true);
+ buttonSelect.setEnabled(false);
+ buttonApps.setEnabled(false);
+ buttonSettings.setEnabled(false);
+ textFinishAlert.setVisibility(View.GONE);
+ textStopAlert.setVisibility(View.GONE);
+
+ textLastError1.setVisibility(View.GONE);
+ textLastError2.setVisibility(View.GONE);
+ }
+
+ private void displayErrorState(boolean stopRequested) {
+ editTextRemotePK.setEnabled(false);
+ editTextPasscode.setEnabled(false);
+ buttonStart.setEnabled(false);
+ buttonStop.setEnabled(!stopRequested);
+ buttonSelect.setEnabled(false);
+ buttonApps.setEnabled(false);
+ buttonSettings.setEnabled(false);
+ textFinishAlert.setVisibility(stopRequested ? View.VISIBLE : View.GONE);
+ textStopAlert.setVisibility(View.GONE);
+
+ textLastError1.setVisibility(View.VISIBLE);
+ textLastError2.setVisibility(View.VISIBLE);
+
+ String lastError = VPNGeneralPersistentData.getLastError(null);
+ textLastError2.setText(lastError);
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/servers/ConditionsList.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/servers/ConditionsList.java
new file mode 100644
index 0000000000..987dbc6a19
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/servers/ConditionsList.java
@@ -0,0 +1,147 @@
+package com.skywire.skycoin.vpn.activities.servers;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.MotionEvent;
+import android.view.View;
+import android.widget.FrameLayout;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.extensible.ButtonBase;
+import com.skywire.skycoin.vpn.helpers.CountriesList;
+
+public class ConditionsList extends ButtonBase implements View.OnTouchListener {
+ private FrameLayout mainContainer;
+ private LinearLayout filtersContainer;
+ private LinearLayout orderContainer;
+ private TextView textFilters;
+ private TextView textOrder;
+
+ public ConditionsList(Context context) {
+ super(context);
+ }
+ public ConditionsList(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+ public ConditionsList(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ }
+
+ @Override
+ protected void Initialize (Context context, AttributeSet attrs) {
+ LayoutInflater inflater = (LayoutInflater)context.getSystemService (Context.LAYOUT_INFLATER_SERVICE);
+ inflater.inflate(R.layout.view_server_list_condition_list, this, true);
+
+ mainContainer = this.findViewById (R.id.mainContainer);
+ filtersContainer = this.findViewById (R.id.filtersContainer);
+ orderContainer = this.findViewById (R.id.orderContainer);
+ textFilters = this.findViewById (R.id.textFilters);
+ textOrder = this.findViewById (R.id.textOrder);
+
+ mainContainer.setVisibility(GONE);
+
+ setOnTouchListener(this);
+ setViewForCheckingClicks(this);
+ }
+
+ public void setConditions(VpnServersAdapter.SortableColumns column, boolean sortingReversed, FilterModalWindow.Filters filters) {
+ if (filters == null && column == VpnServersAdapter.SortableColumns.AUTOMATIC) {
+ mainContainer.setVisibility(GONE);
+ } else {
+ boolean showingValues = false;
+
+ if (filters != null) {
+ String filterList = "";
+ if (filters.countryCode != null && !filters.countryCode.equals("")) {
+ filterList += getContext().getText(R.string.filter_server_country_label) + " \"" + CountriesList.getCountryName(filters.countryCode) + "\"";
+ }
+
+ if (filters.name != null && !filters.name.equals("")) {
+ if (filterList.length() > 0) {
+ filterList += " / ";
+ }
+
+ filterList += getContext().getText(R.string.filter_server_name_label) + " \"" + filters.name + "\"";
+ }
+
+ if (filters.location != null && !filters.location.equals("")) {
+ if (filterList.length() > 0) {
+ filterList += " / ";
+ }
+
+ filterList += getContext().getText(R.string.filter_server_location_label) + " \"" + filters.location + "\"";
+ }
+
+ if (filters.pk != null && !filters.pk.equals("")) {
+ if (filterList.length() > 0) {
+ filterList += " / ";
+ }
+
+ filterList += getContext().getText(R.string.filter_server_public_key_label) + " \"" + filters.pk + "\"";
+ }
+
+ if (filters.note != null && !filters.note.equals("")) {
+ if (filterList.length() > 0) {
+ filterList += " / ";
+ }
+
+ filterList += getContext().getText(R.string.filter_server_note_label) + " \"" + filters.note + "\"";
+ }
+
+ if (filterList.length() > 0) {
+ filtersContainer.setVisibility(VISIBLE);
+ textFilters.setText(filterList);
+
+ showingValues = true;
+ } else {
+ filtersContainer.setVisibility(GONE);
+ }
+ } else {
+ filtersContainer.setVisibility(GONE);
+ }
+
+ if (column != VpnServersAdapter.SortableColumns.AUTOMATIC) {
+ String columnName = getContext().getText(VpnServersAdapter.SortableColumns.getColumnNameId(column)).toString();
+
+ if (sortingReversed) {
+ columnName += " " + getContext().getText(R.string.tmp_select_server_reversed_suffix);
+ }
+
+ orderContainer.setVisibility(VISIBLE);
+ textOrder.setText(getContext().getText(R.string.tmp_select_server_sorted_by_prefix) + " \"" + columnName + "\"");
+
+ showingValues = true;
+ } else {
+ orderContainer.setVisibility(GONE);
+ }
+
+ if (showingValues) {
+ mainContainer.setVisibility(VISIBLE);
+ } else {
+ mainContainer.setVisibility(GONE);
+ }
+ }
+ }
+
+ public boolean showingFilters() {
+ return mainContainer.getVisibility() != GONE && filtersContainer.getVisibility() != GONE;
+ }
+
+ public boolean showingOrder() {
+ return mainContainer.getVisibility() != GONE && orderContainer.getVisibility() != GONE;
+ }
+
+ @Override
+ public boolean onTouch(View v, MotionEvent event) {
+ if (event.getAction() == MotionEvent.ACTION_DOWN) {
+ setAlpha(0.5f);
+ } else if (event.getAction() == MotionEvent.ACTION_CANCEL || event.getAction() == MotionEvent.ACTION_POINTER_UP || event.getAction() == MotionEvent.ACTION_UP) {
+ setAlpha(1f);
+ }
+
+ return false;
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/servers/FilterModalWindow.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/servers/FilterModalWindow.java
new file mode 100644
index 0000000000..cd08118912
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/servers/FilterModalWindow.java
@@ -0,0 +1,167 @@
+package com.skywire.skycoin.vpn.activities.servers;
+
+import android.app.Dialog;
+import android.content.Context;
+import android.os.Bundle;
+import android.view.KeyEvent;
+import android.view.View;
+import android.view.Window;
+import android.view.inputmethod.EditorInfo;
+import android.widget.EditText;
+
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.controls.ModalWindowButton;
+import com.skywire.skycoin.vpn.controls.Select;
+import com.skywire.skycoin.vpn.extensible.ClickEvent;
+import com.skywire.skycoin.vpn.helpers.CountriesList;
+import com.skywire.skycoin.vpn.helpers.HelperFunctions;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+
+public class FilterModalWindow extends Dialog implements ClickEvent {
+ public static class Filters {
+ public String countryCode;
+ public String name;
+ public String location;
+ public String pk;
+ public String note;
+ }
+
+ public interface Confirmed {
+ void confirmed(Filters filters);
+ }
+
+ private Select selectCountry;
+ private EditText editName;
+ private EditText editLocation;
+ private EditText editPk;
+ private EditText editNote;
+ private ModalWindowButton buttonCancel;
+ private ModalWindowButton buttonConfirm;
+
+ private HashSet countries;
+ private Filters currentFilters;
+ private Confirmed event;
+
+ public FilterModalWindow(Context ctx, HashSet countries, Filters currentFilters, Confirmed event) {
+ super(ctx);
+
+ this.countries = countries;
+ this.currentFilters = currentFilters;
+ this.event = event;
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ requestWindowFeature(Window.FEATURE_NO_TITLE);
+ setContentView(R.layout.view_server_filters_modal);
+
+ selectCountry = findViewById(R.id.selectCountry);
+ editName = findViewById(R.id.editName);
+ editLocation = findViewById(R.id.editLocation);
+ editPk = findViewById(R.id.editPk);
+ editNote = findViewById(R.id.editNote);
+ buttonCancel = findViewById(R.id.buttonCancel);
+ buttonConfirm = findViewById(R.id.buttonConfirm);
+
+ ArrayList countryOptions = new ArrayList<>();
+ Select.SelectOption option = new Select.SelectOption();
+ option.text = getContext().getString(R.string.filter_server_any_country_option);
+ countryOptions.add(option);
+
+ Comparator comparator = (a, b) -> a.compareTo(b);
+ ArrayList countriesList = new ArrayList<>(countries);
+ Collections.sort(countriesList, comparator);
+
+ int i = 1;
+ HashMap countryIndexMap = new HashMap<>();
+ for (String countryCode : countriesList) {
+ countryCode = countryCode.toLowerCase();
+ option = new Select.SelectOption();
+ option.text = CountriesList.getCountryName(countryCode);
+ option.value = countryCode;
+ option.iconId = HelperFunctions.getFlagResourceId(countryCode);
+ countryOptions.add(option);
+
+ countryIndexMap.put(countryCode, i);
+ i++;
+ }
+
+ if (currentFilters != null) {
+ editName.setText(currentFilters.name);
+ editLocation.setText(currentFilters.location);
+ editPk.setText(currentFilters.pk);
+ editNote.setText(currentFilters.note);
+ }
+
+ editName.setSelection(editName.getText().length());
+
+ if (currentFilters != null && currentFilters.countryCode != null) {
+ int selectedIndex = countryIndexMap.containsKey(currentFilters.countryCode) ? countryIndexMap.get(currentFilters.countryCode) : 0;
+ selectCountry.setValues(countryOptions, selectedIndex);
+ } else {
+ selectCountry.setValues(countryOptions, 0);
+ }
+
+ editName.setImeOptions(EditorInfo.IME_ACTION_NEXT);
+ editLocation.setImeOptions(EditorInfo.IME_ACTION_NEXT);
+ editPk.setImeOptions(EditorInfo.IME_ACTION_NEXT);
+ editNote.setImeOptions(EditorInfo.IME_ACTION_DONE);
+
+ editNote.setOnEditorActionListener((v, actionId, event) -> {
+ if (
+ actionId == EditorInfo.IME_ACTION_DONE ||
+ (event != null && event.getAction() == KeyEvent.ACTION_DOWN && event.getKeyCode() == KeyEvent.KEYCODE_ENTER)
+ ) {
+ process();
+ dismiss();
+
+ return true;
+ }
+
+ return false;
+ });
+
+ buttonCancel.setClickEventListener(this);
+ buttonConfirm.setClickEventListener(this);
+
+ HelperFunctions.configureModalWindow(this);
+ }
+
+ @Override
+ public void onClick(View view) {
+ if (view.getId() == R.id.buttonConfirm) {
+ process();
+ }
+
+ dismiss();
+ }
+
+ private void process() {
+ if (event != null) {
+ Filters filters = new Filters();
+
+ filters.countryCode = selectCountry.getSelectedValue();
+
+ if (editName.getText() != null && !editName.getText().toString().trim().equals("")) {
+ filters.name = editName.getText().toString().trim();
+ }
+ if (editLocation.getText() != null && !editLocation.getText().toString().trim().equals("")) {
+ filters.location = editLocation.getText().toString().trim();
+ }
+ if (editPk.getText() != null && !editPk.getText().toString().trim().equals("")) {
+ filters.pk = editPk.getText().toString().trim();
+ }
+ if (editNote.getText() != null && !editNote.getText().toString().trim().equals("")) {
+ filters.note = editNote.getText().toString().trim();
+ }
+
+ event.confirmed(filters);
+ }
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/servers/ServerListButton.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/servers/ServerListButton.java
new file mode 100644
index 0000000000..fc2d4cfab5
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/servers/ServerListButton.java
@@ -0,0 +1,176 @@
+package com.skywire.skycoin.vpn.activities.servers;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.core.content.ContextCompat;
+
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.controls.BoxRowLayout;
+import com.skywire.skycoin.vpn.controls.ServerName;
+import com.skywire.skycoin.vpn.controls.SettingsButton;
+import com.skywire.skycoin.vpn.extensible.ListButtonBase;
+import com.skywire.skycoin.vpn.helpers.BoxRowTypes;
+import com.skywire.skycoin.vpn.helpers.HelperFunctions;
+
+import java.text.DateFormat;
+import java.text.SimpleDateFormat;
+
+public class ServerListButton extends ListButtonBase {
+ public static final float APROX_HEIGHT_DP = 55;
+
+ private static DateFormat dateFormat = new SimpleDateFormat("yyyy/MM/dd hh:mm a");
+
+ private BoxRowLayout mainLayout;
+ private ImageView imageFlag;
+ private ServerName serverName;
+ private TextView textDate;
+ private TextView textLocation;
+ private TextView textNote;
+ private TextView textPersonalNote;
+ private LinearLayout noteArea;
+ private LinearLayout personalNoteArea;
+ private SettingsButton buttonSettings;
+
+ // TODO: for currently commented fields, must be deleted or reactivated depending on what happens to the fields.
+ /*
+ private LinearLayout statsArea1;
+ private LinearLayout statsArea2;
+ private TextView textLatency;
+ private TextView textCongestion;
+ private TextView textHops;
+ private TextView textLatencyRating;
+ private TextView textCongestionRating;
+ */
+
+ private VpnServerForList server;
+ private ServerLists listType;
+
+ public ServerListButton (Context context) {
+ super(context);
+ }
+
+ @Override
+ protected void Initialize (Context context, AttributeSet attrs) {
+ LayoutInflater inflater = (LayoutInflater)context.getSystemService (Context.LAYOUT_INFLATER_SERVICE);
+ inflater.inflate(R.layout.view_server_list_item, this, true);
+
+ mainLayout = this.findViewById (R.id.mainLayout);
+ imageFlag = this.findViewById (R.id.imageFlag);
+ serverName = this.findViewById (R.id.serverName);
+ textDate = this.findViewById (R.id.textDate);
+ textLocation = this.findViewById (R.id.textLocation);
+ textNote = this.findViewById (R.id.textNote);
+ textPersonalNote = this.findViewById (R.id.textPersonalNote);
+ noteArea = this.findViewById (R.id.noteArea);
+ personalNoteArea = this.findViewById (R.id.personalNoteArea);
+ buttonSettings = this.findViewById (R.id.buttonSettings);
+
+ // TODO: for currently commented fields, must be deleted or reactivated depending on what happens to the fields.
+ /*
+ statsArea1 = this.findViewById (R.id.statsArea1);
+ statsArea2 = this.findViewById (R.id.statsArea2);
+ textLatency = this.findViewById (R.id.textLatency);
+ textCongestion = this.findViewById (R.id.textCongestion);
+ textHops = this.findViewById (R.id.textHops);
+ textLatencyRating = this.findViewById (R.id.textLatencyRating);
+ textCongestionRating = this.findViewById (R.id.textCongestionRating);
+ */
+
+ imageFlag.setClipToOutline(true);
+
+ buttonSettings.setClickEventListener(view -> showOptions());
+
+ setClickableBoxView(mainLayout);
+ }
+
+ public void changeData(@NonNull VpnServerForList serverData, ServerLists listType) {
+ server = serverData;
+ this.listType = listType;
+
+ imageFlag.setImageResource(HelperFunctions.getFlagResourceId(serverData.countryCode));
+ serverName.setServer(serverData, listType, false);
+
+ if (serverData.location != null && !serverData.location.trim().equals("")) {
+ String pk = serverData.pk;
+ if (pk.length() > 5) {
+ pk = pk.substring(0, 5);
+ }
+ textLocation.setText("(" + pk + ") " + serverData.location);
+ } else {
+ textLocation.setText(serverData.pk);
+ }
+
+ if (serverData.note != null && serverData.note.trim() != "") {
+ noteArea.setVisibility(VISIBLE);
+ textNote.setText(serverData.note);
+ } else {
+ noteArea.setVisibility(GONE);
+ }
+ if (serverData.personalNote != null && serverData.personalNote.trim() != "") {
+ personalNoteArea.setVisibility(VISIBLE);
+ textPersonalNote.setText(serverData.personalNote);
+ } else {
+ personalNoteArea.setVisibility(GONE);
+ }
+
+ // TODO: for currently commented fields, must be deleted or reactivated depending on what happens to the fields.
+ /*
+ if (listType == ServerLists.Public) {
+ statsArea1.setVisibility(VISIBLE);
+ statsArea2.setVisibility(VISIBLE);
+
+ textLatency.setText(HelperFunctions.getLatencyValue(serverData.latency));
+ textCongestion.setText(HelperFunctions.zeroDecimalsFormatter.format(serverData.congestion) + "%");
+ textHops.setText(serverData.hops + "");
+
+ textLatencyRating.setText(ServerRatings.getTextForRating(serverData.latencyRating));
+ textLatencyRating.setTextColor(getRatingColor(serverData.latencyRating));
+ textCongestionRating.setText(ServerRatings.getTextForRating(serverData.congestionRating));
+ textCongestionRating.setTextColor(getRatingColor(serverData.congestionRating));
+
+ textCongestion.setTextColor(HelperFunctions.getCongestionNumberColor((int)serverData.congestion));
+ textLatency.setTextColor(HelperFunctions.getLatencyNumberColor((int)serverData.latency));
+ textHops.setTextColor(HelperFunctions.getHopsNumberColor((int)serverData.hops));
+ } else {
+ statsArea1.setVisibility(GONE);
+ statsArea2.setVisibility(GONE);
+ }
+ */
+
+ if (listType == ServerLists.History) {
+ textDate.setVisibility(VISIBLE);
+ textDate.setText(dateFormat.format(serverData.lastUsed));
+ } else {
+ textDate.setVisibility(GONE);
+ }
+ }
+
+ public void setBoxRowType(BoxRowTypes type) {
+ mainLayout.setType(type);
+ }
+
+ // TODO: for currently commented fields, must be deleted or reactivated depending on what happens to the fields.
+ /*
+ private int getRatingColor(ServerRatings rating) {
+ int colorId = R.color.bronze;
+
+ if (rating == ServerRatings.Gold) {
+ colorId = R.color.gold;
+ } else if (rating == ServerRatings.Silver) {
+ colorId = R.color.silver;
+ }
+
+ return ContextCompat.getColor(getContext(), colorId);
+ }
+ */
+
+ private void showOptions() {
+ HelperFunctions.showServerOptions(getContext(), server, listType);
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/servers/ServerListOptionButton.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/servers/ServerListOptionButton.java
new file mode 100644
index 0000000000..be47576a00
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/servers/ServerListOptionButton.java
@@ -0,0 +1,53 @@
+package com.skywire.skycoin.vpn.activities.servers;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.widget.TextView;
+
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.controls.BoxRowLayout;
+import com.skywire.skycoin.vpn.extensible.ButtonBase;
+
+public class ServerListOptionButton extends ButtonBase {
+
+ private BoxRowLayout mainLayout;
+ private TextView textIcon;
+
+ public ServerListOptionButton(Context context) {
+ super(context);
+ }
+ public ServerListOptionButton(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+ public ServerListOptionButton(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ }
+
+ @Override
+ protected void Initialize (Context context, AttributeSet attrs) {
+ LayoutInflater inflater = (LayoutInflater)context.getSystemService (Context.LAYOUT_INFLATER_SERVICE);
+ inflater.inflate(R.layout.view_server_list_option_button, this, true);
+
+ mainLayout = this.findViewById (R.id.mainLayout);
+ textIcon = this.findViewById (R.id.textIcon);
+
+ if (attrs != null) {
+ TypedArray attributes = context.getTheme().obtainStyledAttributes(
+ attrs,
+ R.styleable.ServerListOptionButton,
+ 0, 0
+ );
+
+ String content = attributes.getString(R.styleable.ServerListOptionButton_content);
+ if (content != null && content.trim() != "") {
+ textIcon.setText(content);
+ }
+
+ attributes.recycle();
+ }
+
+ setClickableBoxView(mainLayout);
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/servers/ServerListOptions.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/servers/ServerListOptions.java
new file mode 100644
index 0000000000..89dbfa4329
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/servers/ServerListOptions.java
@@ -0,0 +1,121 @@
+package com.skywire.skycoin.vpn.activities.servers;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.FrameLayout;
+
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.controls.BoxRowLayout;
+import com.skywire.skycoin.vpn.extensible.ClickEvent;
+import com.skywire.skycoin.vpn.extensible.ClickWithIndexEvent;
+import com.skywire.skycoin.vpn.helpers.HelperFunctions;
+
+public class ServerListOptions extends FrameLayout implements ClickEvent {
+ public static final int filterIndex = -1;
+ public static final int addIndex = -2;
+ public static final int sortIndex = -3;
+ public static final int showPublicIndex = -10;
+ public static final int showHistoryIndex = -11;
+ public static final int showFavoritesIndex = -12;
+ public static final int showBlockedIndex = -13;
+
+ public ServerListOptions(Context context) {
+ super(context);
+ Initialize(context, null);
+ }
+ public ServerListOptions(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ Initialize(context, attrs);
+ }
+ public ServerListOptions(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ Initialize(context, attrs);
+ }
+
+ private BoxRowLayout tabsContainer;
+ private ServerListTopTab tabPublic;
+ private ServerListTopTab tabHistory;
+ private ServerListTopTab tabFavorites;
+ private ServerListTopTab tabBlocked;
+ private ServerListOptionButton buttonSort;
+ private ServerListOptionButton buttonFilter;
+ private ServerListOptionButton buttonAdd;
+
+ private ClickWithIndexEvent clickListener;
+
+ protected void Initialize (Context context, AttributeSet attrs) {
+ LayoutInflater inflater = (LayoutInflater)context.getSystemService (Context.LAYOUT_INFLATER_SERVICE);
+ View rootView = inflater.inflate(R.layout.view_server_list_options, this, true);
+
+ tabsContainer = this.findViewById (R.id.tabsContainer);
+ tabPublic = this.findViewById (R.id.tabPublic);
+ tabHistory = this.findViewById (R.id.tabHistory);
+ tabFavorites = this.findViewById (R.id.tabFavorites);
+ tabBlocked = this.findViewById (R.id.tabBlocked);
+ buttonSort = this.findViewById (R.id.buttonSort);
+ buttonFilter = this.findViewById (R.id.buttonFilter);
+ buttonAdd = this.findViewById (R.id.buttonAdd);
+
+ tabPublic.setClickEventListener(this);
+ tabHistory.setClickEventListener(this);
+ tabFavorites.setClickEventListener(this);
+ tabBlocked.setClickEventListener(this);
+ buttonSort.setClickEventListener(this);
+ buttonFilter.setClickEventListener(this);
+ buttonAdd.setClickEventListener(this);
+
+ RecyclerView.LayoutParams params = new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
+ rootView.setLayoutParams(params);
+
+ if (HelperFunctions.getWidthType(getContext()) == HelperFunctions.WidthTypes.SMALL) {
+ tabsContainer.setVisibility(GONE);
+ }
+ }
+
+ public void setClickWithIndexEventListener(ClickWithIndexEvent listener) {
+ clickListener = listener;
+ }
+
+ public void selectCorrectTab(ServerLists currentListType) {
+ tabPublic.changeState(false);
+ tabHistory.changeState(false);
+ tabFavorites.changeState(false);
+ tabBlocked.changeState(false);
+
+ if (currentListType == ServerLists.Public) {
+ tabPublic.changeState(true);
+ } else if (currentListType == ServerLists.History) {
+ tabHistory.changeState(true);
+ } else if (currentListType == ServerLists.Favorites) {
+ tabFavorites.changeState(true);
+ } else if (currentListType == ServerLists.Blocked) {
+ tabBlocked.changeState(true);
+ }
+ }
+
+ @Override
+ public void onClick(View view) {
+ if (clickListener != null) {
+ if (view.getId() == R.id.tabPublic) {
+ clickListener.onClickWithIndex(showPublicIndex, null);
+ } else if (view.getId() == R.id.tabHistory) {
+ clickListener.onClickWithIndex(showHistoryIndex, null);
+ } else if (view.getId() == R.id.tabFavorites) {
+ clickListener.onClickWithIndex(showFavoritesIndex, null);
+ } else if (view.getId() == R.id.tabBlocked) {
+ clickListener.onClickWithIndex(showBlockedIndex, null);
+ } else if (view.getId() == R.id.buttonSort) {
+ clickListener.onClickWithIndex(sortIndex, null);
+ } else if (view.getId() == R.id.buttonAdd) {
+ clickListener.onClickWithIndex(addIndex, null);
+ } else if (view.getId() == R.id.buttonFilter) {
+ clickListener.onClickWithIndex(filterIndex, null);
+ }
+ }
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/servers/ServerListTableHeader.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/servers/ServerListTableHeader.java
new file mode 100644
index 0000000000..ef33a7c910
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/servers/ServerListTableHeader.java
@@ -0,0 +1,45 @@
+package com.skywire.skycoin.vpn.activities.servers;
+
+import android.content.Context;
+import android.view.LayoutInflater;
+import android.widget.FrameLayout;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import com.skywire.skycoin.vpn.R;
+
+public class ServerListTableHeader extends FrameLayout {
+ private TextView textDate;
+
+ // TODO: for currently commented fields, must be deleted or reactivated depending on what happens to the fields.
+ // private LinearLayout statsArea;
+
+ public ServerListTableHeader(Context context) {
+ super(context);
+
+ LayoutInflater inflater = (LayoutInflater)context.getSystemService (Context.LAYOUT_INFLATER_SERVICE);
+ inflater.inflate(R.layout.view_server_list_table_header, this, true);
+
+ textDate = this.findViewById (R.id.textDate);
+
+ // TODO: for currently commented fields, must be deleted or reactivated depending on what happens to the fields.
+ // statsArea = this.findViewById (R.id.statsArea);
+ }
+
+ public void setListType(ServerLists listType) {
+ // TODO: for currently commented fields, must be deleted or reactivated depending on what happens to the fields.
+ /*
+ if (listType == ServerLists.Public) {
+ statsArea.setVisibility(VISIBLE);
+ } else {
+ statsArea.setVisibility(GONE);
+ }
+ */
+
+ if (listType == ServerLists.History) {
+ textDate.setVisibility(VISIBLE);
+ } else {
+ textDate.setVisibility(GONE);
+ }
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/servers/ServerListTableRow.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/servers/ServerListTableRow.java
new file mode 100644
index 0000000000..707081cd69
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/servers/ServerListTableRow.java
@@ -0,0 +1,162 @@
+package com.skywire.skycoin.vpn.activities.servers;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.controls.BoxRowLayout;
+import com.skywire.skycoin.vpn.controls.ServerName;
+import com.skywire.skycoin.vpn.controls.ServerNotesModalWindow;
+import com.skywire.skycoin.vpn.controls.SettingsButton;
+import com.skywire.skycoin.vpn.extensible.ListButtonBase;
+import com.skywire.skycoin.vpn.helpers.BoxRowTypes;
+import com.skywire.skycoin.vpn.helpers.HelperFunctions;
+
+import java.text.DateFormat;
+import java.text.SimpleDateFormat;
+
+public class ServerListTableRow extends ListButtonBase {
+ public static final float APROX_HEIGHT_DP = 50;
+
+ private static DateFormat dateFormat = new SimpleDateFormat("yyyy/MM/dd hh:mm a");
+
+ private BoxRowLayout mainLayout;
+ private ImageView imageFlag;
+ private ServerName serverName;
+ private TextView textDate;
+ private TextView textLocation;
+ private TextView textPk;
+ private SettingsButton buttonNote;
+ private SettingsButton buttonSettings;
+
+ // TODO: for currently commented fields, must be deleted or reactivated depending on what happens to the fields.
+ /*
+ private ImageView imageCongestionRating;
+ private ImageView imageLatencyRating;
+ private TextView textCongestion;
+ private TextView textLatency;
+ private TextView textHops;
+ private LinearLayout statsArea;
+ */
+
+ private VpnServerForList server;
+ private ServerLists listType;
+
+ public ServerListTableRow(Context context) {
+ super(context);
+ }
+
+ @Override
+ protected void Initialize (Context context, AttributeSet attrs) {
+ LayoutInflater inflater = (LayoutInflater)context.getSystemService (Context.LAYOUT_INFLATER_SERVICE);
+ inflater.inflate(R.layout.view_server_list_table_row, this, true);
+
+ mainLayout = this.findViewById (R.id.mainLayout);
+ imageFlag = this.findViewById (R.id.imageFlag);
+ serverName = this.findViewById (R.id.serverName);
+ textDate = this.findViewById (R.id.textDate);
+ textLocation = this.findViewById (R.id.textLocation);
+ textPk = this.findViewById (R.id.textPk);
+ buttonNote = this.findViewById (R.id.buttonNote);
+ buttonSettings = this.findViewById (R.id.buttonSettings);
+
+ // TODO: for currently commented fields, must be deleted or reactivated depending on what happens to the fields.
+ /*
+ imageCongestionRating = this.findViewById (R.id.imageCongestionRating);
+ imageLatencyRating = this.findViewById (R.id.imageLatencyRating);
+ textCongestion = this.findViewById (R.id.textCongestion);
+ textLatency = this.findViewById (R.id.textLatency);
+ textHops = this.findViewById (R.id.textHops);
+ statsArea = this.findViewById (R.id.statsArea);
+ */
+
+ imageFlag.setClipToOutline(true);
+
+ buttonNote.setClickEventListener(view -> showNotes());
+ buttonSettings.setClickEventListener(view -> showOptions());
+
+ setClickableBoxView(mainLayout);
+ }
+
+ public void changeData(@NonNull VpnServerForList serverData, ServerLists listType) {
+ server = serverData;
+ this.listType = listType;
+
+ imageFlag.setImageResource(HelperFunctions.getFlagResourceId(serverData.countryCode));
+ serverName.setServer(serverData, listType, false);
+
+ if (serverData.location != null && serverData.location.trim().length() > 0) {
+ textLocation.setText(serverData.location);
+ } else {
+ textLocation.setText(R.string.tmp_select_server_unknown_location);
+ }
+
+ textPk.setText(serverData.pk);
+
+ if ((serverData.note == null || serverData.note.equals("")) && (serverData.personalNote == null || serverData.personalNote.equals(""))) {
+ buttonNote.setVisibility(GONE);
+ } else {
+ buttonNote.setVisibility(VISIBLE);
+ }
+
+ // TODO: for currently commented fields, must be deleted or reactivated depending on what happens to the fields.
+ /*
+ if (listType == ServerLists.Public) {
+ statsArea.setVisibility(VISIBLE);
+
+ textCongestion.setText(HelperFunctions.zeroDecimalsFormatter.format(serverData.congestion) + "%");
+ textLatency.setText(HelperFunctions.getLatencyValue(serverData.latency));
+ textHops.setText(serverData.hops + "");
+
+ textCongestion.setTextColor(HelperFunctions.getCongestionNumberColor((int)serverData.congestion));
+ textLatency.setTextColor(HelperFunctions.getLatencyNumberColor((int)serverData.latency));
+ textHops.setTextColor(HelperFunctions.getHopsNumberColor((int)serverData.hops));
+
+ imageCongestionRating.setImageResource(getRatingResource(serverData.congestionRating));
+ imageLatencyRating.setImageResource(getRatingResource(serverData.latencyRating));
+ } else {
+ statsArea.setVisibility(GONE);
+ }
+ */
+
+ if (listType == ServerLists.History) {
+ textDate.setVisibility(VISIBLE);
+ textDate.setText(dateFormat.format(serverData.lastUsed));
+ } else {
+ textDate.setVisibility(GONE);
+ }
+ }
+
+ public void setBoxRowType(BoxRowTypes type) {
+ mainLayout.setType(type);
+ }
+
+ // TODO: for currently commented fields, must be deleted or reactivated depending on what happens to the fields.
+ // TODO: if the fields are removed, the images should be removed too.
+ /*
+ private int getRatingResource(ServerRatings rating) {
+ if (rating == ServerRatings.Gold) {
+ return R.drawable.gold_rating;
+ } else if (rating == ServerRatings.Silver) {
+ return R.drawable.silver_rating;
+ }
+
+ return R.drawable.bronze_rating;
+ }
+ */
+
+ private void showNotes() {
+ ServerNotesModalWindow modal = new ServerNotesModalWindow(getContext(), server);
+ modal.show();
+ }
+
+ private void showOptions() {
+ HelperFunctions.showServerOptions(getContext(), server, listType);
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/servers/ServerListTopTab.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/servers/ServerListTopTab.java
new file mode 100644
index 0000000000..8be2b43c74
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/servers/ServerListTopTab.java
@@ -0,0 +1,94 @@
+package com.skywire.skycoin.vpn.activities.servers;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.drawable.RippleDrawable;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.MotionEvent;
+import android.view.View;
+import android.widget.FrameLayout;
+import android.widget.TextView;
+
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.extensible.ButtonBase;
+
+public class ServerListTopTab extends ButtonBase implements View.OnTouchListener {
+ private FrameLayout mainLayout;
+ private View clickBackground;
+ private TextView text;
+
+ private RippleDrawable rippleDrawable;
+
+ public ServerListTopTab(Context context) {
+ super(context);
+ }
+ public ServerListTopTab(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+ public ServerListTopTab(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ }
+
+ @Override
+ protected void Initialize (Context context, AttributeSet attrs) {
+ LayoutInflater inflater = (LayoutInflater)context.getSystemService (Context.LAYOUT_INFLATER_SERVICE);
+ inflater.inflate(R.layout.view_server_list_top_tab, this, true);
+
+ mainLayout = this.findViewById (R.id.mainLayout);
+ clickBackground = this.findViewById (R.id.clickBackground);
+ text = this.findViewById (R.id.text);
+
+ rippleDrawable = (RippleDrawable) clickBackground.getBackground();
+
+ if (attrs != null) {
+ TypedArray attributes = context.getTheme().obtainStyledAttributes(
+ attrs,
+ R.styleable.ServerListTopTab,
+ 0, 0
+ );
+
+ int corner = attributes.getInteger(R.styleable.ServerListTopTab_position, 0);
+ if (corner != 0) {
+ if (corner == 1) {
+ mainLayout.setBackgroundResource(R.drawable.box_clip_area_left);
+ } else if (corner == 2) {
+ mainLayout.setBackgroundResource(R.drawable.box_clip_area_right);
+ }
+
+ mainLayout.setClipToOutline(true);
+ }
+
+ String txt = attributes.getString(R.styleable.ServerListTopTab_text);
+ if (txt != null && !txt.trim().equals("")) {
+ text.setText(txt);
+ }
+
+ attributes.recycle();
+ }
+
+ clickBackground.setOnTouchListener(this);
+ setViewForCheckingClicks(clickBackground);
+ }
+
+ public void changeState(boolean selected) {
+ if (selected) {
+ clickBackground.setBackgroundResource(R.color.tablet_selected_tab_background);
+ rippleDrawable = null;
+ this.setClickable(false);
+ } else {
+ clickBackground.setBackgroundResource(R.drawable.box_ripple);
+ rippleDrawable = (RippleDrawable) clickBackground.getBackground();
+ this.setClickable(true);
+ }
+ }
+
+ @Override
+ public boolean onTouch(View v, MotionEvent event) {
+ if (rippleDrawable != null) {
+ rippleDrawable.setHotspot(event.getX(), event.getY());
+ }
+
+ return false;
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/servers/ServerLists.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/servers/ServerLists.java
new file mode 100644
index 0000000000..48c6b2b807
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/servers/ServerLists.java
@@ -0,0 +1,8 @@
+package com.skywire.skycoin.vpn.activities.servers;
+
+public enum ServerLists {
+ Public,
+ History,
+ Favorites,
+ Blocked
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/servers/ServersActivity.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/servers/ServersActivity.java
new file mode 100644
index 0000000000..df46ce0362
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/servers/ServersActivity.java
@@ -0,0 +1,437 @@
+package com.skywire.skycoin.vpn.activities.servers;
+
+import android.content.SharedPreferences;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.FrameLayout;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.ProgressBar;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+import androidx.preference.PreferenceManager;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.google.gson.Gson;
+import com.skywire.skycoin.vpn.App;
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.activities.index.IndexPageAdapter;
+import com.skywire.skycoin.vpn.controls.Tab;
+import com.skywire.skycoin.vpn.extensible.ClickEvent;
+import com.skywire.skycoin.vpn.helpers.HelperFunctions;
+import com.skywire.skycoin.vpn.network.ApiClient;
+import com.skywire.skycoin.vpn.objects.LocalServerData;
+import com.skywire.skycoin.vpn.objects.ServerFlags;
+import com.skywire.skycoin.vpn.vpn.VPNCoordinator;
+import com.skywire.skycoin.vpn.vpn.VPNServersPersistentData;
+
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.concurrent.TimeUnit;
+
+import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
+import io.reactivex.rxjava3.core.Observable;
+import io.reactivex.rxjava3.disposables.Disposable;
+import io.reactivex.rxjava3.schedulers.Schedulers;
+
+public class ServersActivity extends Fragment implements VpnServersAdapter.VpnServerListEventListener, ClickEvent {
+ public static String ADDRESS_DATA_PARAM = "address";
+ private static final String ACTIVE_TAB_KEY = "activeTab";
+
+ private Tab tabPublic;
+ private Tab tabHistory;
+ private Tab tabFavorites;
+ private Tab tabBlocked;
+ private RecyclerView recycler;
+ private ProgressBar loadingAnimation;
+ private TextView textNoResults;
+ private LinearLayout noResultsContainer;
+ private LinearLayout bottomTabsContainer;
+ private FrameLayout internalContainer;
+ private ImageView ImageBottomTabsShadow;
+
+ private IndexPageAdapter.RequestTabListener requestTabListener;
+ private ServerLists listType = ServerLists.Public;
+ private VpnServersAdapter adapter;
+ private SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(App.getContext());
+
+ private Disposable serverSubscription;
+
+ @Nullable
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
+ super.onCreateView(inflater, container, savedInstanceState);
+
+ return inflater.inflate(R.layout.activity_server_list, container, true);
+ }
+
+ @Override
+ public void onViewCreated(View view, Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+
+ tabPublic = view.findViewById(R.id.tabPublic);
+ tabHistory = view.findViewById(R.id.tabHistory);
+ tabFavorites = view.findViewById(R.id.tabFavorites);
+ tabBlocked = view.findViewById(R.id.tabBlocked);
+ recycler = view.findViewById(R.id.recycler);
+ loadingAnimation = view.findViewById(R.id.loadingAnimation);
+ textNoResults = view.findViewById(R.id.textNoResults);
+ noResultsContainer = view.findViewById(R.id.noResultsContainer);
+ bottomTabsContainer = view.findViewById(R.id.bottomTabsContainer);
+ internalContainer = view.findViewById(R.id.internalContainer);
+ ImageBottomTabsShadow = view.findViewById(R.id.ImageBottomTabsShadow);
+
+ tabPublic.setClickEventListener(this);
+ tabHistory.setClickEventListener(this);
+ tabFavorites.setClickEventListener(this);
+ tabBlocked.setClickEventListener(this);
+
+ LinearLayoutManager layoutManager = new LinearLayoutManager(getContext());
+ recycler.setLayoutManager(layoutManager);
+
+ // This code retrieves the data from the server and populates the list with the recovered
+ // data, but is not used right now as the server is returning empty arrays.
+ // requestData()
+
+ noResultsContainer.setVisibility(View.GONE);
+ loadingAnimation.setVisibility(View.VISIBLE);
+
+ // Initialize the recycler.
+ adapter = new VpnServersAdapter(getContext());
+ adapter.setVpnServerListEventListener(this);
+ adapter.setData(new ArrayList<>(), listType);
+ recycler.setAdapter(adapter);
+
+ Gson gson = new Gson();
+ String savedlistType = settings.getString(ACTIVE_TAB_KEY, null);
+ if (savedlistType != null) {
+ listType = gson.fromJson(savedlistType, ServerLists.class);
+ }
+
+ showCorrectList();
+
+ if (HelperFunctions.getWidthType(getContext()) != HelperFunctions.WidthTypes.SMALL) {
+ bottomTabsContainer.setVisibility(View.GONE);
+ ImageBottomTabsShadow.setVisibility(View.GONE);
+
+ FrameLayout.LayoutParams params = (FrameLayout.LayoutParams)internalContainer.getLayoutParams();
+ params.bottomMargin = 0;
+ internalContainer.setLayoutParams(params);
+ }
+ }
+
+ public void setRequestTabListener(IndexPageAdapter.RequestTabListener listener) {
+ requestTabListener = listener;
+ }
+
+ @Override
+ public void tabChangeRequested(ServerLists newListType) {
+ if (newListType != listType) {
+ listType = newListType;
+
+ finishChangingTab();
+ }
+ }
+
+ @Override
+ public void onClick(View view) {
+ if (view.getId() == R.id.tabPublic) {
+ listType = ServerLists.Public;
+ } else if (view.getId() == R.id.tabHistory) {
+ listType = ServerLists.History;
+ } else if (view.getId() == R.id.tabFavorites) {
+ listType = ServerLists.Favorites;
+ } else if (view.getId() == R.id.tabBlocked) {
+ listType = ServerLists.Blocked;
+ }
+
+ finishChangingTab();
+ }
+
+ private void finishChangingTab() {
+ Gson gson = new Gson();
+ String listTypeString = gson.toJson(listType);
+ settings.edit()
+ .putString(ACTIVE_TAB_KEY, listTypeString)
+ .apply();
+
+ showCorrectList();
+ }
+
+ private void showCorrectList() {
+ tabPublic.changeState(false);
+ tabHistory.changeState(false);
+ tabFavorites.changeState(false);
+ tabBlocked.changeState(false);
+
+ if (listType == ServerLists.Public) {
+ tabPublic.changeState(true);
+ // Use test data, for now.
+ showTestServers();
+ } else {
+ if (listType == ServerLists.History) {
+ tabHistory.changeState(true);
+ } else if (listType == ServerLists.Favorites) {
+ tabFavorites.changeState(true);
+ } else if (listType == ServerLists.Blocked) {
+ tabBlocked.changeState(true);
+ }
+
+ requestLocalData();
+ }
+ }
+
+ private void requestData() {
+ if (serverSubscription != null) {
+ serverSubscription.dispose();
+ }
+/*
+ serverSubscription = ApiClient.getVpnServers().subscribe(response -> {
+ ArrayList list = new ArrayList<>();
+
+ for (LocalServerData server : response) {
+ list.add(convertLocalServerData(server));
+ }
+
+
+ VpnServersAdapter adapter = new VpnServersAdapter(this, response.body());
+ adapter.setVpnSelectedEventListener(this);
+ recycler.setAdapter(adapter);
+
+ // TODO: addSavedData will remove all blocked servers, so it will have to be called
+ // every time the blocked servers list changes.
+ }, err -> {
+ this.requestData();
+ });
+ */
+ }
+
+ private void requestLocalData() {
+ if (serverSubscription != null) {
+ serverSubscription.dispose();
+ }
+
+ adapter.setData(new ArrayList<>(), listType);
+ noResultsContainer.setVisibility(View.GONE);
+ loadingAnimation.setVisibility(View.VISIBLE);
+
+ Observable> request;
+ if (listType == ServerLists.History) {
+ request = VPNServersPersistentData.getInstance().history();
+ } else if (listType == ServerLists.Favorites) {
+ request = VPNServersPersistentData.getInstance().favorites();
+ } else {
+ request = VPNServersPersistentData.getInstance().blocked();
+ }
+
+ serverSubscription = request.subscribe(response -> {
+ ArrayList list = new ArrayList<>();
+
+ for (LocalServerData server : response) {
+ list.add(convertLocalServerData(server));
+ }
+
+ loadingAnimation.setVisibility(View.GONE);
+
+ adapter.setData(list, listType);
+ });
+ }
+
+ public static VpnServerForList convertLocalServerData(LocalServerData server) {
+ if (server == null) {
+ return null;
+ }
+
+ VpnServerForList converted = new VpnServerForList();
+
+ converted.countryCode = server.countryCode;
+ converted.name = server.name;
+ converted.customName = server.customName;
+ converted.location = server.location;
+ converted.pk = server.pk;
+ converted.note = server.note;
+ converted.personalNote = server.personalNote;
+ converted.lastUsed = server.lastUsed;
+ converted.inHistory = server.inHistory;
+ converted.flag = server.flag;
+ converted.enteredManually = server.enteredManually;
+ converted.hasPassword = server.password != null && !server.password.equals("");
+
+ return converted;
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ }
+
+ @Override
+ public void onDestroyView() {
+ super.onDestroyView();
+
+ if (serverSubscription != null) {
+ serverSubscription.dispose();
+ }
+ }
+
+ @Override
+ public void onVpnServerSelected(VpnServerForList selectedServer) {
+ start(VPNServersPersistentData.getInstance().processFromList(selectedServer));
+ }
+
+ @Override
+ public void onManualEntered(LocalServerData server) {
+ start(server);
+ }
+
+ @Override
+ public void listHasElements(boolean hasElements, boolean emptyBecauseFilters) {
+ if (hasElements || loadingAnimation.getVisibility() != View.GONE) {
+ noResultsContainer.setVisibility(View.GONE);
+ } else {
+ noResultsContainer.setVisibility(View.VISIBLE);
+
+ if (emptyBecauseFilters) {
+ textNoResults.setText(R.string.tmp_select_server_empty_with_filter);
+ } else {
+ if (listType == ServerLists.History) {
+ textNoResults.setText(R.string.tmp_select_server_empty_history);
+ } else if (listType == ServerLists.Favorites) {
+ textNoResults.setText(R.string.tmp_select_server_empty_favorites);
+ } else if (listType == ServerLists.Blocked) {
+ textNoResults.setText(R.string.tmp_select_server_empty_blocked);
+ } else {
+ textNoResults.setText(R.string.tmp_select_server_empty_discovery);
+ }
+ }
+ }
+ }
+
+ private void start(LocalServerData server) {
+ if (VPNCoordinator.getInstance().isServiceRunning()) {
+ HelperFunctions.showToast(getContext().getText(R.string.tmp_select_server_running_error).toString(), true);
+ return;
+ }
+
+ boolean starting = HelperFunctions.prepareAndStartVpn(getActivity(), server);
+
+ if (starting) {
+ if (requestTabListener != null) {
+ requestTabListener.onOpenStatusRequested();
+ }
+ }
+ }
+
+ private void showTestServers() {
+ ArrayList servers = new ArrayList<>();
+
+ VpnServerForList testServer = new VpnServerForList();
+ testServer.lastUsed = new Date();
+ testServer.countryCode = "au";
+ testServer.name = "Server name";
+ testServer.location = "Melbourne";
+ testServer.pk = "024ec47420176680816e0406250e7156465e4531f5b26057c9f6297bb0303558c7";
+ /*
+ testServer.congestion = 20;
+ testServer.congestionRating = ServerRatings.Gold;
+ testServer.latency = 123;
+ testServer.latencyRating = ServerRatings.Gold;
+ testServer.hops = 3;
+ */
+ testServer.note = "Note";
+ servers.add(testServer);
+
+ testServer = new VpnServerForList();
+ testServer.lastUsed = new Date();
+ testServer.countryCode = "br";
+ testServer.name = "Test server 14";
+ testServer.location = "Rio de Janeiro";
+ testServer.pk = "034ec47420176680816e0406250e7156465e4531f5b26057c9f6297bb0303558c7";
+ /*
+ testServer.congestion = 20;
+ testServer.congestionRating = ServerRatings.Silver;
+ testServer.latency = 12345;
+ testServer.latencyRating = ServerRatings.Gold;
+ testServer.hops = 3;
+ */
+ testServer.note = "Note";
+ servers.add(testServer);
+
+ testServer = new VpnServerForList();
+ testServer.lastUsed = new Date();
+ testServer.countryCode = "de";
+ testServer.name = "Test server 20";
+ testServer.location = "Berlin";
+ testServer.pk = "044ec47420176680816e0406250e7156465e4531f5b26057c9f6297bb0303558c7";
+ /*
+ testServer.congestion = 20;
+ testServer.congestionRating = ServerRatings.Gold;
+ testServer.latency = 123;
+ testServer.latencyRating = ServerRatings.Bronze;
+ testServer.hops = 7;
+ */
+ servers.add(testServer);
+
+ VPNServersPersistentData.getInstance().updateFromDiscovery(servers);
+
+ if (serverSubscription != null) {
+ serverSubscription.dispose();
+ }
+
+ adapter.setData(new ArrayList<>(), listType);
+ noResultsContainer.setVisibility(View.GONE);
+ loadingAnimation.setVisibility(View.VISIBLE);
+
+ serverSubscription = Observable.just(servers).delay(50, TimeUnit.MILLISECONDS).flatMap(serversList ->
+ VPNServersPersistentData.getInstance().history()
+ ).subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()).subscribe(r -> {
+ loadingAnimation.setVisibility(View.GONE);
+
+ ArrayList serversCopy = new ArrayList<>(servers);
+
+ removeSavedData(serversCopy);
+ addSavedData(serversCopy);
+ adapter.setData(serversCopy, ServerLists.Public);
+ });
+
+ }
+
+ private void addSavedData(ArrayList servers) {
+ ArrayList remove = new ArrayList();
+ for (VpnServerForList server : servers) {
+ LocalServerData savedVersion = VPNServersPersistentData.getInstance().getSavedVersion(server.pk);
+
+ if (savedVersion != null) {
+ server.customName = savedVersion.customName;
+ server.personalNote = savedVersion.personalNote;
+ server.inHistory = savedVersion.inHistory;
+ server.flag = savedVersion.flag;
+ server.enteredManually = savedVersion.enteredManually;
+ server.hasPassword = savedVersion.password != null && !savedVersion.password.equals("");
+ }
+
+ if (server.flag == ServerFlags.Blocked) {
+ remove.add(server);
+ }
+ }
+
+ servers.removeAll(remove);
+ }
+
+ private void removeSavedData(ArrayList servers) {
+ for (VpnServerForList server : servers) {
+ server.customName = null;
+ server.personalNote = null;
+ server.inHistory = false;
+ server.flag = ServerFlags.None;
+ server.enteredManually = false;
+ server.hasPassword = false;
+ }
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/servers/VpnServerForList.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/servers/VpnServerForList.java
new file mode 100644
index 0000000000..e96eaed8dd
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/servers/VpnServerForList.java
@@ -0,0 +1,32 @@
+package com.skywire.skycoin.vpn.activities.servers;
+
+import com.skywire.skycoin.vpn.objects.ServerFlags;
+
+// TODO: for currently commented fields, must be deleted or reactivated depending on what happens to the fields.
+// import com.skywire.skycoin.vpn.objects.ServerRatings;
+
+import java.util.Date;
+
+public class VpnServerForList {
+ public String countryCode;
+ public String name;
+ public String customName;
+ public String location;
+ public String pk;
+ public String note;
+ public String personalNote;
+ public Date lastUsed;
+ public boolean inHistory;
+ public ServerFlags flag;
+ public boolean hasPassword;
+ public boolean enteredManually;
+
+ // TODO: for currently commented fields, must be deleted or reactivated depending on what happens to the fields.
+ /*
+ public double congestion;
+ public ServerRatings congestionRating;
+ public double latency;
+ public ServerRatings latencyRating;
+ public int hops;
+ */
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/servers/VpnServersAdapter.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/servers/VpnServersAdapter.java
new file mode 100644
index 0000000000..8fe13b7247
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/servers/VpnServersAdapter.java
@@ -0,0 +1,559 @@
+package com.skywire.skycoin.vpn.activities.servers;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.controls.ManualServerModalWindow;
+import com.skywire.skycoin.vpn.controls.options.OptionsItem;
+import com.skywire.skycoin.vpn.controls.options.OptionsModalWindow;
+import com.skywire.skycoin.vpn.extensible.ClickWithIndexEvent;
+import com.skywire.skycoin.vpn.extensible.ListViewHolder;
+import com.skywire.skycoin.vpn.helpers.BoxRowTypes;
+import com.skywire.skycoin.vpn.helpers.HelperFunctions;
+import com.skywire.skycoin.vpn.objects.LocalServerData;
+import com.skywire.skycoin.vpn.vpn.VPNCoordinator;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.List;
+
+public class VpnServersAdapter extends RecyclerView.Adapter> implements ClickWithIndexEvent {
+ public interface VpnServerListEventListener {
+ void onVpnServerSelected(VpnServerForList selectedServer);
+ void onManualEntered(LocalServerData server);
+ void listHasElements(boolean hasElements, boolean emptyBecauseFilters);
+ void tabChangeRequested(ServerLists newListType);
+ }
+
+ public enum SortableColumns {
+ AUTOMATIC,
+ DATE,
+ COUNTRY,
+ NAME,
+ LOCATION,
+ PK,
+ /*
+ // TODO: for currently commented fields, must be deleted or reactivated depending on what happens to the fields.
+ CONGESTION,
+ CONGESTION_RATING,
+ LATENCY,
+ LATENCY_RATING,
+ HOPS,
+ */
+ NOTE;
+
+ public static int getColumnNameId(SortableColumns column) {
+ if (column == SortableColumns.NAME) {
+ return R.string.tmp_select_server_name_label;
+ } else if (column == SortableColumns.DATE) {
+ return R.string.tmp_select_server_date_label;
+ } else if (column == SortableColumns.COUNTRY) {
+ return R.string.tmp_select_server_country_label;
+ } else if (column == SortableColumns.LOCATION) {
+ return R.string.tmp_select_server_location_label;
+ } else if (column == SortableColumns.PK) {
+ return R.string.tmp_select_server_public_key_label;
+ /*
+ // TODO: for currently commented fields, must be deleted or reactivated depending on what happens to the fields.
+ } else if (column == SortableColumns.CONGESTION) {
+ return R.string.tmp_select_server_congestion_label;
+ } else if (column == SortableColumns.CONGESTION_RATING) {
+ return R.string.tmp_select_server_congestion_rating_label;
+ } else if (column == SortableColumns.LATENCY) {
+ return R.string.tmp_select_server_latency_label;
+ } else if (column == SortableColumns.LATENCY_RATING) {
+ return R.string.tmp_select_server_latency_rating_label;
+ } else if (column == SortableColumns.HOPS) {
+ return R.string.tmp_select_server_hops_label;
+ */
+ } else {
+ return R.string.tmp_select_server_note_label;
+ }
+ }
+ }
+
+ private Context context;
+ private List data;
+ private List filteredData;
+ private ServerLists listType = ServerLists.Public;
+ private VpnServerListEventListener listEventListener;
+ private boolean showingRows;
+ private int initialServerIndex;
+
+ private ArrayList filters;
+ private ConditionsList conditionsView;
+
+ private ArrayList sortBy;
+ private ArrayList sortInverse;
+
+ private ArrayList premadeButtons = new ArrayList<>();
+ private ArrayList premadeRows = new ArrayList<>();
+ private int lastUsedPremadeButtonIdex = 0;
+
+ private ServerListOptions listOptionsView;
+ private ServerListTableHeader tableHeader;
+
+ public VpnServersAdapter(Context context) {
+ this.context = context;
+
+ int screenHeightInDP = (int)(Resources.getSystem().getDisplayMetrics().heightPixels / context.getResources().getDisplayMetrics().density);
+ showingRows = HelperFunctions.getWidthType(context) != HelperFunctions.WidthTypes.SMALL;
+
+ if (!showingRows) {
+ int aproxButtonsToFillScreen = (int)Math.ceil((screenHeightInDP / ServerListButton.APROX_HEIGHT_DP) * 1.3);
+ for (int i = 0; i < aproxButtonsToFillScreen; i++) {
+ premadeButtons.add(createNewServerButton());
+ }
+ initialServerIndex = 2;
+ } else {
+ int aproxButtonsToFillScreen = (int)Math.ceil((screenHeightInDP / ServerListTableRow.APROX_HEIGHT_DP) * 1.3);
+ for (int i = 0; i < aproxButtonsToFillScreen; i++) {
+ premadeRows.add(createNewServerRow());
+ }
+ initialServerIndex = 3;
+ }
+ }
+
+ public void setData(List data, ServerLists listType) {
+ this.data = data;
+ this.listType = listType;
+
+ if (listOptionsView != null) {
+ listOptionsView.selectCorrectTab(listType);
+ }
+
+ if (tableHeader != null) {
+ tableHeader.setListType(listType);
+ }
+
+ processData();
+ }
+
+ private void processData() {
+ if (filters == null) {
+ filters = new ArrayList<>();
+ sortBy = new ArrayList<>();
+ sortInverse = new ArrayList<>();
+
+ for (int i = 0; i < 4; i++) {
+ filters.add(null);
+ sortBy.add(SortableColumns.AUTOMATIC);
+ sortInverse.add(false);
+ }
+ }
+
+ FilterModalWindow.Filters currentFilters = filters.get(getCurrentListTypeIntVal());
+
+ if (currentFilters == null) {
+ filteredData = data;
+ } else {
+ filteredData = new ArrayList<>();
+
+ for (VpnServerForList element : data) {
+ boolean valid = true;
+
+ if (valid && currentFilters.countryCode != null && !currentFilters.countryCode.equals("")) {
+ String elementVal = element.countryCode != null ? element.countryCode.toUpperCase() : "";
+ if (!elementVal.equals(currentFilters.countryCode.toUpperCase())) {
+ valid = false;
+ }
+ }
+
+ if (valid && currentFilters.name != null && !currentFilters.name.equals("")) {
+ if (!HelperFunctions.getServerName(element, "").toUpperCase().contains(currentFilters.name.toUpperCase())) {
+ valid = false;
+ }
+ }
+
+ if (valid && currentFilters.location != null && !currentFilters.location.equals("")) {
+ String elementVal = element.location != null ? element.location.toUpperCase() : "";
+ if (!elementVal.contains(currentFilters.location.toUpperCase())) {
+ valid = false;
+ }
+ }
+
+ if (valid && currentFilters.pk != null && !currentFilters.pk.equals("")) {
+ if (!element.pk.toUpperCase().contains(currentFilters.pk.toUpperCase())) {
+ valid = false;
+ }
+ }
+
+ if (valid && currentFilters.note != null && !currentFilters.note.equals("")) {
+ String elementVal1 = element.note != null ? element.note.toUpperCase() : "";
+ String elementVal2 = element.personalNote != null ? element.personalNote.toUpperCase() : "";
+ String filterVal = currentFilters.note.toUpperCase();
+ if (!elementVal1.contains(filterVal) && !elementVal2.contains(filterVal)) {
+ valid = false;
+ }
+ }
+
+ if (valid) {
+ filteredData.add(element);
+ }
+ }
+ }
+
+ if (listEventListener != null) {
+ if (data.size() == 0) {
+ listEventListener.listHasElements(false, false);
+ } else {
+ if (filteredData.size() == 0) {
+ listEventListener.listHasElements(false, true);
+ } else {
+ listEventListener.listHasElements(true, false);
+ }
+ }
+ }
+
+ sortList();
+ }
+
+ private void sortList() {
+ if (conditionsView != null) {
+ conditionsView.setConditions(sortBy.get(getCurrentListTypeIntVal()), sortInverse.get(getCurrentListTypeIntVal()), filters.get(getCurrentListTypeIntVal()));
+ }
+
+ Comparator comparator = (a, b) -> {
+ SortableColumns sortColumn = sortBy.get(getCurrentListTypeIntVal());
+
+ if (sortColumn == SortableColumns.AUTOMATIC) {
+ if (listType == ServerLists.History) {
+ sortColumn = SortableColumns.DATE;
+ } else {
+ sortColumn = SortableColumns.COUNTRY;
+ }
+ }
+
+ int result = 0;
+ if (sortColumn == SortableColumns.DATE) {
+ result = (int)((b.lastUsed.getTime() - a.lastUsed.getTime()) / 1000);
+ } else if (sortColumn == SortableColumns.COUNTRY) {
+ result = a.countryCode.compareTo(b.countryCode);
+ } else if (sortColumn == SortableColumns.NAME) {
+ result = HelperFunctions.getServerName(a, "").compareTo(HelperFunctions.getServerName(b, ""));
+ } else if (sortColumn == SortableColumns.LOCATION) {
+ result = (a.location != null ? a.location : "").compareTo((b.location != null ? b.location : ""));
+ } else if (sortColumn == SortableColumns.PK) {
+ result = (a.pk != null ? a.pk : "").compareTo((b.pk != null ? b.pk : ""));
+ /*
+ // TODO: for currently commented fields, must be deleted or reactivated depending on what happens to the fields.
+ } else if (sortColumn == SortableColumns.CONGESTION) {
+ result = (int)(a.congestion - b.congestion);
+ } else if (sortColumn == SortableColumns.CONGESTION_RATING) {
+ result = ServerRatings.getNumberForRating(b.congestionRating) - ServerRatings.getNumberForRating(a.congestionRating);
+ } else if (sortColumn == SortableColumns.LATENCY) {
+ result = (int)(a.latency - b.latency);
+ } else if (sortColumn == SortableColumns.LATENCY_RATING) {
+ result = ServerRatings.getNumberForRating(b.latencyRating) - ServerRatings.getNumberForRating(a.latencyRating);
+ } else if (sortColumn == SortableColumns.HOPS) {
+ result = (int)(a.hops - b.hops);
+ */
+ } else if (sortColumn == SortableColumns.NOTE) {
+ String noteA = ((a.note != null ? a.note : "") + " " + (a.personalNote != null ? a.personalNote : "")).trim();
+ String noteB = ((b.note != null ? b.note : "") + " " + (b.personalNote != null ? b.personalNote : "")).trim();
+ if (noteA.equals("") && !noteB.equals("")) {
+ result = 1;
+ } else if (noteB.equals("") && !noteA.equals("")) {
+ result = -1;
+ } else {
+ result = noteA.compareTo(noteB);
+ }
+ }
+
+ if (result == 0 && sortColumn != SortableColumns.NAME) {
+ result = HelperFunctions.getServerName(a, "").compareTo(HelperFunctions.getServerName(b, ""));
+ }
+
+ if (result == 0 && sortColumn != SortableColumns.PK) {
+ result = (a.pk != null ? a.pk : "").compareTo((b.pk != null ? b.pk : ""));
+ }
+
+ boolean mustSortInverse = sortInverse.get(getCurrentListTypeIntVal());
+
+ if (mustSortInverse) {
+ result *= -1;
+ }
+
+ return result;
+ };
+
+ Collections.sort(filteredData, comparator);
+
+ this.notifyDataSetChanged();
+ }
+
+ private int getCurrentListTypeIntVal() {
+ if (listType == ServerLists.Public) {
+ return 0;
+ } else if (listType == ServerLists.History) {
+ return 1;
+ } else if (listType == ServerLists.Favorites) {
+ return 2;
+ }
+
+ return 3;
+ }
+
+ public void setVpnServerListEventListener(VpnServerListEventListener listener) {
+ listEventListener = listener;
+ }
+
+ @Override
+ public int getItemViewType(int position) {
+ if (position == 0) {
+ return 0;
+ } else if (position == 1) {
+ return 1;
+ } else if (position == 2 && showingRows) {
+ return 3;
+ }
+
+ return 2;
+ }
+
+ @NonNull
+ @Override
+ public ListViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ if (viewType == 0) {
+ listOptionsView = new ServerListOptions(context);
+ listOptionsView.setClickWithIndexEventListener(this);
+ listOptionsView.selectCorrectTab(listType);
+ return new ListViewHolder<>(listOptionsView);
+ } else if (viewType == 1) {
+ conditionsView = new ConditionsList(context);
+ conditionsView.setConditions(sortBy.get(getCurrentListTypeIntVal()), sortInverse.get(getCurrentListTypeIntVal()), filters.get(getCurrentListTypeIntVal()));
+
+ conditionsView.setClickEventListener(v -> {
+ if (conditionsView.showingFilters() && conditionsView.showingOrder()) {
+ ArrayList options = new ArrayList();
+
+ OptionsItem.SelectableOption option = new OptionsItem.SelectableOption();
+ option.translatableLabelId = R.string.tmp_select_server_remove_filters_button;
+ options.add(option);
+
+ option = new OptionsItem.SelectableOption();
+ option.translatableLabelId = R.string.tmp_select_server_remove_custom_sorting_button;
+ options.add(option);
+
+ option = new OptionsItem.SelectableOption();
+ option.translatableLabelId = R.string.tmp_select_server_remove_both_button;
+ options.add(option);
+
+ OptionsModalWindow modal = new OptionsModalWindow(context, null, options, (int selectedOption) -> {
+ if (selectedOption == 0 || selectedOption == 2) {
+ filters.set(getCurrentListTypeIntVal(), null);
+ }
+ if (selectedOption == 1 || selectedOption == 2) {
+ sortBy.set(getCurrentListTypeIntVal(), SortableColumns.AUTOMATIC);
+ sortInverse.set(getCurrentListTypeIntVal(), false);
+ }
+
+ processData();
+ });
+
+ modal.show();
+ } else if (conditionsView.showingFilters()) {
+ filters.set(getCurrentListTypeIntVal(), null);
+ processData();
+ } else if (conditionsView.showingOrder()) {
+ sortBy.set(getCurrentListTypeIntVal(), SortableColumns.AUTOMATIC);
+ sortInverse.set(getCurrentListTypeIntVal(), false);
+ processData();
+ }
+ });
+
+ return new ListViewHolder<>(conditionsView);
+ } else if (viewType == 3) {
+ tableHeader = new ServerListTableHeader(context);
+ tableHeader.setListType(listType);
+ return new ListViewHolder<>(tableHeader);
+ }
+
+ if (!showingRows) {
+ ServerListButton view;
+ if (lastUsedPremadeButtonIdex < premadeButtons.size()) {
+ view = premadeButtons.get(lastUsedPremadeButtonIdex);
+ lastUsedPremadeButtonIdex += 1;
+ } else {
+ view = createNewServerButton();
+ }
+
+ return new ListViewHolder<>(view);
+ } else {
+ ServerListTableRow view;
+ if (lastUsedPremadeButtonIdex < premadeRows.size()) {
+ view = premadeRows.get(lastUsedPremadeButtonIdex);
+ lastUsedPremadeButtonIdex += 1;
+ } else {
+ view = createNewServerRow();
+ }
+
+ return new ListViewHolder<>(view);
+ }
+ }
+
+ private ServerListButton createNewServerButton() {
+ ServerListButton view = new ServerListButton(context);
+ view.setClickWithIndexEventListener(this);
+ return view;
+ }
+
+ private ServerListTableRow createNewServerRow() {
+ ServerListTableRow view = new ServerListTableRow(context);
+ view.setClickWithIndexEventListener(this);
+ return view;
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull ListViewHolder holder, int position) {
+ if (position >= initialServerIndex) {
+ position -= initialServerIndex;
+
+ if (!showingRows) {
+ ((ServerListButton) holder.itemView).setIndex(position);
+ ((ServerListButton) holder.itemView).changeData(filteredData.get(position), listType);
+
+ if (filteredData.size() == 1) {
+ ((ServerListButton) holder.itemView).setBoxRowType(BoxRowTypes.SINGLE);
+ } else if (position == 0) {
+ ((ServerListButton) holder.itemView).setBoxRowType(BoxRowTypes.TOP);
+ } else if (position == filteredData.size() - 1) {
+ ((ServerListButton) holder.itemView).setBoxRowType(BoxRowTypes.BOTTOM);
+ } else {
+ ((ServerListButton) holder.itemView).setBoxRowType(BoxRowTypes.MIDDLE);
+ }
+ } else {
+ ((ServerListTableRow) holder.itemView).setIndex(position);
+ ((ServerListTableRow) holder.itemView).changeData(filteredData.get(position), listType);
+
+ if (position == filteredData.size() - 1) {
+ ((ServerListTableRow) holder.itemView).setBoxRowType(BoxRowTypes.BOTTOM);
+ } else {
+ ((ServerListTableRow) holder.itemView).setBoxRowType(BoxRowTypes.MIDDLE);
+ }
+ }
+ }
+ }
+
+ @Override
+ public int getItemCount() {
+ if (!showingRows) {
+ return filteredData != null ? (filteredData.size() + 2) : 2;
+ }
+
+ if (filteredData == null || filteredData.size() == 0) {
+ return 2;
+ }
+ return filteredData.size() + 3;
+ }
+
+ @Override
+ public void onClickWithIndex(int index, Void data) {
+ if (listEventListener != null) {
+ if (index >= 0) {
+ listEventListener.onVpnServerSelected(this.filteredData.get(index));
+ } else {
+ if (index <= ServerListOptions.showPublicIndex) {
+ if (index == ServerListOptions.showPublicIndex) {
+ listEventListener.tabChangeRequested(ServerLists.Public);
+ } else if (index == ServerListOptions.showHistoryIndex) {
+ listEventListener.tabChangeRequested(ServerLists.History);
+ } else if (index == ServerListOptions.showFavoritesIndex) {
+ listEventListener.tabChangeRequested(ServerLists.Favorites);
+ } else if (index == ServerListOptions.showBlockedIndex) {
+ listEventListener.tabChangeRequested(ServerLists.Blocked);
+ }
+ } else if (index == ServerListOptions.sortIndex) {
+ SortableColumns currentSortBy = sortBy.get(getCurrentListTypeIntVal());
+ boolean currentSortInverse = sortInverse.get(getCurrentListTypeIntVal());
+
+ ArrayList optionValues = new ArrayList();
+ if (listType == ServerLists.History) {
+ optionValues.add(SortableColumns.DATE);
+ }
+ optionValues.add(SortableColumns.NAME);
+ optionValues.add(SortableColumns.COUNTRY);
+ optionValues.add(SortableColumns.LOCATION);
+ optionValues.add(SortableColumns.PK);
+ /*
+ // TODO: for currently commented fields, must be deleted or reactivated depending on what happens to the fields.
+ if (listType == ServerLists.Public) {
+ optionValues.add(SortableColumns.CONGESTION);
+ optionValues.add(SortableColumns.CONGESTION_RATING);
+ optionValues.add(SortableColumns.LATENCY);
+ optionValues.add(SortableColumns.LATENCY_RATING);
+ optionValues.add(SortableColumns.HOPS);
+ }
+ */
+ optionValues.add(SortableColumns.NOTE);
+
+ ArrayList options = new ArrayList();
+ OptionsItem.SelectableOption option = new OptionsItem.SelectableOption();
+ option.translatableLabelId = R.string.tmp_select_server_automatic_label;
+ if (currentSortBy == SortableColumns.AUTOMATIC) {
+ option.icon = "\ue876";
+ }
+ options.add(option);
+
+ for(int i = 0; i < optionValues.size(); i++) {
+ option = new OptionsItem.SelectableOption();
+ option.translatableLabelId = SortableColumns.getColumnNameId(optionValues.get(i));
+ if (optionValues.get(i) == currentSortBy && !currentSortInverse) {
+ option.icon = "\ue876";
+ }
+ options.add(option);
+
+ option = new OptionsItem.SelectableOption();
+ option.label = context.getText(SortableColumns.getColumnNameId(optionValues.get(i))) + " " + context.getText(R.string.tmp_select_server_reversed_suffix);
+ if (optionValues.get(i) == currentSortBy && currentSortInverse) {
+ option.icon = "\ue876";
+ }
+ options.add(option);
+ }
+
+ OptionsModalWindow modal = new OptionsModalWindow(context, context.getString(R.string.tmp_select_server_sort_title), options, (int selectedOption) -> {
+ if (selectedOption == 0) {
+ sortBy.set(getCurrentListTypeIntVal(), SortableColumns.AUTOMATIC);
+ sortInverse.set(getCurrentListTypeIntVal(), false);
+ } else {
+ selectedOption -= 1;
+ sortBy.set(getCurrentListTypeIntVal(), optionValues.get((int)(selectedOption / 2)));
+ sortInverse.set(getCurrentListTypeIntVal(), selectedOption % 2 != 0);
+ }
+
+ sortList();
+ });
+
+ modal.show();
+ } else if (index == ServerListOptions.addIndex) {
+ if (VPNCoordinator.getInstance().isServiceRunning()) {
+ HelperFunctions.showToast(context.getText(R.string.tmp_select_server_running_error).toString(), true);
+ return;
+ }
+
+ ManualServerModalWindow modal = new ManualServerModalWindow(context, server -> listEventListener.onManualEntered(server));
+ modal.show();
+ } else if (index == ServerListOptions.filterIndex) {
+ HashSet countries = new HashSet<>();
+ for (VpnServerForList element : this.data) {
+ countries.add(element.countryCode);
+ }
+
+ FilterModalWindow modal = new FilterModalWindow(context, countries, filters.get(getCurrentListTypeIntVal()), newFilters -> {
+ filters.set(getCurrentListTypeIntVal(), newFilters);
+ processData();
+ });
+ modal.show();
+ }
+ }
+ }
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/settings/CustomDnsModalWindow.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/settings/CustomDnsModalWindow.java
new file mode 100644
index 0000000000..9a5639faa4
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/settings/CustomDnsModalWindow.java
@@ -0,0 +1,108 @@
+package com.skywire.skycoin.vpn.activities.settings;
+
+import android.app.Dialog;
+import android.content.Context;
+import android.os.Bundle;
+import android.view.KeyEvent;
+import android.view.View;
+import android.view.Window;
+import android.view.inputmethod.EditorInfo;
+import android.widget.EditText;
+
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.controls.ModalWindowButton;
+import com.skywire.skycoin.vpn.extensible.ClickEvent;
+import com.skywire.skycoin.vpn.helpers.HelperFunctions;
+import com.skywire.skycoin.vpn.vpn.VPNGeneralPersistentData;
+
+import java.util.regex.Matcher;
+
+import static androidx.core.util.PatternsCompat.IP_ADDRESS;
+
+public class CustomDnsModalWindow extends Dialog implements ClickEvent {
+ public interface Confirmed {
+ void confirmed(String newIp);
+ }
+
+ private EditText editValue;
+ private ModalWindowButton buttonCancel;
+ private ModalWindowButton buttonConfirm;
+
+ private Confirmed event;
+
+ public CustomDnsModalWindow(Context ctx, Confirmed event) {
+ super(ctx);
+
+ this.event = event;
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ requestWindowFeature(Window.FEATURE_NO_TITLE);
+ setContentView(R.layout.view_settings_dns_modal);
+
+ editValue = findViewById(R.id.editValue);
+ buttonCancel = findViewById(R.id.buttonCancel);
+ buttonConfirm = findViewById(R.id.buttonConfirm);
+
+ String currentServer = VPNGeneralPersistentData.getCustomDns();
+ if (currentServer != null) {
+ editValue.setText(currentServer);
+ }
+
+ editValue.setOnEditorActionListener((v, actionId, event) -> {
+ if (
+ actionId == EditorInfo.IME_ACTION_DONE ||
+ (event != null && event.getAction() == KeyEvent.ACTION_DOWN && event.getKeyCode() == KeyEvent.KEYCODE_ENTER)
+ ) {
+ makeChange();
+
+ return true;
+ }
+
+ return false;
+ });
+
+ editValue.setSelection(editValue.getText().length());
+
+ buttonCancel.setClickEventListener(this);
+ buttonConfirm.setClickEventListener(this);
+
+ HelperFunctions.configureModalWindow(this);
+ }
+
+ @Override
+ public void onClick(View view) {
+ if (view.getId() == R.id.buttonConfirm) {
+ makeChange();
+ } else {
+ dismiss();
+ }
+ }
+
+ private void makeChange() {
+ boolean valid = false;
+ String ip = null;
+
+ if (editValue.getText() == null || editValue.getText().toString().trim().length() == 0) {
+ valid = true;
+ } else {
+ ip = editValue.getText().toString().trim();
+ Matcher matcher = IP_ADDRESS.matcher(ip);
+ if (matcher.matches()) {
+ valid = true;
+ }
+ }
+
+ if (valid) {
+ if (event != null) {
+ event.confirmed(ip);
+ }
+
+ dismiss();
+ } else {
+ HelperFunctions.showToast(getContext().getString(R.string.tmp_dns_validation_error), true);
+ }
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/settings/SettingsActivity.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/settings/SettingsActivity.java
new file mode 100644
index 0000000000..e079385c54
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/settings/SettingsActivity.java
@@ -0,0 +1,196 @@
+package com.skywire.skycoin.vpn.activities.settings;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+
+import com.skywire.skycoin.vpn.activities.apps.AppsActivity;
+import com.skywire.skycoin.vpn.controls.options.OptionsItem;
+import com.skywire.skycoin.vpn.controls.options.OptionsModalWindow;
+import com.skywire.skycoin.vpn.extensible.ClickEvent;
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.helpers.Globals;
+import com.skywire.skycoin.vpn.helpers.HelperFunctions;
+import com.skywire.skycoin.vpn.vpn.VPNCoordinator;
+import com.skywire.skycoin.vpn.vpn.VPNGeneralPersistentData;
+import com.skywire.skycoin.vpn.vpn.VPNServersPersistentData;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+
+public class SettingsActivity extends Fragment implements ClickEvent {
+ private SettingsOption optionApps;
+ private SettingsOption optionShowIp;
+ private SettingsOption optionKillSwitch;
+ private SettingsOption optionResetAfterErrors;
+ private SettingsOption optionProtectBeforeConnecting;
+ private SettingsOption optionStartOnBoot;
+ private SettingsOption optionDataUnits;
+ private SettingsOption optionDns;
+
+ // Units that must be used for displaying the data stats.
+ private Globals.DataUnits dataUnitsOption = VPNGeneralPersistentData.getDataUnits();
+
+ @Nullable
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
+ super.onCreateView(inflater, container, savedInstanceState);
+
+ return inflater.inflate(R.layout.activity_settings, container, true);
+ }
+
+ @Override
+ public void onViewCreated(View view, Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+
+ optionApps = view.findViewById(R.id.optionApps);
+ optionShowIp = view.findViewById(R.id.optionShowIp);
+ optionKillSwitch = view.findViewById(R.id.optionKillSwitch);
+ optionResetAfterErrors = view.findViewById(R.id.optionResetAfterErrors);
+ optionProtectBeforeConnecting = view.findViewById(R.id.optionProtectBeforeConnecting);
+ optionStartOnBoot = view.findViewById(R.id.optionStartOnBoot);
+ optionDataUnits = view.findViewById(R.id.optionDataUnits);
+ optionDns = view.findViewById(R.id.optionDns);
+
+ optionShowIp.setChecked(VPNGeneralPersistentData.getShowIpActivated());
+ optionKillSwitch.setChecked(VPNGeneralPersistentData.getKillSwitchActivated());
+ optionResetAfterErrors.setChecked(VPNGeneralPersistentData.getMustRestartVpn());
+ optionProtectBeforeConnecting.setChecked(VPNGeneralPersistentData.getProtectBeforeConnected());
+ optionStartOnBoot.setChecked(VPNGeneralPersistentData.getStartOnBoot());
+
+ optionApps.setClickEventListener(this);
+ optionShowIp.setClickEventListener(this);
+ optionKillSwitch.setClickEventListener(this);
+ optionResetAfterErrors.setClickEventListener(this);
+ optionProtectBeforeConnecting.setClickEventListener(this);
+ optionStartOnBoot.setClickEventListener(this);
+ optionDataUnits.setClickEventListener(this);
+ optionDns.setClickEventListener(this);
+
+ optionDataUnits.setDescription(getUnitsOptionText(dataUnitsOption), null);
+
+ setDnsOptionText(VPNGeneralPersistentData.getCustomDns());
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+
+ Globals.AppFilteringModes appsMode = VPNGeneralPersistentData.getAppsSelectionMode();
+ if (appsMode == Globals.AppFilteringModes.PROTECT_ALL) {
+ optionApps.setDescription(R.string.tmp_options_apps_description, null);
+ optionApps.setChecked(false);
+ optionApps.changeAlertIconVisibility(false);
+ } else {
+ HashSet selectedApps = HelperFunctions.filterAvailableApps(VPNGeneralPersistentData.getAppList(new HashSet<>()));
+
+ if (appsMode == Globals.AppFilteringModes.PROTECT_SELECTED) {
+ optionApps.setDescription(R.string.tmp_options_apps_include_description, selectedApps.size() + "");
+ } else if (appsMode == Globals.AppFilteringModes.IGNORE_SELECTED) {
+ optionApps.setDescription(R.string.tmp_options_apps_exclude_description, selectedApps.size() + "");
+ }
+
+ optionApps.setChecked(true);
+ optionApps.changeAlertIconVisibility(true);
+ }
+ }
+
+ /**
+ * Gets the ID of the string for a data units selection.
+ */
+ private int getUnitsOptionText(Globals.DataUnits units) {
+ if (units == Globals.DataUnits.OnlyBits) {
+ return R.string.tmp_options_data_units_only_bits;
+ } else if (units == Globals.DataUnits.OnlyBytes) {
+ return R.string.tmp_options_data_units_only_bytes;
+ }
+
+ return R.string.tmp_options_data_units_bits_speed_and_bytes_volume;
+ }
+
+ private void setDnsOptionText(String customIp) {
+ if (customIp == null || customIp.trim().length() == 0) {
+ optionDns.setDescription(R.string.tmp_options_dns_default, null);
+ optionDns.changeAlertIconVisibility(false);
+ } else {
+ optionDns.setDescription(R.string.tmp_options_dns_description, customIp);
+ optionDns.changeAlertIconVisibility(true);
+ }
+ }
+
+ @Override
+ public void onClick(View view) {
+ if (view.getId() == R.id.optionDataUnits) {
+ ArrayList options = new ArrayList();
+ Globals.DataUnits[] unitOptions = new Globals.DataUnits[3];
+ unitOptions[0] = Globals.DataUnits.BitsSpeedAndBytesVolume;
+ unitOptions[1] = Globals.DataUnits.OnlyBytes;
+ unitOptions[2] = Globals.DataUnits.OnlyBits;
+
+ for (Globals.DataUnits unitOption : unitOptions) {
+ OptionsItem.SelectableOption option = new OptionsItem.SelectableOption();
+ option.icon = dataUnitsOption == unitOption ? "\ue876" : null;
+ option.translatableLabelId = getUnitsOptionText(unitOption);
+ options.add(option);
+ }
+
+ OptionsModalWindow modal = new OptionsModalWindow(getContext(), null, options, (int selectedOption) -> {
+ dataUnitsOption = unitOptions[selectedOption];
+ optionDataUnits.setDescription(getUnitsOptionText(dataUnitsOption), null);
+ VPNGeneralPersistentData.setDataUnits(dataUnitsOption);
+ });
+ modal.show();
+
+ return;
+ }
+
+ if (VPNCoordinator.getInstance().isServiceRunning()) {
+ HelperFunctions.showToast(getContext().getText(R.string.general_server_running_error).toString(), true);
+
+ return;
+ }
+
+ if (view.getId() == R.id.optionApps) {
+ Intent intent = new Intent(getContext(), AppsActivity.class);
+ startActivity(intent);
+
+ return;
+ }
+
+ if (view.getId() == R.id.optionDns) {
+ CustomDnsModalWindow modal = new CustomDnsModalWindow(getContext(), (String newIp) -> {
+ VPNGeneralPersistentData.setCustomDns(newIp);
+ setDnsOptionText(newIp);
+
+ HelperFunctions.showToast(getContext().getString(R.string.tmp_dns_changes_made_confirmation), true);
+ });
+ modal.show();
+ }
+
+ if (view.getId() == R.id.optionStartOnBoot && VPNServersPersistentData.getInstance().getCurrentServer() == null) {
+ HelperFunctions.showToast(getContext().getText(R.string.tmp_options_start_on_boot_without_server_error).toString(), true);
+
+ return;
+ }
+
+ ((SettingsOption)view).setChecked(!((SettingsOption)view).isChecked());
+
+ if (view.getId() == R.id.optionShowIp) {
+ VPNGeneralPersistentData.setShowIpActivated(((SettingsOption)view).isChecked());
+ } else if (view.getId() == R.id.optionKillSwitch) {
+ VPNGeneralPersistentData.setKillSwitchActivated(((SettingsOption)view).isChecked());
+ } else if (view.getId() == R.id.optionResetAfterErrors) {
+ VPNGeneralPersistentData.setMustRestartVpn(((SettingsOption)view).isChecked());
+ } else if (view.getId() == R.id.optionProtectBeforeConnecting) {
+ VPNGeneralPersistentData.setProtectBeforeConnected(((SettingsOption)view).isChecked());
+ } else {
+ VPNGeneralPersistentData.setStartOnBoot(((SettingsOption)view).isChecked());
+ }
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/settings/SettingsOption.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/settings/SettingsOption.java
new file mode 100644
index 0000000000..8aaf37e966
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/settings/SettingsOption.java
@@ -0,0 +1,106 @@
+package com.skywire.skycoin.vpn.activities.settings;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.widget.CheckBox;
+import android.widget.TextView;
+
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.controls.BoxRowLayout;
+import com.skywire.skycoin.vpn.extensible.ButtonBase;
+import com.skywire.skycoin.vpn.helpers.BoxRowTypes;
+
+public class SettingsOption extends ButtonBase {
+ private BoxRowLayout mainLayout;
+ private TextView textAlertIcon;
+ private TextView textName;
+ private TextView textDescription;
+ private CheckBox checkSelected;
+
+ public SettingsOption(Context context) {
+ super(context);
+ }
+ public SettingsOption(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+ public SettingsOption(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ }
+
+ @Override
+ protected void Initialize(Context context, AttributeSet attrs) {
+ LayoutInflater inflater = (LayoutInflater)context.getSystemService (Context.LAYOUT_INFLATER_SERVICE);
+ inflater.inflate(R.layout.view_settings_list_item, this, true);
+
+ mainLayout = this.findViewById (R.id.mainLayout);
+ textAlertIcon = this.findViewById (R.id.textAlertIcon);
+ textName = this.findViewById (R.id.textName);
+ textDescription = this.findViewById (R.id.textDescription);
+ checkSelected = this.findViewById (R.id.checkSelected);
+
+ int type = 1;
+ String name = "";
+ String description = "";
+
+ if (attrs != null) {
+ TypedArray attributes = getContext().getTheme().obtainStyledAttributes(
+ attrs,
+ R.styleable.SettingsOption,
+ 0, 0
+ );
+
+ type = attributes.getInteger(R.styleable.SettingsOption_box_row_type, 1);
+ name = attributes.getString(R.styleable.SettingsOption_title);
+ description = attributes.getString(R.styleable.SettingsOption_description);
+
+ boolean hideCheckbox = attributes.getBoolean(R.styleable.SettingsOption_hide_checkbox, false);
+ if (hideCheckbox) {
+ checkSelected.setVisibility(GONE);
+ }
+
+ attributes.recycle();
+ }
+
+ textName.setText(name);
+ textDescription.setText(description);
+
+ if (type == 0) {
+ mainLayout.setType(BoxRowTypes.TOP);
+ } else if (type == 1) {
+ mainLayout.setType(BoxRowTypes.MIDDLE);
+ } else if (type == 2) {
+ mainLayout.setType(BoxRowTypes.BOTTOM);
+ } else if (type == 3) {
+ mainLayout.setType(BoxRowTypes.SINGLE);
+ }
+
+ textAlertIcon.setVisibility(GONE);
+
+ setClickableBoxView(mainLayout);
+ }
+
+ public void setChecked(boolean checked) {
+ checkSelected.setChecked(checked);
+ }
+ public boolean isChecked() {
+ return checkSelected.isChecked();
+ }
+
+ public void setDescription(int resource, String param) {
+ if (param == null) {
+ textDescription.setText(resource);
+ } else {
+ textDescription.setText(String.format(getResources().getString(resource), param));
+ }
+ }
+
+ public void changeAlertIconVisibility(boolean visible) {
+ if (visible) {
+ textAlertIcon.setVisibility(VISIBLE);
+ } else {
+ textAlertIcon.setVisibility(GONE);
+ }
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/start/MapBackground.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/start/MapBackground.java
new file mode 100644
index 0000000000..142647ecd6
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/start/MapBackground.java
@@ -0,0 +1,139 @@
+package com.skywire.skycoin.vpn.activities.start;
+
+import android.animation.Animator;
+import android.animation.ObjectAnimator;
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.Canvas;
+import android.graphics.Rect;
+import android.graphics.drawable.BitmapDrawable;
+import android.util.AttributeSet;
+import android.view.View;
+import android.view.animation.AccelerateInterpolator;
+import android.view.animation.DecelerateInterpolator;
+
+import com.skywire.skycoin.vpn.R;
+
+public class MapBackground extends View {
+ public MapBackground(Context context) {
+ super(context);
+ Initialize(context, null);
+ }
+ public MapBackground(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ Initialize(context, attrs);
+ }
+ public MapBackground(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ Initialize(context, attrs);
+ }
+
+ private BitmapDrawable bitmapDrawable;
+ private float proportion = 1;
+ private Rect drawableArea = new Rect(0, 0,1, 1);
+ private int widthSize;
+ private boolean finished = false;
+ private ObjectAnimator animation;
+
+ private void Initialize (Context context, AttributeSet attrs) {
+ Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.map_phones);
+ bitmapDrawable = new BitmapDrawable(context.getResources(), bitmap);
+ bitmapDrawable.setAlpha(25);
+
+ proportion = (float)bitmap.getWidth() / (float)bitmap.getHeight();
+ }
+
+ public void pauseAnimation() {
+ if (animation != null) {
+ animation.pause();
+ }
+ }
+
+ public void resumeAnimation() {
+ if (animation != null) {
+ animation.resume();
+ }
+ }
+
+ public void cancelAnimation() {
+ finished = true;
+ stopAnimation();
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ widthSize = MeasureSpec.getSize(widthMeasureSpec);
+ int heightSize = MeasureSpec.getSize(heightMeasureSpec);
+
+ if (widthSize != drawableArea.width() || heightSize != drawableArea.height()) {
+ setValues(widthSize, heightSize);
+ }
+
+ setMeasuredDimension(drawableArea.width(), drawableArea.height());
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ bitmapDrawable.draw(canvas);
+ super.onDraw(canvas);
+ }
+
+ private void setValues(int width, int height) {
+ if (finished) {
+ return;
+ }
+
+ drawableArea = new Rect(0, 0, (int) (height * proportion), height);
+ bitmapDrawable.setBounds(drawableArea);
+
+ stopAnimation();
+ selectPosition();
+ startAnimation(true);
+ }
+
+ private void selectPosition() {
+ int max = drawableArea.width() - widthSize;
+ this.setTranslationX(-(int)Math.round(Math.random() * max));
+ invalidate();
+ }
+
+ private void startAnimation(boolean appear) {
+ animation = ObjectAnimator.ofFloat(this, "alpha", appear ? 0 : 1, appear ? 1 : 0);
+ animation.setDuration(800);
+ animation.setInterpolator(appear ? new DecelerateInterpolator() : new AccelerateInterpolator());
+ if (!appear) {
+ animation.setStartDelay(15000);
+ }
+
+ animation.addListener(new Animator.AnimatorListener() {
+ @Override
+ public void onAnimationStart(Animator animation) { }
+ @Override
+ public void onAnimationCancel(Animator animation) { }
+ @Override
+ public void onAnimationRepeat(Animator animation) { }
+
+ @Override
+ public void onAnimationEnd(Animator anim) {
+ stopAnimation();
+ if (appear) {
+ startAnimation(false);
+ } else {
+ selectPosition();
+ startAnimation(true);
+ }
+ }
+ });
+
+ animation.start();
+ }
+
+ private void stopAnimation() {
+ if (animation != null) {
+ animation.removeAllListeners();
+ animation.cancel();
+ animation = null;
+ }
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/start/StartActivity.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/start/StartActivity.java
new file mode 100644
index 0000000000..36299e3b66
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/start/StartActivity.java
@@ -0,0 +1,297 @@
+package com.skywire.skycoin.vpn.activities.start;
+
+import android.animation.Animator;
+import android.animation.ObjectAnimator;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.animation.AccelerateInterpolator;
+import android.view.animation.DecelerateInterpolator;
+import android.widget.FrameLayout;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.activities.index.IndexPageAdapter;
+import com.skywire.skycoin.vpn.activities.start.connected.StartViewConnected;
+import com.skywire.skycoin.vpn.activities.start.disconnected.StartViewDisconnected;
+import com.skywire.skycoin.vpn.helpers.HelperFunctions;
+import com.skywire.skycoin.vpn.vpn.VPNCoordinator;
+
+import java.util.concurrent.TimeUnit;
+
+import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
+import io.reactivex.rxjava3.core.Observable;
+import io.reactivex.rxjava3.disposables.Disposable;
+import io.reactivex.rxjava3.schedulers.Schedulers;
+
+public class StartActivity extends Fragment {
+ private enum SimpleVpnStates {
+ Unknown,
+ Running,
+ Stopped,
+ }
+
+ private FrameLayout mainContainer;
+ private MapBackground background;
+
+ private StartViewDisconnected viewDisconnected;
+ private StartViewConnected viewConnected;
+
+ private SimpleVpnStates vpnState = SimpleVpnStates.Unknown;
+ private ObjectAnimator animation;
+ private ObjectAnimator positionAnimation;
+ private SimpleVpnStates animationDestination = SimpleVpnStates.Unknown;
+
+ private IndexPageAdapter.RequestTabListener requestTabListener;
+ private Disposable serviceSubscription;
+
+ @Nullable
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
+ super.onCreateView(inflater, container, savedInstanceState);
+
+ return inflater.inflate(R.layout.activity_start, container, true);
+ }
+
+ @Override
+ public void onViewCreated(View view, Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+
+ mainContainer = view.findViewById(R.id.mainContainer);
+ background = view.findViewById(R.id.background);
+
+ if (!HelperFunctions.showBackgroundForVerticalScreen()) {
+ background.setVisibility(View.GONE);
+ }
+ }
+
+ public void setRequestTabListener(IndexPageAdapter.RequestTabListener listener) {
+ requestTabListener = listener;
+ if (viewDisconnected != null) {
+ viewDisconnected.setRequestTabListener(listener);
+ }
+ }
+
+ @Override
+ public void onStart() {
+ super.onStart();
+
+ serviceSubscription = VPNCoordinator.getInstance().getEventsObservable().subscribe(state -> {
+ if (state.state.val() < 10) {
+ if (vpnState == SimpleVpnStates.Unknown) {
+ vpnState = SimpleVpnStates.Stopped;
+ configureViewDisconnected();
+ } else {
+ vpnState = SimpleVpnStates.Stopped;
+ startInitialAnimation(SimpleVpnStates.Stopped);
+ }
+ } else {
+ if (vpnState == SimpleVpnStates.Unknown) {
+ vpnState = SimpleVpnStates.Running;
+ configureViewConnected();
+ } else {
+ vpnState = SimpleVpnStates.Running;
+ startInitialAnimation(SimpleVpnStates.Running);
+ }
+ }
+ });
+ }
+
+ private void configureViewDisconnected() {
+ if (viewDisconnected == null) {
+ if (viewConnected != null) {
+ mainContainer.removeView(viewConnected);
+ viewConnected.close();
+ viewConnected = null;
+ }
+
+ viewDisconnected = new StartViewDisconnected(getContext());
+ viewDisconnected.setParentActivity(getActivity());
+ if (requestTabListener != null) {
+ viewDisconnected.setRequestTabListener(requestTabListener);
+ }
+
+ mainContainer.addView(viewDisconnected);
+ viewDisconnected.startAnimation();
+ }
+ }
+
+ private void configureViewConnected() {
+ if (viewConnected == null) {
+ if (viewDisconnected != null) {
+ mainContainer.removeView(viewDisconnected);
+ viewDisconnected.close();
+ viewDisconnected = null;
+ }
+
+ viewConnected = new StartViewConnected(getContext());
+ mainContainer.addView(viewConnected);
+ }
+ }
+
+ private void startInitialAnimation(SimpleVpnStates desiredDestination) {
+ if (animation != null || desiredDestination == SimpleVpnStates.Unknown) {
+ return;
+ }
+ if (desiredDestination == SimpleVpnStates.Running && viewConnected != null) {
+ return;
+ }
+ if (desiredDestination == SimpleVpnStates.Stopped && viewDisconnected != null) {
+ return;
+ }
+
+ animationDestination = desiredDestination;
+
+ View viewToAnimate;
+ if (desiredDestination == SimpleVpnStates.Running) {
+ viewToAnimate = viewDisconnected;
+ } else {
+ viewToAnimate = viewConnected;
+ }
+
+ animate(viewToAnimate, true);
+ }
+
+ private void startFinalAnimation() {
+ View viewToAnimate;
+ if (animationDestination == SimpleVpnStates.Running) {
+ configureViewConnected();
+ viewToAnimate = viewConnected;
+ } else {
+ configureViewDisconnected();
+ viewToAnimate = viewDisconnected;
+ }
+
+ animate(viewToAnimate, false);
+ }
+
+ private void animate(View viewToAnimate, boolean isInitialAnimation) {
+ if (animation != null) {
+ animation.cancel();
+ }
+ if (positionAnimation != null) {
+ positionAnimation.cancel();
+ }
+
+ float initialPosition;
+ float finalPosition;
+ if (animationDestination == SimpleVpnStates.Running) {
+ if (isInitialAnimation) {
+ initialPosition = 0;
+ finalPosition = 20 * getContext().getResources().getDisplayMetrics().density;
+ } else {
+ initialPosition = -20 * getContext().getResources().getDisplayMetrics().density;
+ finalPosition = 0;
+ }
+ } else {
+ if (isInitialAnimation) {
+ initialPosition = 0;
+ finalPosition = -20 * getContext().getResources().getDisplayMetrics().density;
+ } else {
+ initialPosition = 20 * getContext().getResources().getDisplayMetrics().density;
+ finalPosition = 0;
+ }
+ }
+
+ long duration = 200;
+
+ positionAnimation = ObjectAnimator.ofFloat(viewToAnimate, "translationY", initialPosition, finalPosition);
+ positionAnimation.setDuration(duration);
+ positionAnimation.setInterpolator(isInitialAnimation ? new AccelerateInterpolator() : new DecelerateInterpolator());
+ positionAnimation.start();
+
+ animation = ObjectAnimator.ofFloat(viewToAnimate, "alpha", isInitialAnimation ? 1 : 0, isInitialAnimation ? 0 : 1);
+ animation.setDuration(duration);
+ animation.setInterpolator(isInitialAnimation ? new AccelerateInterpolator() : new DecelerateInterpolator());
+
+ animation.addListener(new Animator.AnimatorListener() {
+ @Override
+ public void onAnimationStart(Animator animation) { }
+ @Override
+ public void onAnimationCancel(Animator animation) { }
+ @Override
+ public void onAnimationRepeat(Animator animation) { }
+
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ if (isInitialAnimation) {
+ Observable.just(1).delay(50, TimeUnit.MILLISECONDS)
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(v -> startFinalAnimation());
+ } else {
+ finishAnimations();
+ animationDestination = SimpleVpnStates.Unknown;
+
+ if (vpnState == SimpleVpnStates.Running && viewConnected == null) {
+ startInitialAnimation(SimpleVpnStates.Running);
+ } else if (vpnState == SimpleVpnStates.Stopped && viewDisconnected == null) {
+ startInitialAnimation(SimpleVpnStates.Stopped);
+ }
+ }
+ }
+ });
+
+ animation.start();
+ }
+
+ private void finishAnimations() {
+ animation.cancel();
+ animation = null;
+
+ positionAnimation.cancel();
+ positionAnimation = null;
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+
+ background.resumeAnimation();
+ if (viewDisconnected != null) {
+ viewDisconnected.startAnimation();
+ viewDisconnected.updateRightBar();
+ }
+ if (viewConnected != null) {
+ viewConnected.continueUpdatingStats();
+ viewConnected.updateRightBar();
+ }
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+
+ background.pauseAnimation();
+ if (viewDisconnected != null) {
+ viewDisconnected.stopAnimation();
+ }
+ if (viewConnected != null) {
+ viewConnected.pauseUpdatingStats();
+ }
+ }
+
+ @Override
+ public void onStop() {
+ super.onStop();
+ serviceSubscription.dispose();
+ }
+
+ @Override
+ public void onDestroyView() {
+ super.onDestroyView();
+
+ background.cancelAnimation();
+
+ if (viewDisconnected != null) {
+ viewDisconnected.close();
+ }
+ if (viewConnected != null) {
+ viewConnected.close();
+ }
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/start/StartViewRightPanel.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/start/StartViewRightPanel.java
new file mode 100644
index 0000000000..f20ea4fa7e
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/start/StartViewRightPanel.java
@@ -0,0 +1,329 @@
+package com.skywire.skycoin.vpn.activities.start;
+
+import android.content.ClipData;
+import android.content.ClipboardManager;
+import android.content.Context;
+import android.content.Intent;
+import android.content.res.TypedArray;
+import android.text.SpannableStringBuilder;
+import android.text.Spanned;
+import android.text.style.RelativeSizeSpan;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.FrameLayout;
+import android.widget.LinearLayout;
+import android.widget.ProgressBar;
+import android.widget.TextView;
+
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.activities.apps.AppsActivity;
+import com.skywire.skycoin.vpn.activities.servers.ServerLists;
+import com.skywire.skycoin.vpn.activities.servers.ServersActivity;
+import com.skywire.skycoin.vpn.controls.ClickableLinearLayout;
+import com.skywire.skycoin.vpn.controls.ServerName;
+import com.skywire.skycoin.vpn.extensible.ClickEvent;
+import com.skywire.skycoin.vpn.helpers.AlphaSpan;
+import com.skywire.skycoin.vpn.helpers.Globals;
+import com.skywire.skycoin.vpn.helpers.HelperFunctions;
+import com.skywire.skycoin.vpn.helpers.MaterialFontSpan;
+import com.skywire.skycoin.vpn.network.ApiClient;
+import com.skywire.skycoin.vpn.objects.LocalServerData;
+import com.skywire.skycoin.vpn.vpn.VPNGeneralPersistentData;
+import com.skywire.skycoin.vpn.vpn.VPNServersPersistentData;
+
+import java.io.Closeable;
+import java.util.Date;
+import java.util.HashSet;
+import java.util.concurrent.TimeUnit;
+
+import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
+import io.reactivex.rxjava3.core.Observable;
+import io.reactivex.rxjava3.disposables.Disposable;
+import io.reactivex.rxjava3.schedulers.Schedulers;
+
+public class StartViewRightPanel extends FrameLayout implements ClickEvent, Closeable {
+ public StartViewRightPanel(Context context) {
+ super(context);
+ Initialize(context, null);
+ }
+ public StartViewRightPanel(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ Initialize(context, attrs);
+ }
+ public StartViewRightPanel(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ Initialize(context, attrs);
+ }
+
+ private final int retryDelay = 20000;
+
+ private TextView textWaitingIp;
+ private TextView textIp;
+ private TextView textWaitingCountry;
+ private TextView textCountry;
+ private TextView textRemotePk;
+ private TextView textLocalPk;
+ private TextView textAppProtection;
+ private ServerName serverName;
+ private ClickableLinearLayout ipClickableLayout;
+ private ClickableLinearLayout serverClickableLayout;
+ private ClickableLinearLayout remotePkClickableLayout;
+ private ClickableLinearLayout localPkClickableLayout;
+ private ClickableLinearLayout appProtectionClickableLayout;
+ private LinearLayout loadingIpContainer;
+ private LinearLayout ipContainer;
+ private LinearLayout countryContainer;
+ private LinearLayout bottomPartContainer;
+ private ProgressBar progressCountry;
+
+ private LocalServerData currentServer;
+
+ private String previousIp;
+ private String currentIp;
+ private String previousCountry;
+ private Date lastIpRefresDate;
+
+ private Disposable serverSubscription;
+ private Disposable ipSubscription;
+
+ protected void Initialize (Context context, AttributeSet attrs) {
+ LayoutInflater inflater = (LayoutInflater)context.getSystemService (Context.LAYOUT_INFLATER_SERVICE);
+ inflater.inflate(R.layout.view_start_right_panel, this, true);
+
+ textWaitingIp = findViewById(R.id.textWaitingIp);
+ textIp = findViewById(R.id.textIp);
+ textWaitingCountry = findViewById(R.id.textWaitingCountry);
+ textCountry = findViewById(R.id.textCountry);
+ textRemotePk = findViewById(R.id.textRemotePk);
+ textLocalPk = findViewById(R.id.textLocalPk);
+ textAppProtection = findViewById(R.id.textAppProtection);
+ serverName = findViewById(R.id.serverName);
+ ipClickableLayout = findViewById(R.id.ipClickableLayout);
+ serverClickableLayout = findViewById(R.id.serverClickableLayout);
+ remotePkClickableLayout = findViewById(R.id.remotePkClickableLayout);
+ localPkClickableLayout = findViewById(R.id.localPkClickableLayout);
+ appProtectionClickableLayout = findViewById(R.id.appProtectionClickableLayout);
+ loadingIpContainer = findViewById(R.id.loadingIpContainer);
+ ipContainer = findViewById(R.id.ipContainer);
+ countryContainer = findViewById(R.id.countryContainer);
+ bottomPartContainer = findViewById(R.id.bottomPartContainer);
+ progressCountry = findViewById(R.id.progressCountry);
+
+ ipClickableLayout.setClickEventListener(this);
+ serverClickableLayout.setClickEventListener(this);
+ remotePkClickableLayout.setClickEventListener(this);
+ localPkClickableLayout.setClickEventListener(this);
+ appProtectionClickableLayout.setClickEventListener(this);
+
+ localPkClickableLayout.setVisibility(View.GONE);
+ ipClickableLayout.setVisibility(View.GONE);
+ ipContainer.setVisibility(View.GONE);
+ countryContainer.setVisibility(View.GONE);
+
+ if (attrs != null) {
+ TypedArray attributes = context.getTheme().obtainStyledAttributes(
+ attrs,
+ R.styleable.StartViewRightPanel,
+ 0, 0
+ );
+
+ if (attributes.getBoolean(R.styleable.StartViewRightPanel_hide_bottom_part, false)) {
+ bottomPartContainer.setVisibility(GONE);
+ }
+
+ attributes.recycle();
+ }
+
+ if (!isInEditMode()) {
+ updateData();
+
+ if (!VPNGeneralPersistentData.getShowIpActivated()) {
+ textWaitingIp.setText(R.string.tmp_status_connected_ip_option_disabled);
+ textWaitingCountry.setText(R.string.tmp_status_connected_ip_option_disabled);
+ }
+ }
+ }
+
+ public void updateData() {
+ if (serverSubscription == null) {
+ serverSubscription = VPNServersPersistentData.getInstance().getCurrentServerObservable().subscribe(server -> {
+ currentServer = server;
+ serverName.setServer(ServersActivity.convertLocalServerData(currentServer), ServerLists.History, true);
+ putTextWithIcon(textRemotePk, currentServer.pk, " \ue14d");
+ });
+ }
+
+ Globals.AppFilteringModes selectedMode = VPNGeneralPersistentData.getAppsSelectionMode();
+ if (selectedMode != Globals.AppFilteringModes.PROTECT_ALL) {
+ HashSet selectedApps = HelperFunctions.filterAvailableApps(VPNGeneralPersistentData.getAppList(new HashSet<>()));
+
+ if (selectedApps.size() > 0) {
+ appProtectionClickableLayout.setVisibility(VISIBLE);
+
+ String text;
+ if (selectedMode == Globals.AppFilteringModes.PROTECT_SELECTED) {
+ text = getContext().getString(R.string.tmp_status_connected_protecting_selected_apps);
+ } else {
+ text = getContext().getString(R.string.tmp_status_connected_ignoring_selected_apps);
+ }
+
+ putTextWithIcon(textAppProtection, text, " \ue8f4");
+ } else {
+ appProtectionClickableLayout.setVisibility(GONE);
+ }
+ } else {
+ appProtectionClickableLayout.setVisibility(GONE);
+ }
+ }
+
+ public void putInWaitingForVpnState() {
+ cancelIpCheck();
+
+ ipClickableLayout.setVisibility(GONE);
+ loadingIpContainer.setVisibility(VISIBLE);
+
+ textWaitingIp.setVisibility(VISIBLE);
+ textWaitingCountry.setVisibility(VISIBLE);
+ ipContainer.setVisibility(View.GONE);
+ countryContainer.setVisibility(View.GONE);
+ }
+
+ public void refreshIpData() {
+ getIp(0);
+ }
+
+ private void getIp(int delayMs) {
+ if (!VPNGeneralPersistentData.getShowIpActivated()) {
+ return;
+ }
+
+ cancelIpCheck();
+
+ ipClickableLayout.setVisibility(GONE);
+ loadingIpContainer.setVisibility(VISIBLE);
+
+ textWaitingIp.setVisibility(GONE);
+ textWaitingCountry.setVisibility(GONE);
+ progressCountry.setVisibility(VISIBLE);
+ ipContainer.setVisibility(View.VISIBLE);
+ countryContainer.setVisibility(View.VISIBLE);
+ textIp.setText("---");
+ textCountry.setText("---");
+
+ ipSubscription = Observable.just(0).delay(delayMs, TimeUnit.MILLISECONDS).flatMap(v -> ApiClient.getCurrentIp())
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(response -> {
+ if (response.body() != null) {
+ lastIpRefresDate = new Date();
+
+ ipClickableLayout.setVisibility(VISIBLE);
+ loadingIpContainer.setVisibility(GONE);
+
+ currentIp = response.body().ip;
+ textIp.setText(currentIp);
+
+ if (currentIp.equals(previousIp) && previousCountry != null) {
+ textCountry.setText(previousCountry);
+ progressCountry.setVisibility(GONE);
+ } else {
+ getIpCountry(0);
+ }
+
+ previousIp = currentIp;
+ } else {
+ getIp(retryDelay);
+ }
+ }, err -> {
+ getIp(retryDelay);
+ });
+ }
+
+ private void getIpCountry(int delayMs) {
+ if (!VPNGeneralPersistentData.getShowIpActivated()) {
+ return;
+ }
+
+ ipSubscription.dispose();
+
+ ipSubscription = Observable.just(0).delay(delayMs, TimeUnit.MILLISECONDS).flatMap(v -> ApiClient.getIpCountry(currentIp))
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(response -> {
+ if (response.body() != null) {
+ progressCountry.setVisibility(GONE);
+
+ String[] dataParts = response.body().split(";");
+ if (dataParts.length == 4) {
+ textCountry.setText(dataParts[3]);
+ } else {
+ textCountry.setText(getContext().getText(R.string.general_unknown));
+ }
+
+ previousCountry = textCountry.getText().toString();
+ } else {
+ getIpCountry(retryDelay);
+ }
+ }, err -> {
+ getIpCountry(retryDelay);
+ });
+ }
+
+ private void cancelIpCheck() {
+ if (ipSubscription != null) {
+ ipSubscription.dispose();
+ }
+ }
+
+ private void putTextWithIcon(TextView textView, String text, String iconText) {
+ MaterialFontSpan materialFontSpan = new MaterialFontSpan(getContext());
+ RelativeSizeSpan relativeSizeSpan = new RelativeSizeSpan(0.75f);
+ AlphaSpan alphaSpan = new AlphaSpan(128);
+
+ SpannableStringBuilder finalText = new SpannableStringBuilder(text.toString() + iconText);
+ finalText.setSpan(materialFontSpan, finalText.length() - iconText.length(), finalText.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ finalText.setSpan(relativeSizeSpan, finalText.length() - iconText.length(), finalText.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ finalText.setSpan(alphaSpan, finalText.length() - iconText.length(), finalText.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+
+ textView.setText(finalText);
+ }
+
+ @Override
+ public void onClick(View view) {
+ if (view.getId() == R.id.ipClickableLayout) {
+ long msToWait = 10000;
+ long elapsedTime = (new Date()).getTime() - lastIpRefresDate.getTime();
+
+ if (elapsedTime < msToWait) {
+ HelperFunctions.showToast(String.format(
+ getContext().getText(R.string.tmp_status_connected_ip_refresh_time_warning).toString(),
+ HelperFunctions.zeroDecimalsFormatter.format(Math.ceil((msToWait - elapsedTime)) / 1000d)
+ ), true);
+ } else {
+ this.refreshIpData();
+ }
+ } else if (view.getId() == R.id.serverClickableLayout) {
+ HelperFunctions.showServerOptions(getContext(), ServersActivity.convertLocalServerData(currentServer), ServerLists.History);
+ } else if (view.getId() == R.id.appProtectionClickableLayout) {
+ Intent intent = new Intent(getContext(), AppsActivity.class);
+ intent.putExtra(AppsActivity.READ_ONLY_EXTRA, true);
+ getContext().startActivity(intent);
+ } else {
+ String textToCopy = currentServer.pk;
+
+ ClipboardManager clipboard = (ClipboardManager)getContext().getSystemService(Context.CLIPBOARD_SERVICE);
+ ClipData clipData = ClipData.newPlainText("", textToCopy);
+ clipboard.setPrimaryClip(clipData);
+ HelperFunctions.showToast(getContext().getString(R.string.general_copied), true);
+ }
+ }
+
+ @Override
+ public void close() {
+ if (serverSubscription != null) {
+ serverSubscription.dispose();
+ }
+ cancelIpCheck();
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/start/connected/Chart.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/start/connected/Chart.java
new file mode 100644
index 0000000000..1c44894935
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/start/connected/Chart.java
@@ -0,0 +1,158 @@
+package com.skywire.skycoin.vpn.activities.start.connected;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.widget.FrameLayout;
+import android.widget.TextView;
+
+import com.github.mikephil.charting.charts.LineChart;
+import com.github.mikephil.charting.data.Entry;
+import com.github.mikephil.charting.data.LineData;
+import com.github.mikephil.charting.data.LineDataSet;
+import com.github.mikephil.charting.interfaces.datasets.ILineDataSet;
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.helpers.Globals;
+import com.skywire.skycoin.vpn.helpers.HelperFunctions;
+import com.skywire.skycoin.vpn.vpn.VPNGeneralPersistentData;
+
+import java.io.Closeable;
+import java.util.ArrayList;
+
+import io.reactivex.rxjava3.disposables.Disposable;
+
+public class Chart extends FrameLayout implements Closeable {
+ public Chart(Context context) {
+ super(context);
+ Initialize(context, null);
+ }
+ public Chart(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ Initialize(context, attrs);
+ }
+ public Chart(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ Initialize(context, attrs);
+ }
+
+ private LineChart chart;
+ private FrameLayout chartContainer;
+ private TextView textMin;
+ private TextView textMid;
+ private TextView textMax;
+
+ private Globals.DataUnits dataUnits = VPNGeneralPersistentData.getDataUnits();
+ private ArrayList lastData;
+ private boolean showingMs;
+
+ private Disposable dataUnitsSubscription;
+
+ protected void Initialize (Context context, AttributeSet attrs) {
+ LayoutInflater inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ inflater.inflate(R.layout.view_start_chart, this, true);
+
+ chart = findViewById(R.id.chart);
+ chartContainer = findViewById(R.id.chartContainer);
+ textMin = findViewById(R.id.textMin);
+ textMid = findViewById(R.id.textMid);
+ textMax = findViewById(R.id.textMax);
+
+ chartContainer.setClipToOutline(true);
+
+ chart.getDescription().setEnabled(false);
+ chart.getLegend().setEnabled(false);
+ chart.setDrawGridBackground(false);
+ chart.getXAxis().setEnabled(false);
+ chart.getAxisLeft().setEnabled(false);
+ chart.getAxisRight().setEnabled(false);
+
+ chart.setViewPortOffsets(0f, 0f, 0f, 0f);
+ chart.getAxisLeft().setAxisMinimum(0);
+ chart.getAxisLeft().setSpaceTop(0);
+ chart.getAxisLeft().setSpaceBottom(0);
+
+ chart.setScaleEnabled(false);
+ chart.setTouchEnabled(false);
+
+ dataUnitsSubscription = VPNGeneralPersistentData.getDataUnitsObservable().subscribe(response -> {
+ dataUnits = response;
+
+ if (lastData != null) {
+ setData(lastData, showingMs);
+ }
+ });
+ }
+
+ public void setData(ArrayList data, boolean showingMs) {
+ this.lastData = data;
+ this.showingMs = showingMs;
+
+ ArrayList values = new ArrayList<>();
+
+ double max = 0;
+ for (int i = 0; i < data.size(); i++) {
+ double val = (float)data.get(i);
+ values.add(new Entry(i, (float)val));
+
+ if (val > max) {
+ max = val;
+ }
+ }
+
+ if (max == 0) {
+ max = 1;
+ }
+
+ double mid = max / 2;
+
+ if (chart.getAxisLeft().getAxisMaximum() != max) {
+ chart.getAxisLeft().setAxisMaximum((float)max);
+
+ if (showingMs) {
+ textMax.setText(HelperFunctions.getLatencyValue(max));
+ textMid.setText(HelperFunctions.getLatencyValue(mid));
+ textMin.setText(HelperFunctions.getLatencyValue(0));
+ } else {
+ textMax.setText(HelperFunctions.computeDataAmountString(max, true, dataUnits != Globals.DataUnits.OnlyBytes));
+ textMid.setText(HelperFunctions.computeDataAmountString(mid, true, dataUnits != Globals.DataUnits.OnlyBytes));
+ textMin.setText(HelperFunctions.computeDataAmountString(0, true, dataUnits != Globals.DataUnits.OnlyBytes));
+ }
+ }
+
+ LineDataSet dataSet;
+ if (chart.getData() != null && chart.getData().getDataSetCount() > 0) {
+ dataSet = (LineDataSet) chart.getData().getDataSetByIndex(0);
+ dataSet.setValues(values);
+ dataSet.notifyDataSetChanged();
+ chart.getData().notifyDataChanged();
+ chart.notifyDataSetChanged();
+ chart.invalidate();
+ } else {
+ dataSet = new LineDataSet(values, "");
+ dataSet.setDrawIcons(false);
+ dataSet.setDrawValues(false);
+ dataSet.setDrawCircleHole(false);
+ dataSet.setDrawCircles(false);
+
+ dataSet.setMode(LineDataSet.Mode.HORIZONTAL_BEZIER);
+
+ dataSet.setColor(0x59000000);
+ dataSet.setLineWidth(0f);
+
+ dataSet.setDrawFilled(true);
+ dataSet.setFillColor(0x00000000);
+ dataSet.setFillAlpha(255);
+
+ ArrayList dataSets = new ArrayList<>();
+ dataSets.add(dataSet);
+ LineData lineData = new LineData(dataSets);
+
+ chart.setData(lineData);
+ }
+ }
+
+ @Override
+ public void close() {
+ dataUnitsSubscription.dispose();
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/start/connected/StartViewConnected.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/start/connected/StartViewConnected.java
new file mode 100644
index 0000000000..d4129b09b2
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/start/connected/StartViewConnected.java
@@ -0,0 +1,509 @@
+package com.skywire.skycoin.vpn.activities.start.connected;
+
+import android.content.Context;
+import android.content.Intent;
+import android.util.AttributeSet;
+import android.util.TypedValue;
+import android.view.Gravity;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.FrameLayout;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.ProgressBar;
+import android.widget.TextView;
+
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.activities.apps.AppsActivity;
+import com.skywire.skycoin.vpn.activities.servers.ServerLists;
+import com.skywire.skycoin.vpn.activities.servers.ServersActivity;
+import com.skywire.skycoin.vpn.activities.start.StartViewRightPanel;
+import com.skywire.skycoin.vpn.controls.ConfirmationModalWindow;
+import com.skywire.skycoin.vpn.controls.ServerName;
+import com.skywire.skycoin.vpn.extensible.ClickEvent;
+import com.skywire.skycoin.vpn.helpers.ClickTimeManagement;
+import com.skywire.skycoin.vpn.helpers.Globals;
+import com.skywire.skycoin.vpn.helpers.HelperFunctions;
+import com.skywire.skycoin.vpn.network.ApiClient;
+import com.skywire.skycoin.vpn.vpn.VPNCoordinator;
+import com.skywire.skycoin.vpn.vpn.VPNGeneralPersistentData;
+import com.skywire.skycoin.vpn.vpn.VPNServersPersistentData;
+import com.skywire.skycoin.vpn.vpn.VPNStates;
+
+import java.io.Closeable;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashSet;
+import java.util.concurrent.TimeUnit;
+
+import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
+import io.reactivex.rxjava3.core.Observable;
+import io.reactivex.rxjava3.disposables.Disposable;
+import io.reactivex.rxjava3.schedulers.Schedulers;
+
+public class StartViewConnected extends FrameLayout implements ClickEvent, Closeable {
+ public StartViewConnected(Context context) {
+ super(context);
+ Initialize(context, null);
+ }
+ public StartViewConnected(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ Initialize(context, attrs);
+ }
+ public StartViewConnected(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ Initialize(context, attrs);
+ }
+
+ private final int retryDelay = 20000;
+
+ private TextView textTime;
+ private TextView textState;
+ private TextView textStateDescription;
+ private TextView textLastError;
+ private TextView textWaitingIp;
+ private TextView textWaitingCountry;
+ private TextView textIp;
+ private TextView textCountry;
+ private TextView textUploadSpeed;
+ private TextView textTotalUploaded;
+ private TextView textDownloadSpeed;
+ private TextView textTotalDownloaded;
+ private TextView textLatency;
+ private TextView textAppsProtectionMode;
+ private TextView textServerNote;
+ private TextView textStartedByTheSystem;
+ private ServerName serverName;
+ private ImageView imageStateLine;
+ private Chart downloadChart;
+ private Chart uploadChart;
+ private Chart latencyChart;
+ private LinearLayout leftContainer;
+ private LinearLayout ipDataContainer;
+ private LinearLayout ipContainer;
+ private LinearLayout countryContainer;
+ private FrameLayout appsContainer;
+ private LinearLayout appsInternalContainer;
+ private LinearLayout serverContainer;
+ private FrameLayout rightContainer;
+ private ProgressBar progressIp;
+ private ProgressBar progressCountry;
+ private StopButton buttonStop;
+ private StartViewRightPanel rightPanel;
+
+ private String previousIp;
+ private String currentIp;
+ private String previousCountry;
+ private VPNCoordinator.ConnectionStats lastStats;
+ private boolean updateStats = true;
+ private Globals.DataUnits dataUnits = VPNGeneralPersistentData.getDataUnits();
+
+ private ClickTimeManagement appsButtonTimeManager = new ClickTimeManagement();
+ private ClickTimeManagement serverButtonTimeManager = new ClickTimeManagement();
+
+ private Disposable serviceSubscription;
+ private Disposable serverSubscription;
+ private Disposable ipSubscription;
+ private Disposable statsSubscription;
+ private Disposable dataUnitsSubscription;
+
+ protected void Initialize (Context context, AttributeSet attrs) {
+ LayoutInflater inflater = (LayoutInflater)context.getSystemService (Context.LAYOUT_INFLATER_SERVICE);
+ inflater.inflate(R.layout.view_start_connected, this, true);
+
+ textTime = findViewById(R.id.textTime);
+ textState = findViewById(R.id.textState);
+ textStateDescription = findViewById(R.id.textStateDescription);
+ textLastError = findViewById(R.id.textLastError);
+ textWaitingIp = findViewById(R.id.textWaitingIp);
+ textWaitingCountry = findViewById(R.id.textWaitingCountry);
+ textIp = findViewById(R.id.textIp);
+ textCountry = findViewById(R.id.textCountry);
+ textUploadSpeed = findViewById(R.id.textUploadSpeed);
+ textTotalUploaded = findViewById(R.id.textTotalUploaded);
+ textDownloadSpeed = findViewById(R.id.textDownloadSpeed);
+ textTotalDownloaded = findViewById(R.id.textTotalDownloaded);
+ textLatency = findViewById(R.id.textLatency);
+ textAppsProtectionMode = findViewById(R.id.textAppsProtectionMode);
+ textServerNote = findViewById(R.id.textServerNote);
+ textStartedByTheSystem = findViewById(R.id.textStartedByTheSystem);
+ serverName = this.findViewById (R.id.serverName);
+ imageStateLine = findViewById(R.id.imageStateLine);
+ imageStateLine = findViewById(R.id.imageStateLine);
+ downloadChart = findViewById(R.id.downloadChart);
+ uploadChart = findViewById(R.id.uploadChart);
+ latencyChart = findViewById(R.id.latencyChart);
+ leftContainer = findViewById(R.id.leftContainer);
+ ipDataContainer = findViewById(R.id.ipDataContainer);
+ ipContainer = findViewById(R.id.ipContainer);
+ countryContainer = findViewById(R.id.countryContainer);
+ appsContainer = findViewById(R.id.appsContainer);
+ appsInternalContainer = findViewById(R.id.appsInternalContainer);
+ serverContainer = findViewById(R.id.serverContainer);
+ rightContainer = findViewById(R.id.rightContainer);
+ progressIp = findViewById(R.id.progressIp);
+ progressCountry = findViewById(R.id.progressCountry);
+ buttonStop = findViewById(R.id.buttonStop);
+ rightPanel = findViewById(R.id.rightPanel);
+
+ textLastError.setVisibility(GONE);
+ textStartedByTheSystem.setVisibility(GONE);
+ ipContainer.setVisibility(GONE);
+ countryContainer.setVisibility(GONE);
+
+ if (HelperFunctions.getWidthType(getContext()) != HelperFunctions.WidthTypes.SMALL) {
+ float areaWidth = getContext().getResources().getDimension(R.dimen.tablet_status_area_width);
+ FrameLayout.LayoutParams params = new FrameLayout.LayoutParams((int)Math.round(areaWidth), LayoutParams.WRAP_CONTENT);
+ params.gravity = Gravity.CENTER_HORIZONTAL;
+ leftContainer.setLayoutParams(params);
+
+ ipDataContainer.setVisibility(GONE);
+ appsContainer.setVisibility(GONE);
+ serverContainer.setVisibility(GONE);
+
+ textLastError.setTextSize(TypedValue.COMPLEX_UNIT_PX, getContext().getResources().getDimension(R.dimen.small_text_size));
+ } else {
+ rightContainer.setVisibility(GONE);
+ }
+
+ Globals.AppFilteringModes selectedMode = VPNGeneralPersistentData.getAppsSelectionMode();
+ if (selectedMode != Globals.AppFilteringModes.PROTECT_ALL) {
+ HashSet selectedApps = HelperFunctions.filterAvailableApps(VPNGeneralPersistentData.getAppList(new HashSet<>()));
+
+ if (HelperFunctions.getWidthType(getContext()) == HelperFunctions.WidthTypes.SMALL) {
+ if (selectedApps.size() > 0) {
+ if (selectedMode == Globals.AppFilteringModes.PROTECT_SELECTED) {
+ textAppsProtectionMode.setText(R.string.tmp_status_connected_protecting_selected_apps);
+ } else {
+ textAppsProtectionMode.setText(R.string.tmp_status_connected_ignoring_selected_apps);
+ }
+
+ appsInternalContainer.setOnClickListener((View v) -> {
+ if (appsButtonTimeManager.canClick()) {
+ appsButtonTimeManager.informClickMade();
+ Intent intent = new Intent(getContext(), AppsActivity.class);
+ intent.putExtra(AppsActivity.READ_ONLY_EXTRA, true);
+ getContext().startActivity(intent);
+ }
+ });
+ } else {
+ appsContainer.setVisibility(GONE);
+ }
+ } else {
+ appsContainer.setVisibility(GONE);
+ }
+ } else {
+ appsContainer.setVisibility(GONE);
+ }
+
+ if (!VPNGeneralPersistentData.getShowIpActivated()) {
+ textWaitingIp.setText(R.string.tmp_status_connected_ip_option_disabled);
+ textWaitingCountry.setText(R.string.tmp_status_connected_ip_option_disabled);
+ }
+
+ ArrayList emptyValues = new ArrayList<>();
+ emptyValues.add(0L);
+
+ VPNCoordinator.ConnectionStats emptyStats = new VPNCoordinator.ConnectionStats();
+ emptyStats.downloadSpeedHistory = emptyValues;
+ emptyStats.uploadSpeedHistory = emptyValues;
+ emptyStats.latencyHistory = emptyValues;
+ emptyStats.currentDownloadSpeed = 0;
+ emptyStats.currentUploadSpeed = 0;
+ emptyStats.currentLatency = 0;
+ emptyStats.totalDownloadedData = 0;
+ emptyStats.totalUploadedData = 0;
+ updateDisplayedStats(emptyStats);
+
+ downloadChart.setData(emptyValues, false);
+ uploadChart.setData(emptyValues, false);
+ latencyChart.setData(emptyValues, true);
+
+ serverSubscription = VPNServersPersistentData.getInstance().getCurrentServerObservable().subscribe(server -> {
+ serverName.setServer(ServersActivity.convertLocalServerData(server), ServerLists.History, true);
+
+ String note = HelperFunctions.getServerNote(server);
+ if (note != null) {
+ textServerNote.setText(note);
+ } else {
+ textServerNote.setText(server.pk);
+ }
+ });
+
+ if (HelperFunctions.getWidthType(getContext()) == HelperFunctions.WidthTypes.SMALL) {
+ serverContainer.setOnClickListener((View v) -> {
+ if (serverButtonTimeManager.canClick()) {
+ serverButtonTimeManager.informClickMade();
+ Observable.just(1).delay(Globals.CLICK_DELAY_MS, TimeUnit.MILLISECONDS)
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(val -> {
+ HelperFunctions.showServerOptions(
+ getContext(),
+ ServersActivity.convertLocalServerData(VPNServersPersistentData.getInstance().getCurrentServer()),
+ ServerLists.History
+ );
+ });
+ }
+ });
+ }
+
+ buttonStop.setClickEventListener(this);
+
+ serviceSubscription = VPNCoordinator.getInstance().getEventsObservable().subscribe(
+ state -> {
+ int mainText = VPNStates.getTitleForState(state.state);
+ if (mainText != -1) {
+ textState.setText(mainText);
+ } else {
+ textState.setText("---");
+ }
+
+ imageStateLine.setBackgroundResource(VPNStates.getColorForStateTitle(mainText));
+
+ int description = VPNStates.getDescriptionForState(state.state);
+ if (description != -1) {
+ textStateDescription.setText(description);
+ } else {
+ textStateDescription.setText("---");
+ }
+
+ buttonStop.setEnabled(true);
+
+ if (state.startedByTheSystem) {
+ buttonStop.setEnabled(false);
+ textStartedByTheSystem.setVisibility(View.VISIBLE);
+ } else {
+ textStartedByTheSystem.setVisibility(View.GONE);
+ }
+
+ if (state.stopRequested) {
+ buttonStop.setEnabled(false);
+ buttonStop.setBusyState(true);
+ } else {
+ buttonStop.setBusyState(false);
+ }
+
+ if (state.state != VPNStates.CONNECTED) {
+ String lastError = VPNGeneralPersistentData.getLastError(null);
+ if (lastError != null) {
+ String start = getContext().getString(R.string.tmp_status_page_last_error);
+ textLastError.setText(start + " " + lastError);
+ textLastError.setVisibility(VISIBLE);
+ } else {
+ textLastError.setVisibility(GONE);
+ }
+ } else {
+ textLastError.setVisibility(GONE);
+ }
+
+ if (VPNGeneralPersistentData.getShowIpActivated()) {
+ if (HelperFunctions.getWidthType(getContext()) == HelperFunctions.WidthTypes.SMALL) {
+ if (state.state == VPNStates.CONNECTED) {
+ if (ipContainer.getVisibility() == TextView.GONE) {
+ ipContainer.setVisibility(VISIBLE);
+ countryContainer.setVisibility(VISIBLE);
+ textWaitingIp.setVisibility(GONE);
+ textWaitingCountry.setVisibility(GONE);
+
+ textIp.setText("---");
+ textCountry.setText("---");
+
+ getIp(0);
+ }
+ } else {
+ if (ipContainer.getVisibility() == TextView.VISIBLE) {
+ ipContainer.setVisibility(GONE);
+ countryContainer.setVisibility(GONE);
+ textWaitingIp.setVisibility(VISIBLE);
+ textWaitingCountry.setVisibility(VISIBLE);
+
+ cancelIpCheck();
+ }
+ }
+ } else {
+ if (state.state == VPNStates.CONNECTED) {
+ rightPanel.refreshIpData();
+ } else {
+ rightPanel.putInWaitingForVpnState();
+ }
+ }
+ }
+ }
+ );
+
+ statsSubscription = VPNCoordinator.getInstance().getConnectionStats().subscribe(stats -> {
+ lastStats = stats;
+ if (updateStats) {
+ updateDisplayedStats(lastStats);
+ }
+ });
+
+ dataUnitsSubscription = VPNGeneralPersistentData.getDataUnitsObservable().subscribe(response -> {
+ dataUnits = response;
+
+ if (lastStats != null && updateStats) {
+ updateDisplayedStats(lastStats);
+ }
+ });
+
+ updateTime(null);
+ }
+
+ private void updateDisplayedStats(VPNCoordinator.ConnectionStats stats) {
+ if (stats != null) {
+ updateTime(stats.lastConnectionDate);
+
+ downloadChart.setData(stats.downloadSpeedHistory, false);
+ uploadChart.setData(stats.uploadSpeedHistory, false);
+ latencyChart.setData(stats.latencyHistory, true);
+
+ textDownloadSpeed.setText(HelperFunctions.computeDataAmountString(stats.currentDownloadSpeed, true, dataUnits != Globals.DataUnits.OnlyBytes));
+ textUploadSpeed.setText(HelperFunctions.computeDataAmountString(stats.currentUploadSpeed, true, dataUnits != Globals.DataUnits.OnlyBytes));
+ textLatency.setText(HelperFunctions.getLatencyValue(stats.currentLatency));
+
+ textTotalDownloaded.setText(String.format(
+ getContext().getText(R.string.tmp_status_connected_total_data).toString(),
+ HelperFunctions.computeDataAmountString(stats.totalDownloadedData, false, dataUnits == Globals.DataUnits.OnlyBits)
+ ));
+
+ textTotalUploaded.setText(String.format(
+ getContext().getText(R.string.tmp_status_connected_total_data).toString(),
+ HelperFunctions.computeDataAmountString(stats.totalUploadedData, false, dataUnits == Globals.DataUnits.OnlyBits)
+ ));
+ }
+ }
+
+ public void pauseUpdatingStats() {
+ updateStats = false;
+ }
+
+ public void continueUpdatingStats() {
+ updateStats = true;
+ updateDisplayedStats(lastStats);
+ }
+
+ public void updateRightBar() {
+ rightPanel.updateData();
+ }
+
+ private void updateTime(Date lastConnectionDate) {
+ if (lastConnectionDate == null) {
+ textTime.setText(R.string.tmp_status_connected_waiting);
+ } else {
+ long connectionMs = (new Date()).getTime() - lastConnectionDate.getTime();
+
+ String time = String.format("%02d", connectionMs / 3600000) + ":";
+ time += String.format("%02d", (connectionMs / 60000) % 60) + ":";
+ time += String.format("%02d", (connectionMs / 1000) % 60);
+
+ textTime.setText(time);
+ }
+ }
+
+ private void getIp(int delayMs) {
+ if (!VPNGeneralPersistentData.getShowIpActivated()) {
+ return;
+ }
+
+ if (ipSubscription != null) {
+ ipSubscription.dispose();
+ }
+
+ progressIp.setVisibility(VISIBLE);
+ progressCountry.setVisibility(VISIBLE);
+
+ this.ipSubscription = Observable.just(0).delay(delayMs, TimeUnit.MILLISECONDS).flatMap(v -> ApiClient.getCurrentIp())
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(response -> {
+ if (response.body() != null) {
+ progressIp.setVisibility(GONE);
+
+ currentIp = response.body().ip;
+ textIp.setText(currentIp);
+
+ if (currentIp.equals(previousIp) && previousCountry != null) {
+ textCountry.setText(previousCountry);
+ progressCountry.setVisibility(GONE);
+ } else {
+ getIpCountry(0);
+ }
+
+ previousIp = currentIp;
+ } else {
+ getIp(retryDelay);
+ }
+ }, err -> {
+ getIp(retryDelay);
+ });
+ }
+
+ private void getIpCountry(int delayMs) {
+ if (!VPNGeneralPersistentData.getShowIpActivated()) {
+ return;
+ }
+
+ ipSubscription.dispose();
+
+ this.ipSubscription = Observable.just(0).delay(delayMs, TimeUnit.MILLISECONDS).flatMap(v -> ApiClient.getIpCountry(currentIp))
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(response -> {
+ if (response.body() != null) {
+ progressCountry.setVisibility(GONE);
+
+ String[] dataParts = response.body().split(";");
+ if (dataParts.length == 4) {
+ textCountry.setText(dataParts[3]);
+ } else {
+ textCountry.setText(getContext().getText(R.string.general_unknown));
+ }
+
+ previousCountry = textCountry.getText().toString();
+ } else {
+ getIpCountry(retryDelay);
+ }
+ }, err -> {
+ getIpCountry(retryDelay);
+ });
+ }
+
+ @Override
+ public void close() {
+ serverSubscription.dispose();
+ serviceSubscription.dispose();
+ statsSubscription.dispose();
+ dataUnitsSubscription.dispose();
+ rightPanel.close();
+ downloadChart.close();
+ uploadChart.close();
+ latencyChart.close();
+ cancelIpCheck();
+ }
+
+ private void cancelIpCheck() {
+ if (ipSubscription != null) {
+ ipSubscription.dispose();
+ }
+ }
+
+ @Override
+ public void onClick(View view) {
+ if (!VPNGeneralPersistentData.getKillSwitchActivated()) {
+ VPNCoordinator.getInstance().stopVPN();
+ } else {
+ ConfirmationModalWindow confirmationModal = new ConfirmationModalWindow(
+ getContext(),
+ R.string.tmp_status_connected_disconnect_confirmation,
+ R.string.tmp_confirmation_yes,
+ R.string.tmp_confirmation_no,
+ () -> {
+ VPNCoordinator.getInstance().stopVPN();
+ buttonStop.setEnabled(false);
+ }
+ );
+ confirmationModal.show();
+ }
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/start/connected/StopButton.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/start/connected/StopButton.java
new file mode 100644
index 0000000000..b956b2fd7c
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/start/connected/StopButton.java
@@ -0,0 +1,89 @@
+package com.skywire.skycoin.vpn.activities.start.connected;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.MotionEvent;
+import android.view.View;
+import android.widget.FrameLayout;
+import android.widget.ProgressBar;
+import android.widget.TextView;
+
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.extensible.ButtonBase;
+
+public class StopButton extends ButtonBase implements View.OnTouchListener {
+ public StopButton(Context context) {
+ super(context);
+ }
+ public StopButton(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+ public StopButton(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ }
+
+ private FrameLayout mainLayout;
+ private FrameLayout internalContainer;
+ private TextView textIcon;
+ private ProgressBar progressAnimation;
+
+ protected void Initialize (Context context, AttributeSet attrs) {
+ LayoutInflater inflater = (LayoutInflater)context.getSystemService (Context.LAYOUT_INFLATER_SERVICE);
+ inflater.inflate(R.layout.view_stop_button, this, true);
+
+ mainLayout = this.findViewById(R.id.mainLayout);
+ internalContainer = this.findViewById(R.id.internalContainer);
+ textIcon = this.findViewById(R.id.textIcon);
+ progressAnimation = this.findViewById(R.id.progressAnimation);
+
+ progressAnimation.setVisibility(GONE);
+
+ internalContainer.setClipToOutline(true);
+
+ setOnTouchListener(this);
+ setViewForCheckingClicks(this);
+ }
+
+ @Override
+ public boolean onTouch(View v, MotionEvent event) {
+ if (event.getAction() == MotionEvent.ACTION_DOWN) {
+ mainLayout.setScaleX(0.98f);
+ mainLayout.setScaleY(0.98f);
+ } else if (event.getAction() == MotionEvent.ACTION_CANCEL || event.getAction() == MotionEvent.ACTION_POINTER_UP || event.getAction() == MotionEvent.ACTION_UP) {
+ mainLayout.setScaleX(1.0f);
+ mainLayout.setScaleY(1.0f);
+ }
+
+ return false;
+ }
+
+ @Override
+ public void setEnabled(boolean enabled) {
+ super.setEnabled(enabled);
+
+ if (enabled) {
+ setAlpha(1f);
+ } else {
+ setAlpha(0.5f);
+ }
+ }
+
+ public void setBusyState(boolean busy) {
+ if (busy) {
+ if (!getBusyState()) {
+ progressAnimation.setVisibility(VISIBLE);
+ textIcon.setVisibility(GONE);
+ }
+ } else {
+ if (getBusyState()) {
+ progressAnimation.setVisibility(GONE);
+ textIcon.setVisibility(VISIBLE);
+ }
+ }
+ }
+
+ public boolean getBusyState() {
+ return progressAnimation.getVisibility() == VISIBLE;
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/start/disconnected/CurrentServerButton.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/start/disconnected/CurrentServerButton.java
new file mode 100644
index 0000000000..af847828dc
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/start/disconnected/CurrentServerButton.java
@@ -0,0 +1,89 @@
+package com.skywire.skycoin.vpn.activities.start.disconnected;
+
+import android.content.Context;
+import android.graphics.drawable.RippleDrawable;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.MotionEvent;
+import android.view.View;
+import android.widget.FrameLayout;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.activities.servers.ServerLists;
+import com.skywire.skycoin.vpn.activities.servers.ServersActivity;
+import com.skywire.skycoin.vpn.controls.ServerName;
+import com.skywire.skycoin.vpn.extensible.ButtonBase;
+import com.skywire.skycoin.vpn.helpers.HelperFunctions;
+import com.skywire.skycoin.vpn.objects.LocalServerData;
+
+public class CurrentServerButton extends ButtonBase implements View.OnTouchListener {
+ public CurrentServerButton(Context context) {
+ super(context);
+ }
+ public CurrentServerButton(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+ public CurrentServerButton(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ }
+
+ private FrameLayout mainContainer;
+ private FrameLayout internalContainer;
+ private LinearLayout serverContainer;
+ private ImageView imageFlag;
+ private ServerName serverName;
+ private TextView textBottom;
+ private TextView textNoServer;
+
+ private RippleDrawable rippleDrawable;
+
+ @Override
+ protected void Initialize (Context context, AttributeSet attrs) {
+ LayoutInflater inflater = (LayoutInflater)context.getSystemService (Context.LAYOUT_INFLATER_SERVICE);
+ inflater.inflate(R.layout.view_current_server_button, this, true);
+
+ mainContainer = this.findViewById (R.id.mainContainer);
+ internalContainer = this.findViewById (R.id.internalContainer);
+ serverContainer = this.findViewById (R.id.serverContainer);
+ imageFlag = this.findViewById (R.id.imageFlag);
+ serverName = this.findViewById (R.id.serverName);
+ textBottom = this.findViewById (R.id.textBottom);
+ textNoServer = this.findViewById (R.id.textNoServer);
+
+ rippleDrawable = (RippleDrawable) internalContainer.getBackground();
+
+ mainContainer.setClipToOutline(true);
+ imageFlag.setClipToOutline(true);
+
+ setOnTouchListener(this);
+ setViewForCheckingClicks(this);
+ }
+
+ public void setData (LocalServerData currentServer) {
+ if (currentServer == null || currentServer.pk == null) {
+ textNoServer.setVisibility(VISIBLE);
+ serverContainer.setVisibility(GONE);
+
+ return;
+ }
+
+ serverContainer.setVisibility(VISIBLE);
+ textNoServer.setVisibility(GONE);
+
+ serverName.setServer(ServersActivity.convertLocalServerData(currentServer), ServerLists.History, true);
+ textBottom.setText(currentServer.pk);
+ imageFlag.setImageResource(HelperFunctions.getFlagResourceId(currentServer.countryCode));
+ }
+
+ @Override
+ public boolean onTouch(View v, MotionEvent event) {
+ if (rippleDrawable != null) {
+ rippleDrawable.setHotspot(event.getX(), event.getY());
+ }
+
+ return false;
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/start/disconnected/StartButton.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/start/disconnected/StartButton.java
new file mode 100644
index 0000000000..7ba05604e0
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/start/disconnected/StartButton.java
@@ -0,0 +1,95 @@
+package com.skywire.skycoin.vpn.activities.start.disconnected;
+
+import android.animation.Animator;
+import android.animation.AnimatorInflater;
+import android.animation.AnimatorSet;
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.MotionEvent;
+import android.view.View;
+import android.widget.FrameLayout;
+import android.widget.ImageView;
+
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.extensible.ButtonBase;
+
+public class StartButton extends ButtonBase implements Animator.AnimatorListener, View.OnTouchListener {
+ public StartButton(Context context) {
+ super(context);
+ }
+ public StartButton(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+ public StartButton(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ }
+
+ private FrameLayout mainLayout;
+ private ImageView imageAnim;
+ private ImageView imageBackground;
+
+ private AnimatorSet animSet;
+
+ protected void Initialize (Context context, AttributeSet attrs) {
+ LayoutInflater inflater = (LayoutInflater)context.getSystemService (Context.LAYOUT_INFLATER_SERVICE);
+ inflater.inflate(R.layout.view_start_button, this, true);
+
+ mainLayout = this.findViewById(R.id.mainLayout);
+ imageAnim = this.findViewById(R.id.imageAnim);
+ imageBackground = this.findViewById(R.id.imageBackground);
+
+ animSet = (AnimatorSet) AnimatorInflater.loadAnimator(getContext(), R.animator.anim_start_button);
+ animSet.setTarget(imageAnim);
+
+ setOnTouchListener(this);
+ setViewForCheckingClicks(this);
+ }
+
+ public void startAnimation() {
+ animSet.addListener(this);
+ animSet.start();
+ }
+
+ public void stopAnimation() {
+ animSet.removeAllListeners();
+ animSet.cancel();
+ }
+
+ @Override
+ public void onAnimationStart(Animator animation) { }
+ @Override
+ public void onAnimationCancel(Animator animation) { }
+ @Override
+ public void onAnimationRepeat(Animator animation) { }
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ animSet.start();
+ }
+
+ @Override
+ public boolean onTouch(View v, MotionEvent event) {
+ if (event.getAction() == MotionEvent.ACTION_DOWN) {
+ mainLayout.setScaleX(0.9f);
+ mainLayout.setScaleY(0.9f);
+ imageBackground.setAlpha(1.0f);
+ } else if (event.getAction() == MotionEvent.ACTION_CANCEL || event.getAction() == MotionEvent.ACTION_POINTER_UP || event.getAction() == MotionEvent.ACTION_UP) {
+ mainLayout.setScaleX(1.0f);
+ mainLayout.setScaleY(1.0f);
+ imageBackground.setAlpha(0.7f);
+ }
+
+ return false;
+ }
+
+ @Override
+ public void setEnabled(boolean enabled) {
+ super.setEnabled(enabled);
+
+ if (enabled) {
+ setAlpha(1f);
+ } else {
+ setAlpha(0.5f);
+ }
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/start/disconnected/StartViewDisconnected.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/start/disconnected/StartViewDisconnected.java
new file mode 100644
index 0000000000..67d59299eb
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/start/disconnected/StartViewDisconnected.java
@@ -0,0 +1,156 @@
+package com.skywire.skycoin.vpn.activities.start.disconnected;
+
+import android.app.Activity;
+import android.content.Context;
+import android.util.AttributeSet;
+import android.util.TypedValue;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.FrameLayout;
+import android.widget.TextView;
+
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.activities.index.IndexPageAdapter;
+import com.skywire.skycoin.vpn.activities.servers.ServerLists;
+import com.skywire.skycoin.vpn.activities.servers.ServersActivity;
+import com.skywire.skycoin.vpn.activities.start.StartViewRightPanel;
+import com.skywire.skycoin.vpn.extensible.ClickEvent;
+import com.skywire.skycoin.vpn.helpers.HelperFunctions;
+import com.skywire.skycoin.vpn.objects.LocalServerData;
+import com.skywire.skycoin.vpn.vpn.VPNGeneralPersistentData;
+import com.skywire.skycoin.vpn.vpn.VPNServersPersistentData;
+
+import java.io.Closeable;
+
+import io.reactivex.rxjava3.disposables.Disposable;
+
+public class StartViewDisconnected extends FrameLayout implements ClickEvent, Closeable {
+ public StartViewDisconnected(Context context) {
+ super(context);
+ Initialize(context, null);
+ }
+ public StartViewDisconnected(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ Initialize(context, attrs);
+ }
+ public StartViewDisconnected(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ Initialize(context, attrs);
+ }
+
+ private CurrentServerButton viewCurrentServerButton;
+ private StartButton startButton;
+ private TextView textServerNote;
+ private TextView textLastError;
+ private FrameLayout rightContainer;
+ private StartViewRightPanel rightPanel;
+
+ private Activity parentActivity;
+ private IndexPageAdapter.RequestTabListener requestTabListener;
+ private Disposable currentServerSubscription;
+
+ protected void Initialize (Context context, AttributeSet attrs) {
+ LayoutInflater inflater = (LayoutInflater)context.getSystemService (Context.LAYOUT_INFLATER_SERVICE);
+ inflater.inflate(R.layout.view_start_disconnected, this, true);
+
+ viewCurrentServerButton = findViewById(R.id.viewCurrentServerButton);
+ startButton = findViewById(R.id.startButton);
+ textServerNote = findViewById(R.id.textServerNote);
+ textLastError = findViewById(R.id.textLastError);
+ rightContainer = findViewById(R.id.rightContainer);
+ rightPanel = findViewById(R.id.rightPanel);
+
+ viewCurrentServerButton.setClickEventListener(this);
+ startButton.setClickEventListener(this);
+
+ currentServerSubscription = VPNServersPersistentData.getInstance().getCurrentServerObservable().subscribe(currentServer -> {
+ viewCurrentServerButton.setData(currentServer);
+ updateNote(currentServer);
+ });
+
+ setErrorMsg(VPNGeneralPersistentData.getLastError(null));
+
+ if (HelperFunctions.getWidthType(getContext()) == HelperFunctions.WidthTypes.SMALL) {
+ rightContainer.setVisibility(GONE);
+ } else {
+ textServerNote.setTextSize(TypedValue.COMPLEX_UNIT_PX, getContext().getResources().getDimension(R.dimen.small_text_size));
+ textLastError.setTextSize(TypedValue.COMPLEX_UNIT_PX, getContext().getResources().getDimension(R.dimen.small_text_size));
+ rightPanel.refreshIpData();
+ }
+ }
+
+ public void setRequestTabListener(IndexPageAdapter.RequestTabListener listener) {
+ requestTabListener = listener;
+ }
+
+ public void setParentActivity(Activity activity) {
+ parentActivity = activity;
+ }
+
+ public void startAnimation() {
+ startButton.startAnimation();
+ }
+
+ public void stopAnimation() {
+ startButton.stopAnimation();
+ }
+
+ public void updateRightBar() {
+ rightPanel.updateData();
+ }
+
+ public void setErrorMsg(String errorMsg) {
+ if (errorMsg != null) {
+ String start = getContext().getString(R.string.tmp_status_page_last_error);
+ textLastError.setText(start + " " + errorMsg);
+ textLastError.setVisibility(VISIBLE);
+ } else {
+ textLastError.setVisibility(GONE);
+ }
+ }
+
+ private void updateNote(LocalServerData currentServer) {
+ if (currentServer == null) {
+ textServerNote.setVisibility(GONE);
+
+ return;
+ }
+
+ String note = HelperFunctions.getServerNote(currentServer);
+
+ if (note != null) {
+ textServerNote.setText(note);
+ textServerNote.setVisibility(VISIBLE);
+ } else {
+ textServerNote.setVisibility(GONE);
+ }
+ }
+
+ @Override
+ public void close() {
+ currentServerSubscription.dispose();
+ rightPanel.close();
+ stopAnimation();
+ }
+
+ @Override
+ public void onClick(View view) {
+ LocalServerData currentServer = VPNServersPersistentData.getInstance().getCurrentServer();
+ if (currentServer != null) {
+ if (view.getId() == R.id.viewCurrentServerButton) {
+ HelperFunctions.showServerOptions(getContext(), ServersActivity.convertLocalServerData(currentServer), ServerLists.History);
+ } else {
+ if (parentActivity != null) {
+ boolean starting = HelperFunctions.prepareAndStartVpn(parentActivity, currentServer);
+ if (starting) {
+ startButton.setEnabled(false);
+ }
+ }
+ }
+ } else {
+ if (requestTabListener != null) {
+ requestTabListener.onOpenServerListRequested();
+ }
+ }
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/BoxRowBackground.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/BoxRowBackground.java
new file mode 100644
index 0000000000..9df7c76f0e
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/BoxRowBackground.java
@@ -0,0 +1,66 @@
+package com.skywire.skycoin.vpn.controls;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.Canvas;
+import android.graphics.Rect;
+import android.graphics.Shader;
+import android.graphics.drawable.BitmapDrawable;
+import android.util.AttributeSet;
+import android.view.View;
+import android.view.ViewOutlineProvider;
+
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.helpers.BoxRowTypes;
+
+public class BoxRowBackground extends View {
+ public BoxRowBackground(Context context) {
+ super(context);
+ Initialize(context, null);
+ }
+ public BoxRowBackground(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ Initialize(context, attrs);
+ }
+ public BoxRowBackground(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ Initialize(context, attrs);
+ }
+
+ BitmapDrawable bitmapDrawable;
+
+ private void Initialize (Context context, AttributeSet attrs) {
+ setOutlineProvider(ViewOutlineProvider.BACKGROUND);
+ setClipToOutline(true);
+
+ Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.box_pattern);
+
+ bitmapDrawable = new BitmapDrawable(context.getResources(), bitmap);
+ bitmapDrawable.setTileModeXY(Shader.TileMode.REPEAT, Shader.TileMode.REPEAT);
+
+ setType(BoxRowTypes.TOP);
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ bitmapDrawable.setBounds(new Rect(0, 0, canvas.getWidth(), canvas.getHeight()));
+ bitmapDrawable.draw(canvas);
+
+ super.onDraw(canvas);
+ }
+
+ public void setType(BoxRowTypes type) {
+ if (type == BoxRowTypes.TOP) {
+ setBackgroundResource(R.drawable.box_row_rounded_box_1);
+ } else if (type == BoxRowTypes.MIDDLE) {
+ setBackgroundResource(R.drawable.box_row_rounded_box_2);
+ } else if (type == BoxRowTypes.BOTTOM) {
+ setBackgroundResource(R.drawable.box_row_rounded_box_3);
+ } else {
+ setBackgroundResource(R.drawable.box_row_rounded_box_4);
+ }
+
+ this.invalidate();
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/BoxRowLayout.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/BoxRowLayout.java
new file mode 100644
index 0000000000..868c713d29
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/BoxRowLayout.java
@@ -0,0 +1,219 @@
+package com.skywire.skycoin.vpn.controls;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.util.AttributeSet;
+import android.util.TypedValue;
+import android.view.Gravity;
+import android.view.View;
+import android.widget.FrameLayout;
+
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.extensible.ClickEvent;
+import com.skywire.skycoin.vpn.helpers.BoxRowTypes;
+import com.skywire.skycoin.vpn.helpers.HelperFunctions;
+
+public class BoxRowLayout extends FrameLayout implements ClickEvent {
+ public BoxRowLayout(Context context) {
+ super(context);
+ Initialize(context, null);
+ }
+ public BoxRowLayout(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ Initialize(context, attrs);
+ }
+ public BoxRowLayout(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ Initialize(context, attrs);
+ }
+
+ private View baseBackground;
+ private BoxRowBackground background;
+ private BoxRowRipple ripple;
+ private View separator;
+
+ private ClickEvent clickListener;
+
+ private boolean addExtraPaddingForTablets = false;
+ private boolean ignoreMargins = false;
+ private boolean ignoreClicks = false;
+ private boolean hideSeparator = false;
+
+ private int tabletExtraHorizontalPadding = 0;
+ private float horizontalPadding;
+ private float verticalPadding;
+
+ private void Initialize (Context context, AttributeSet attrs) {
+ baseBackground = new View(context);
+ background = new BoxRowBackground(context);
+ ripple = new BoxRowRipple(context);
+ separator = new View(context);
+
+ int type = 1;
+
+ if (attrs != null) {
+ TypedArray attributes = context.getTheme().obtainStyledAttributes(
+ attrs,
+ R.styleable.BoxRowLayout,
+ 0, 0
+ );
+
+ type = attributes.getInteger(R.styleable.BoxRowLayout_box_row_type, 1);
+
+ addExtraPaddingForTablets = attributes.getBoolean(R.styleable.BoxRowLayout_add_extra_padding_for_tablets, false);
+ ignoreMargins = attributes.getBoolean(R.styleable.BoxRowLayout_ignore_margins, false);
+ ignoreClicks = attributes.getBoolean(R.styleable.BoxRowLayout_ignore_clicks, false);
+ hideSeparator = attributes.getBoolean(R.styleable.BoxRowLayout_hide_separator, false);
+
+ setUseBigFastClickPrevention(attributes.getBoolean(R.styleable.BoxRowLayout_use_big_fast_click_prevention, true));
+
+ attributes.recycle();
+ }
+
+ horizontalPadding = TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP,
+ 10,
+ getResources().getDisplayMetrics()
+ );
+ if (!ignoreMargins) {
+ horizontalPadding += getContext().getResources().getDimension(R.dimen.box_row_layout_horizontal_padding);
+ }
+
+ verticalPadding = 0;
+ if (!ignoreMargins) {
+ verticalPadding += getContext().getResources().getDimension(R.dimen.box_row_layout_vertical_padding);
+ }
+
+ if (addExtraPaddingForTablets) {
+ tabletExtraHorizontalPadding = HelperFunctions.getTabletExtraHorizontalPadding(getContext());
+ }
+
+ separator.setBackgroundResource(R.color.box_separator);
+
+ if (type == 0) {
+ setType(BoxRowTypes.TOP);
+ } else if (type == 1) {
+ setType(BoxRowTypes.MIDDLE);
+ } else if (type == 2) {
+ setType(BoxRowTypes.BOTTOM);
+ } else if (type == 3) {
+ setType(BoxRowTypes.SINGLE);
+ }
+
+ this.setClipToPadding(false);
+
+ this.addView(baseBackground);
+ this.addView(background);
+ if (!ignoreClicks) {
+ ripple.setClickEventListener(this);
+ this.addView(ripple);
+ }
+ this.addView(separator);
+
+ setClickable(false);
+ }
+
+ public void setClickEventListener(ClickEvent listener) {
+ clickListener = listener;
+ }
+
+ public void setUseBigFastClickPrevention(boolean useBigFastClickPrevention) {
+ ripple.setUseBigFastClickPrevention(useBigFastClickPrevention);
+ }
+
+ public void setType(BoxRowTypes type) {
+ float bottomPaddingExtra = 0;
+ float topPaddingExtra = 0;
+
+ if (type == BoxRowTypes.TOP) {
+ baseBackground.setBackgroundResource(R.drawable.background_box1);
+
+ topPaddingExtra = TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP,
+ 10,
+ getResources().getDisplayMetrics()
+ );
+
+ separator.setVisibility(View.VISIBLE);
+ } else if (type == BoxRowTypes.MIDDLE) {
+ baseBackground.setBackgroundResource(R.drawable.background_box2);
+ separator.setVisibility(View.VISIBLE);
+ } else if (type == BoxRowTypes.BOTTOM) {
+ baseBackground.setBackgroundResource(R.drawable.background_box3);
+
+ bottomPaddingExtra = TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP,
+ 15,
+ getResources().getDisplayMetrics()
+ );
+
+ separator.setVisibility(View.GONE);
+ } else if (type == BoxRowTypes.SINGLE) {
+ baseBackground.setBackgroundResource(R.drawable.background_box4);
+
+ topPaddingExtra = TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP,
+ 10,
+ getResources().getDisplayMetrics()
+ );
+ bottomPaddingExtra = TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP,
+ 15,
+ getResources().getDisplayMetrics()
+ );
+
+ separator.setVisibility(View.GONE);
+ }
+
+ if (hideSeparator) {
+ separator.setVisibility(View.GONE);
+ }
+
+ int finalLeftPadding = (int)horizontalPadding;
+ int finalTopPadding = (int)(verticalPadding + topPaddingExtra);
+ int finalRightPadding = (int)horizontalPadding;
+ int finalBottomPadding = (int)(verticalPadding + bottomPaddingExtra);
+
+ this.setPadding(finalLeftPadding + tabletExtraHorizontalPadding, finalTopPadding, finalRightPadding + tabletExtraHorizontalPadding, finalBottomPadding);
+
+ FrameLayout.LayoutParams backgroundLayoutParams = new FrameLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
+ backgroundLayoutParams.leftMargin = -finalLeftPadding;
+ backgroundLayoutParams.rightMargin = -finalRightPadding;
+ if (finalTopPadding > 0) {
+ backgroundLayoutParams.topMargin = -finalTopPadding;
+ }
+ if (finalBottomPadding > 0) {
+ backgroundLayoutParams.bottomMargin = -finalBottomPadding;
+ }
+
+ baseBackground.setLayoutParams(backgroundLayoutParams);
+ background.setLayoutParams(backgroundLayoutParams);
+ background.setType(type);
+ if (!ignoreClicks) {
+ ripple.setLayoutParams(backgroundLayoutParams);
+ ripple.setType(type);
+ }
+
+ float separatorHeight = getContext().getResources().getDimension(R.dimen.box_row_layout_separator_height);
+ float separatorHorizontalMargin;
+ if (ignoreMargins) {
+ separatorHorizontalMargin = getContext().getResources().getDimension(R.dimen.box_row_layout_separator_combined_horizontal_margin);
+ } else {
+ separatorHorizontalMargin = getContext().getResources().getDimension(R.dimen.box_row_layout_separator_horizontal_margin);
+ }
+
+ FrameLayout.LayoutParams separatorLayoutParams = new FrameLayout.LayoutParams(LayoutParams.MATCH_PARENT, (int)Math.round(separatorHeight));
+ separatorLayoutParams.gravity = Gravity.BOTTOM;
+ separatorLayoutParams.bottomMargin = -finalBottomPadding;
+ separatorLayoutParams.leftMargin = (int)separatorHorizontalMargin;
+ separatorLayoutParams.rightMargin = (int)separatorHorizontalMargin;
+ separator.setLayoutParams(separatorLayoutParams);
+ }
+
+ @Override
+ public void onClick(View view) {
+ if (clickListener != null) {
+ clickListener.onClick(this);
+ }
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/BoxRowRipple.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/BoxRowRipple.java
new file mode 100644
index 0000000000..42ea085859
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/BoxRowRipple.java
@@ -0,0 +1,68 @@
+package com.skywire.skycoin.vpn.controls;
+
+import android.content.Context;
+import android.graphics.drawable.RippleDrawable;
+import android.util.AttributeSet;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewOutlineProvider;
+import android.widget.FrameLayout;
+
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.extensible.ButtonBase;
+import com.skywire.skycoin.vpn.helpers.BoxRowTypes;
+
+public class BoxRowRipple extends ButtonBase implements View.OnTouchListener {
+ public BoxRowRipple(Context context) {
+ super(context);
+ }
+ public BoxRowRipple(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+ public BoxRowRipple(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ }
+
+ RippleDrawable rippleDrawable;
+
+ @Override
+ protected void Initialize (Context context, AttributeSet attrs) {
+ setOutlineProvider(ViewOutlineProvider.BACKGROUND);
+ setClipToOutline(true);
+ setClickable(true);
+
+ View ripple = new View(context);
+ FrameLayout.LayoutParams rippleLayoutParams = new FrameLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
+ ripple.setLayoutParams(rippleLayoutParams);
+ ripple.setBackgroundResource(R.drawable.box_ripple);
+ this.addView(ripple);
+
+ rippleDrawable = (RippleDrawable) ripple.getBackground();
+
+ ripple.setOnTouchListener(this);
+ setViewForCheckingClicks(ripple);
+
+ setType(BoxRowTypes.TOP);
+ }
+
+ public void setType(BoxRowTypes type) {
+ if (type == BoxRowTypes.TOP) {
+ setBackgroundResource(R.drawable.box_row_rounded_box_1);
+ } else if (type == BoxRowTypes.MIDDLE) {
+ setBackgroundResource(R.drawable.box_row_rounded_box_2);
+ } else if (type == BoxRowTypes.BOTTOM) {
+ setBackgroundResource(R.drawable.box_row_rounded_box_3);
+ } else {
+ setBackgroundResource(R.drawable.box_row_rounded_box_4);
+ }
+
+ this.invalidate();
+ }
+
+ @Override
+ public boolean onTouch(View v, MotionEvent event) {
+ rippleDrawable.setHotspot(event.getX(), event.getY());
+
+ return false;
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/ClickableLinearLayout.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/ClickableLinearLayout.java
new file mode 100644
index 0000000000..78939294e2
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/ClickableLinearLayout.java
@@ -0,0 +1,66 @@
+package com.skywire.skycoin.vpn.controls;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.MotionEvent;
+import android.view.View;
+import android.widget.LinearLayout;
+
+import com.skywire.skycoin.vpn.extensible.ClickEvent;
+import com.skywire.skycoin.vpn.helpers.ClickTimeManagement;
+import com.skywire.skycoin.vpn.helpers.Globals;
+
+import java.util.concurrent.TimeUnit;
+
+import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
+import io.reactivex.rxjava3.core.Observable;
+import io.reactivex.rxjava3.schedulers.Schedulers;
+
+public class ClickableLinearLayout extends LinearLayout implements View.OnTouchListener, View.OnClickListener {
+ private ClickEvent clickListener;
+ private ClickTimeManagement buttonTimeManager = new ClickTimeManagement();
+
+ public ClickableLinearLayout(Context context) {
+ super(context);
+ Initialize(context, null);
+ }
+ public ClickableLinearLayout(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ Initialize(context, attrs);
+ }
+ public ClickableLinearLayout(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ Initialize(context, attrs);
+ }
+
+ protected void Initialize (Context context, AttributeSet attrs) {
+ setOnTouchListener(this);
+ setOnClickListener(this);
+ }
+
+ public void setClickEventListener(ClickEvent listener) {
+ clickListener = listener;
+ }
+
+ @Override
+ public boolean onTouch(View v, MotionEvent event) {
+ if (event.getAction() == MotionEvent.ACTION_DOWN) {
+ setAlpha(0.5f);
+ } else if (event.getAction() == MotionEvent.ACTION_CANCEL || event.getAction() == MotionEvent.ACTION_POINTER_UP || event.getAction() == MotionEvent.ACTION_UP) {
+ setAlpha(1f);
+ }
+
+ return false;
+ }
+
+ @Override
+ public void onClick(View view) {
+ if (clickListener != null && buttonTimeManager.canClick()) {
+ buttonTimeManager.informClickMade();
+ Observable.just(1).delay(Globals.CLICK_DELAY_MS, TimeUnit.MILLISECONDS)
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(v -> clickListener.onClick(this));
+ }
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/ConfirmationModalWindow.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/ConfirmationModalWindow.java
new file mode 100644
index 0000000000..cea138f401
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/ConfirmationModalWindow.java
@@ -0,0 +1,65 @@
+package com.skywire.skycoin.vpn.controls;
+
+import android.app.Dialog;
+import android.content.Context;
+import android.os.Bundle;
+import android.view.View;
+import android.view.Window;
+import android.widget.TextView;
+
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.extensible.ClickEvent;
+import com.skywire.skycoin.vpn.helpers.HelperFunctions;
+
+public class ConfirmationModalWindow extends Dialog implements ClickEvent {
+ public interface Confirmed {
+ void confirmed();
+ }
+
+ private TextView text;
+ private ModalWindowButton buttonCancel;
+ private ModalWindowButton buttonConfirm;
+
+ private int textResource;
+ private int confirmBtnResource;
+ private int cancelBtnResource;
+ private Confirmed event;
+
+ public ConfirmationModalWindow(Context ctx, int textResource, int confirmBtnResource, int cancelBtnResource, Confirmed event) {
+ super(ctx);
+
+ this.textResource = textResource;
+ this.confirmBtnResource = confirmBtnResource;
+ this.cancelBtnResource = cancelBtnResource;
+ this.event = event;
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ requestWindowFeature(Window.FEATURE_NO_TITLE);
+ setContentView(R.layout.view_confirmation_dialog);
+
+ text = findViewById(R.id.text);
+ buttonCancel = findViewById(R.id.buttonCancel);
+ buttonConfirm = findViewById(R.id.buttonConfirm);
+
+ text.setText(textResource);
+ buttonCancel.setText(cancelBtnResource);
+ buttonConfirm.setText(confirmBtnResource);
+
+ buttonCancel.setClickEventListener(this);
+ buttonConfirm.setClickEventListener(this);
+
+ HelperFunctions.configureModalWindow(this);
+ }
+
+ @Override
+ public void onClick(View view) {
+ if (view.getId() == R.id.buttonConfirm && event != null) {
+ event.confirmed();
+ }
+
+ dismiss();
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/EditServerValueModalWindow.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/EditServerValueModalWindow.java
new file mode 100644
index 0000000000..e11fa85e9a
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/EditServerValueModalWindow.java
@@ -0,0 +1,122 @@
+package com.skywire.skycoin.vpn.controls;
+
+import android.app.Dialog;
+import android.content.Context;
+import android.os.Bundle;
+import android.view.KeyEvent;
+import android.view.View;
+import android.view.Window;
+import android.view.inputmethod.EditorInfo;
+import android.widget.EditText;
+
+import com.google.android.material.textfield.TextInputLayout;
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.activities.servers.VpnServerForList;
+import com.skywire.skycoin.vpn.extensible.ClickEvent;
+import com.skywire.skycoin.vpn.helpers.HelperFunctions;
+import com.skywire.skycoin.vpn.objects.LocalServerData;
+import com.skywire.skycoin.vpn.vpn.VPNServersPersistentData;
+
+public class EditServerValueModalWindow extends Dialog implements ClickEvent {
+ private ModalBase modalBase;
+ private TextInputLayout editContainer;
+ private EditText editValue;
+ private ModalWindowButton buttonCancel;
+ private ModalWindowButton buttonConfirm;
+
+ private boolean editingName;
+ private VpnServerForList server;
+
+ public EditServerValueModalWindow(Context ctx, boolean editingName, VpnServerForList server) {
+ super(ctx);
+
+ this.editingName = editingName;
+ this.server = server;
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ requestWindowFeature(Window.FEATURE_NO_TITLE);
+ setContentView(R.layout.view_edit_server_value_modal);
+
+ modalBase = findViewById(R.id.modalBase);
+ editContainer = findViewById(R.id.editContainer);
+ editValue = findViewById(R.id.editValue);
+ buttonCancel = findViewById(R.id.buttonCancel);
+ buttonConfirm = findViewById(R.id.buttonConfirm);
+
+ LocalServerData localServerData = VPNServersPersistentData.getInstance().processFromList(server);
+ if (editingName) {
+ modalBase.setTitle(R.string.tmp_edit_value_name_title);
+ editContainer.setHint(getContext().getText(R.string.tmp_edit_value_name_label));
+
+ if (localServerData.customName != null) {
+ editValue.setText(localServerData.customName);
+ } else {
+ editValue.setText("");
+ }
+ } else {
+ modalBase.setTitle(R.string.tmp_edit_value_note_title);
+ editContainer.setHint(getContext().getText(R.string.tmp_edit_value_note_label));
+
+ if (localServerData.personalNote != null) {
+ editValue.setText(localServerData.personalNote);
+ } else {
+ editValue.setText("");
+ }
+ }
+
+ editValue.setOnEditorActionListener((v, actionId, event) -> {
+ if (
+ actionId == EditorInfo.IME_ACTION_DONE ||
+ (event != null && event.getAction() == KeyEvent.ACTION_DOWN && event.getKeyCode() == KeyEvent.KEYCODE_ENTER)
+ ) {
+ makeChange();
+ dismiss();
+
+ return true;
+ }
+
+ return false;
+ });
+
+ editValue.setSelection(editValue.getText().length());
+
+ buttonCancel.setClickEventListener(this);
+ buttonConfirm.setClickEventListener(this);
+
+ HelperFunctions.configureModalWindow(this);
+ }
+
+ @Override
+ public void onClick(View view) {
+ if (view.getId() == R.id.buttonConfirm) {
+ makeChange();
+ }
+
+ dismiss();
+ }
+
+ private void makeChange() {
+ LocalServerData localServerData = VPNServersPersistentData.getInstance().processFromList(server);
+
+ String newValue = editValue.getText().toString().trim();
+ String currentValue = editingName ? localServerData.customName : localServerData.personalNote;
+ if (currentValue == null) {
+ currentValue = "";
+ }
+ if (newValue.equals(currentValue)) {
+ return;
+ }
+
+ if (editingName) {
+ localServerData.customName = newValue;
+ } else {
+ localServerData.personalNote = newValue;
+ }
+ VPNServersPersistentData.getInstance().updateServer(localServerData);
+
+ HelperFunctions.showToast(getContext().getString(R.string.tmp_edit_value_changes_made_confirmation), true);
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/ManualServerModalWindow.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/ManualServerModalWindow.java
new file mode 100644
index 0000000000..cab1ee98c2
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/ManualServerModalWindow.java
@@ -0,0 +1,154 @@
+package com.skywire.skycoin.vpn.controls;
+
+import android.app.Dialog;
+import android.content.Context;
+import android.os.Bundle;
+import android.text.Editable;
+import android.text.TextWatcher;
+import android.view.KeyEvent;
+import android.view.View;
+import android.view.Window;
+import android.view.inputmethod.EditorInfo;
+import android.widget.EditText;
+
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.extensible.ClickEvent;
+import com.skywire.skycoin.vpn.helpers.HelperFunctions;
+import com.skywire.skycoin.vpn.objects.LocalServerData;
+import com.skywire.skycoin.vpn.objects.ManualVpnServerData;
+import com.skywire.skycoin.vpn.vpn.VPNServersPersistentData;
+
+import skywiremob.Skywiremob;
+
+public class ManualServerModalWindow extends Dialog implements ClickEvent, TextWatcher {
+ public interface Confirmed {
+ void confirmed(LocalServerData server);
+ }
+
+ private EditText editPk;
+ private EditText editPassword;
+ private EditText editName;
+ private EditText editNote;
+ private ModalWindowButton buttonCancel;
+ private ModalWindowButton buttonConfirm;
+
+ private Confirmed event;
+ private boolean hasError;
+
+ public ManualServerModalWindow(Context ctx, Confirmed event) {
+ super(ctx);
+
+ this.event = event;
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ requestWindowFeature(Window.FEATURE_NO_TITLE);
+ setContentView(R.layout.view_manual_server_modal);
+
+ editPk = findViewById(R.id.editPk);
+ editPassword = findViewById(R.id.editPassword);
+ editName = findViewById(R.id.editName);
+ editNote = findViewById(R.id.editNote);
+ buttonCancel = findViewById(R.id.buttonCancel);
+ buttonConfirm = findViewById(R.id.buttonConfirm);
+
+ editPk.addTextChangedListener(this);
+
+ editPk.setImeOptions(EditorInfo.IME_ACTION_NEXT);
+ editName.setImeOptions(EditorInfo.IME_ACTION_NEXT);
+ editNote.setImeOptions(EditorInfo.IME_ACTION_DONE);
+
+ editPk.setSelection(editName.getText().length());
+
+ editNote.setOnEditorActionListener((v, actionId, event) -> {
+ if (
+ actionId == EditorInfo.IME_ACTION_DONE ||
+ (event != null && event.getAction() == KeyEvent.ACTION_DOWN && event.getKeyCode() == KeyEvent.KEYCODE_ENTER)
+ ) {
+ if (!hasError) {
+ process();
+ dismiss();
+ }
+
+ return true;
+ }
+
+ return false;
+ });
+
+ buttonCancel.setClickEventListener(this);
+ buttonConfirm.setClickEventListener(this);
+
+ buttonConfirm.setEnabled(false);
+ hasError = true;
+
+ HelperFunctions.configureModalWindow(this);
+ }
+
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
+ @Override
+ public void afterTextChanged(Editable s) { }
+
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+ hasError = false;
+ if (editPk.getText().length() < 66) {
+ editPk.setError(getContext().getText(R.string.add_server_pk_length_error));
+ hasError = true;
+ } else if (Skywiremob.isPKValid(editPk.getText().toString()).getCode() != Skywiremob.ErrCodeNoError) {
+ editPk.setError(getContext().getText(R.string.add_server_pk_invalid_error));
+ hasError = true;
+ }
+
+ if (hasError) {
+ buttonConfirm.setEnabled(false);
+ } else {
+ buttonConfirm.setEnabled(true);
+ }
+ }
+
+ @Override
+ public void onClick(View view) {
+ if (view.getId() == R.id.buttonConfirm) {
+ process();
+ }
+
+ dismiss();
+ }
+
+ private void process() {
+ if (hasError) {
+ return;
+ }
+
+ LocalServerData savedVersion = VPNServersPersistentData.getInstance().getSavedVersion(editPk.getText().toString().trim());
+
+ ManualVpnServerData serverData = new ManualVpnServerData();
+ serverData.pk = editPk.getText().toString().trim();
+
+ String password = editPassword.getText().toString();
+ if (password != null && !password.equals("")) {
+ serverData.password = password;
+ }
+
+ if (editName.getText() != null && !editName.getText().toString().trim().equals("")) {
+ serverData.name = editName.getText().toString().trim();
+ } else if (savedVersion != null && savedVersion.customName != null && !savedVersion.customName.equals("")) {
+ serverData.name = savedVersion.customName;
+ }
+
+ if (editNote.getText() != null && !editNote.getText().toString().trim().equals("")) {
+ serverData.note = editNote.getText().toString().trim();
+ } else if (savedVersion != null && savedVersion.personalNote != null && !savedVersion.personalNote.equals("")) {
+ serverData.note = savedVersion.personalNote;
+ }
+
+ LocalServerData localServerData = VPNServersPersistentData.getInstance().processFromManual(serverData);
+ if (event != null) {
+ event.confirmed(localServerData);
+ }
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/ModalBase.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/ModalBase.java
new file mode 100644
index 0000000000..cfb280343a
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/ModalBase.java
@@ -0,0 +1,115 @@
+package com.skywire.skycoin.vpn.controls;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.FrameLayout;
+import android.widget.TextView;
+
+import com.skywire.skycoin.vpn.R;
+
+public class ModalBase extends FrameLayout {
+ public ModalBase(Context context) {
+ super(context);
+ Initialize(context, null);
+ }
+ public ModalBase(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ Initialize(context, attrs);
+ }
+ public ModalBase(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ Initialize(context, attrs);
+ }
+
+ private FrameLayout mainContainer;
+ private TextView textTitle;
+ private FrameLayout contentArea;
+
+ private void Initialize (Context context, AttributeSet attrs) {
+ LayoutInflater inflater = (LayoutInflater)context.getSystemService (Context.LAYOUT_INFLATER_SERVICE);
+ inflater.inflate(R.layout.view_modal_base, this, true);
+
+ mainContainer = findViewById(R.id.mainContainer);
+ textTitle = findViewById(R.id.textTitle);
+ contentArea = findViewById(R.id.contentArea);
+
+ mainContainer.setClipToOutline(true);
+
+ if (attrs != null) {
+ TypedArray attributes = context.getTheme().obtainStyledAttributes(
+ attrs,
+ R.styleable.ModalBase,
+ 0, 0
+ );
+
+ String title = attributes.getString(R.styleable.ModalBase_title);
+ if (title != null) {
+ textTitle.setText(title);
+ }
+
+ boolean removeInternalPadding = attributes.getBoolean(R.styleable.ModalBase_remove_internal_padding, false);
+ if (removeInternalPadding) {
+ contentArea.setPadding(0, 0, 0, 0);
+ }
+
+ attributes.recycle();
+ }
+ }
+
+ public void setTitle(int resourceId) {
+ textTitle.setText(resourceId);
+ }
+
+ public void setTitleString(String title) {
+ textTitle.setText(title);
+ }
+
+ @Override
+ public void addView(View child) {
+ if (contentArea != null) {
+ contentArea.addView(child);
+ } else {
+ super.addView(child);
+ }
+ }
+
+ @Override
+ public void addView(View child, int index) {
+ if (contentArea != null) {
+ contentArea.addView(child, index);
+ } else {
+ super.addView(child, index);
+ }
+ }
+
+ @Override
+ public void addView(View child, ViewGroup.LayoutParams params) {
+ if (contentArea != null) {
+ contentArea.addView(child, params);
+ } else {
+ super.addView(child, params);
+ }
+ }
+
+ @Override
+ public void addView(View child, int width, int height) {
+ if (contentArea != null) {
+ contentArea.addView(child, width, height);
+ } else {
+ super.addView(child, width, height);
+ }
+ }
+
+ @Override
+ public void addView(View child, int index, ViewGroup.LayoutParams params) {
+ if (contentArea != null) {
+ contentArea.addView(child, index, params);
+ } else {
+ super.addView(child, index, params);
+ }
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/ModalWindowButton.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/ModalWindowButton.java
new file mode 100644
index 0000000000..762fe9550a
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/ModalWindowButton.java
@@ -0,0 +1,93 @@
+package com.skywire.skycoin.vpn.controls;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.drawable.RippleDrawable;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.MotionEvent;
+import android.view.View;
+import android.widget.FrameLayout;
+import android.widget.TextView;
+
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.extensible.ButtonBase;
+
+public class ModalWindowButton extends ButtonBase implements View.OnTouchListener {
+ private FrameLayout mainContainer;
+ private FrameLayout effectContainer;
+ private TextView text;
+
+ private RippleDrawable rippleDrawable;
+
+ public ModalWindowButton(Context context) {
+ super(context);
+ }
+ public ModalWindowButton(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+ public ModalWindowButton(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ }
+
+ @Override
+ protected void Initialize (Context context, AttributeSet attrs) {
+ LayoutInflater inflater = (LayoutInflater)context.getSystemService (Context.LAYOUT_INFLATER_SERVICE);
+ inflater.inflate(R.layout.view_modal_window_button, this, true);
+
+ mainContainer = this.findViewById (R.id.mainContainer);
+ effectContainer = this.findViewById (R.id.effectContainer);
+ text = this.findViewById (R.id.text);
+
+ mainContainer.setClipToOutline(true);
+
+ if (attrs != null) {
+ TypedArray attributes = context.getTheme().obtainStyledAttributes(
+ attrs,
+ R.styleable.ModalWindowButton,
+ 0, 0
+ );
+
+ String textForButton = attributes.getString(R.styleable.ModalWindowButton_text);
+ if (textForButton != null) {
+ text.setText(textForButton);
+ }
+
+ if (attributes.getBoolean(R.styleable.ModalWindowButton_use_secondary_color, false)) {
+ mainContainer.setBackgroundResource(R.drawable.modal_button_secondary_background);
+ effectContainer.setBackgroundResource(R.drawable.modal_button_secondary_ripple);
+ }
+
+ attributes.recycle();
+ }
+
+ rippleDrawable = (RippleDrawable) effectContainer.getBackground();
+
+ setOnTouchListener(this);
+ setViewForCheckingClicks(this);
+ }
+
+ public void setText(int resourceId) {
+ text.setText(resourceId);
+ }
+
+ @Override
+ public boolean onTouch(View v, MotionEvent event) {
+ if (rippleDrawable != null) {
+ rippleDrawable.setHotspot(event.getX(), event.getY());
+ }
+
+ return false;
+ }
+
+ @Override
+ public void setEnabled(boolean enabled) {
+ super.setEnabled(enabled);
+
+ if (enabled) {
+ this.setAlpha(1);
+ } else {
+ this.setAlpha(0.35f);
+ }
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/Select.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/Select.java
new file mode 100644
index 0000000000..9f3f8b9494
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/Select.java
@@ -0,0 +1,143 @@
+package com.skywire.skycoin.vpn.controls;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.drawable.Drawable;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.MotionEvent;
+import android.view.View;
+import android.widget.EditText;
+import android.widget.FrameLayout;
+
+import androidx.core.content.ContextCompat;
+
+import com.google.android.material.textfield.TextInputLayout;
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.controls.options.OptionsItem;
+import com.skywire.skycoin.vpn.controls.options.OptionsModalWindow;
+import com.skywire.skycoin.vpn.helpers.ClickTimeManagement;
+
+import java.util.ArrayList;
+
+public class Select extends FrameLayout implements View.OnTouchListener, View.OnClickListener {
+ public static class SelectOption {
+ public String text;
+ public String value;
+ public Integer iconId;
+ }
+
+ private TextInputLayout container;
+ private EditText edit;
+ private FrameLayout clickArea;
+
+ private ArrayList options;
+ private int selectedIndex = 0;
+ private ClickTimeManagement buttonTimeManager = new ClickTimeManagement();
+
+ public Select(Context context) {
+ super(context);
+ Initialize(context, null);
+ }
+ public Select(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ Initialize(context, attrs);
+ }
+ public Select(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ Initialize(context, attrs);
+ }
+
+ protected void Initialize (Context context, AttributeSet attrs) {
+ LayoutInflater inflater = (LayoutInflater)context.getSystemService (Context.LAYOUT_INFLATER_SERVICE);
+ inflater.inflate(R.layout.view_select, this, true);
+
+ container = this.findViewById (R.id.container);
+ edit = this.findViewById (R.id.edit);
+ clickArea = this.findViewById (R.id.clickArea);
+
+ if (attrs != null) {
+ TypedArray attributes = context.getTheme().obtainStyledAttributes(
+ attrs,
+ R.styleable.Select,
+ 0, 0
+ );
+
+ String hint = attributes.getString(R.styleable.Select_hint);
+ if (hint != null) {
+ this.container.setHint(hint);
+ }
+
+ attributes.recycle();
+ }
+
+ clickArea.setOnTouchListener(this);
+ clickArea.setOnClickListener(this);
+ }
+
+ public void setValues(ArrayList options, int selectedIndex) {
+ this.options = options;
+ this.selectedIndex = selectedIndex;
+
+ updateContent();
+ }
+
+ private void updateContent() {
+ SelectOption currentOption = options.get(selectedIndex);
+
+ Drawable leftDrawable = null;
+ if (currentOption.iconId != null) {
+ leftDrawable = ContextCompat.getDrawable(getContext(), currentOption.iconId);
+ leftDrawable.setBounds(0, 0, leftDrawable.getIntrinsicWidth(), leftDrawable.getIntrinsicHeight());
+ }
+ Drawable[] drawables = edit.getCompoundDrawables();
+ edit.setCompoundDrawables(leftDrawable, drawables[1], drawables[2], drawables[3]);
+
+ if (currentOption.iconId != null) {
+ edit.setText(" " + currentOption.text);
+ } else {
+ edit.setText(currentOption.text);
+ }
+ }
+
+ public String getSelectedValue() {
+ return options.get(selectedIndex).value;
+ }
+
+ @Override
+ public boolean onTouch(View v, MotionEvent event) {
+ if (event.getAction() == MotionEvent.ACTION_DOWN) {
+ setAlpha(0.5f);
+ } else if (event.getAction() == MotionEvent.ACTION_CANCEL || event.getAction() == MotionEvent.ACTION_POINTER_UP || event.getAction() == MotionEvent.ACTION_UP) {
+ setAlpha(1f);
+ }
+
+ return false;
+ }
+
+ @Override
+ public void onClick(View view) {
+ if (!buttonTimeManager.canClick()) {
+ return;
+ }
+
+ buttonTimeManager.informClickMade();
+
+ ArrayList optionsToShow = new ArrayList();
+
+ for (SelectOption option : options) {
+ OptionsItem.SelectableOption optionToShow = new OptionsItem.SelectableOption();
+ optionToShow.drawableId = option.iconId;
+ optionToShow.label = option.text;
+
+ optionsToShow.add(optionToShow);
+ }
+
+ OptionsModalWindow modal = new OptionsModalWindow(getContext(), null, optionsToShow, (int selectedOption) -> {
+ selectedIndex = selectedOption;
+ updateContent();
+ });
+
+ modal.show();
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/ServerInfoModalWindow.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/ServerInfoModalWindow.java
new file mode 100644
index 0000000000..83c81e6fef
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/ServerInfoModalWindow.java
@@ -0,0 +1,276 @@
+package com.skywire.skycoin.vpn.controls;
+
+import android.app.Dialog;
+import android.content.Context;
+import android.os.Bundle;
+import android.text.SpannableStringBuilder;
+import android.text.Spanned;
+import android.text.style.ForegroundColorSpan;
+import android.text.style.RelativeSizeSpan;
+import android.view.View;
+import android.view.Window;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import androidx.core.content.res.ResourcesCompat;
+
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.activities.servers.ServerLists;
+import com.skywire.skycoin.vpn.activities.servers.VpnServerForList;
+import com.skywire.skycoin.vpn.extensible.ClickEvent;
+import com.skywire.skycoin.vpn.helpers.CountriesList;
+import com.skywire.skycoin.vpn.helpers.HelperFunctions;
+import com.skywire.skycoin.vpn.helpers.MaterialFontSpan;
+import com.skywire.skycoin.vpn.objects.ServerFlags;
+import com.skywire.skycoin.vpn.vpn.VPNServersPersistentData;
+
+import java.text.DateFormat;
+import java.text.SimpleDateFormat;
+
+public class ServerInfoModalWindow extends Dialog implements ClickEvent {
+ private ForegroundColorSpan lightColorSpan =
+ new ForegroundColorSpan(ResourcesCompat.getColor(getContext().getResources(), R.color.modal_window_light_text, null));
+ private ForegroundColorSpan superLightColorSpan =
+ new ForegroundColorSpan(ResourcesCompat.getColor(getContext().getResources(), R.color.modal_window_super_light_text, null));
+ private DateFormat dateFormat = new SimpleDateFormat("yyyy/MM/dd hh:mm a");
+
+ private TextView textName;
+ private TextView textCustomName;
+ private TextView textPk;
+ private TextView textNote;
+ private TextView textPersonalNote;
+ private TextView textLastTimeUsed;
+
+ private TextView textCountry;
+ private TextView textCountryCode;
+ private TextView textLocation;
+
+ // TODO: for currently commented fields, must be deleted or reactivated depending on what happens to the fields.
+ /*
+ private LinearLayout connectivityContainer;
+ private TextView textCongestion;
+ private TextView textCongestionRating;
+ private TextView textLatency;
+ private TextView textLatencyRating;
+ private TextView textHops;
+ */
+
+ private LinearLayout specialContainer;
+ private TextView textIsCurrent;
+ private TextView textIsFavorite;
+ private TextView textBlocked;
+ private TextView textInHistory;
+ private TextView textEnteredManually;
+ private TextView textHasPassword;
+
+ private ModalWindowButton buttonClose;
+
+ private VpnServerForList server;
+ private ServerLists listType;
+
+ public ServerInfoModalWindow(Context ctx, VpnServerForList server, ServerLists listType) {
+ super(ctx);
+
+ this.server = server;
+ this.listType = listType;
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ requestWindowFeature(Window.FEATURE_NO_TITLE);
+ setContentView(R.layout.view_server_info_modal);
+
+ textName = findViewById(R.id.textName);
+ textCustomName = findViewById(R.id.textCustomName);
+ textPk = findViewById(R.id.textPk);
+ textNote = findViewById(R.id.textNote);
+ textPersonalNote = findViewById(R.id.textPersonalNote);
+ textLastTimeUsed = findViewById(R.id.textLastTimeUsed);
+
+ textCountry = findViewById(R.id.textCountry);
+ textCountryCode = findViewById(R.id.textCountryCode);
+ textLocation = findViewById(R.id.textLocation);
+
+ // TODO: for currently commented fields, must be deleted or reactivated depending on what happens to the fields.
+ /*
+ connectivityContainer = findViewById(R.id.connectivityContainer);
+ textCongestion = findViewById(R.id.textCongestion);
+ textCongestionRating = findViewById(R.id.textCongestionRating);
+ textLatency = findViewById(R.id.textLatency);
+ textLatencyRating = findViewById(R.id.textLatencyRating);
+ textHops = findViewById(R.id.textHops);
+ */
+
+ specialContainer = findViewById(R.id.specialContainer);
+ textIsCurrent = findViewById(R.id.textIsCurrent);
+ textIsFavorite = findViewById(R.id.textIsFavorite);
+ textBlocked = findViewById(R.id.textBlocked);
+ textInHistory = findViewById(R.id.textInHistory);
+ textEnteredManually = findViewById(R.id.textEnteredManually);
+ textHasPassword = findViewById(R.id.textHasPassword);
+
+ buttonClose = findViewById(R.id.buttonClose);
+
+ putValue(textName, R.string.server_info_name, server.name, null, null);
+ putValue(textCustomName, R.string.server_info_custom_name, server.customName, null, null);
+ putValue(textPk, R.string.server_info_pk, server.pk, null, null);
+ if ((server.note != null && !server.note.trim().equals("")) && (server.personalNote != null && !server.personalNote.trim().equals(""))) {
+ putValue(textNote, R.string.server_info_original_note, server.note, null, null);
+ putValue(textPersonalNote, R.string.server_info_personal_note, server.personalNote, null, null);
+ } else if (server.note != null && !server.note.trim().equals("")) {
+ putValue(textNote, R.string.server_info_note, server.note, null, null);
+ textPersonalNote.setVisibility(View.GONE);
+ } else if (server.personalNote != null && !server.personalNote.trim().equals("")) {
+ putValue(textPersonalNote, R.string.server_info_note, server.personalNote, null, null);
+ textNote.setVisibility(View.GONE);
+ } else {
+ putValue(textNote, R.string.server_info_note, null, null, null);
+ textPersonalNote.setVisibility(View.GONE);
+ }
+ if (server.inHistory) {
+ putValue(textLastTimeUsed, R.string.server_info_last_time_used, dateFormat.format(server.lastUsed), null, null);
+ } else {
+ textLastTimeUsed.setVisibility(View.GONE);
+ }
+
+ putValue(textCountry, R.string.server_info_country, CountriesList.getCountryName(server.countryCode), null, null);
+ if (!server.countryCode.toUpperCase().equals("ZZ")) {
+ putValue(textCountryCode, R.string.server_info_country_code, server.countryCode.toUpperCase(), null, null);
+ } else {
+ textCountryCode.setVisibility(View.GONE);
+ }
+ putValue(textLocation, R.string.server_info_location, server.location, null, null);
+
+ // TODO: for currently commented fields, must be deleted or reactivated depending on what happens to the fields.
+ /*
+ if (listType == ServerLists.Public) {
+ putValue(textCongestion, R.string.server_info_congestion,
+ HelperFunctions.zeroDecimalsFormatter.format(server.congestion) + "%", null, null
+ );
+ putValue(textCongestionRating, R.string.server_info_congestion_rating,
+ getContext().getText(ServerRatings.getTextForRating(server.congestionRating)).toString(), getRatingColor(server.congestionRating), null
+ );
+ putValue(textLatency, R.string.server_info_latency,
+ HelperFunctions.getLatencyValue(server.latency), null, null
+ );
+ putValue(textLatencyRating, R.string.server_info_latency_rating,
+ getContext().getText(ServerRatings.getTextForRating(server.latencyRating)).toString(), getRatingColor(server.latencyRating), null
+ );
+ putValue(textHops, R.string.server_info_hops,
+ server.hops + "", null, null
+ );
+ } else {
+ connectivityContainer.setVisibility(View.GONE);
+ }
+ */
+
+ boolean hasSpecialCondition = false;
+ boolean isTheCurrentServer = VPNServersPersistentData.getInstance().getCurrentServer() != null &&
+ VPNServersPersistentData.getInstance().getCurrentServer().pk.toLowerCase().equals(server.pk.toLowerCase());
+
+ if (isTheCurrentServer) {
+ putValue(textIsCurrent, R.string.server_info_is_current, getBooleanString(true), null, "\ue876");
+ hasSpecialCondition = true;
+ } else {
+ textIsCurrent.setVisibility(View.GONE);
+ }
+ if (server.flag == ServerFlags.Favorite) {
+ ForegroundColorSpan iconColor = new ForegroundColorSpan(ResourcesCompat.getColor(getContext().getResources(),R.color.yellow, null));
+ putValue(textIsFavorite, R.string.server_info_is_favorite, getBooleanString(true), iconColor, "\ue838");
+ hasSpecialCondition = true;
+ } else {
+ textIsFavorite.setVisibility(View.GONE);
+ }
+ if (server.flag == ServerFlags.Blocked) {
+ ForegroundColorSpan iconColor = new ForegroundColorSpan(ResourcesCompat.getColor(getContext().getResources(),R.color.red, null));
+ putValue(textBlocked, R.string.server_info_is_blocked, getBooleanString(true), iconColor, "\ue14c");
+ hasSpecialCondition = true;
+ } else {
+ textBlocked.setVisibility(View.GONE);
+ }
+ if (server.inHistory && !isTheCurrentServer) {
+ putValue(textInHistory, R.string.server_info_is_in_history, getBooleanString(true), null, "\ue889");
+ hasSpecialCondition = true;
+ } else {
+ textInHistory.setVisibility(View.GONE);
+ }
+ if (server.enteredManually) {
+ putValue(textEnteredManually, R.string.server_info_entered_manually, getBooleanString(true), null, null);
+ hasSpecialCondition = true;
+ } else {
+ textEnteredManually.setVisibility(View.GONE);
+ }
+ if (server.enteredManually && server.hasPassword) {
+ putValue(textHasPassword, R.string.server_info_has_password, getBooleanString(true), null, "\ue899");
+ hasSpecialCondition = true;
+ } else {
+ textHasPassword.setVisibility(View.GONE);
+ }
+ if (!hasSpecialCondition) {
+ specialContainer.setVisibility(View.GONE);
+ }
+
+ buttonClose.setClickEventListener(this);
+
+ HelperFunctions.configureModalWindow(this);
+ }
+
+ @Override
+ public void onClick(View view) {
+ dismiss();
+ }
+
+ private void putValue(TextView textView, int titleResurce, String value, ForegroundColorSpan valueColor, String icon) {
+ SpannableStringBuilder finalText = new SpannableStringBuilder(getContext().getString(titleResurce));
+ finalText.setSpan(lightColorSpan, 0, finalText.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+
+ finalText.append("\n");
+ int initialValuePos = finalText.length();
+
+ if (value != null && !value.trim().equals("")) {
+ if (icon == null) {
+ finalText.append(value);
+
+ if (valueColor != null) {
+ finalText.setSpan(valueColor, initialValuePos, finalText.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+ } else {
+ finalText.append(icon + " ");
+ finalText.setSpan(new MaterialFontSpan(getContext()), initialValuePos, finalText.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ finalText.setSpan(new RelativeSizeSpan(0.75f), initialValuePos, finalText.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ if (valueColor != null) {
+ finalText.setSpan(valueColor, initialValuePos, finalText.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+
+ finalText.append(value);
+ }
+ } else {
+ finalText.append(getContext().getString(R.string.server_info_without_value));
+ finalText.setSpan(superLightColorSpan, initialValuePos, finalText.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+
+ textView.setText(finalText);
+ }
+
+ private String getBooleanString(boolean value) {
+ if (value) {
+ return getContext().getText(R.string.general_yes).toString();
+ }
+
+ return getContext().getText(R.string.general_no).toString();
+ }
+
+ // TODO: for currently commented fields, must be deleted or reactivated depending on what happens to the fields.
+ /*
+ private ForegroundColorSpan getRatingColor(ServerRatings rating) {
+ if (rating == ServerRatings.Gold) {
+ return new ForegroundColorSpan(ResourcesCompat.getColor(getContext().getResources(), R.color.gold, null));
+ } else if (rating == ServerRatings.Silver) {
+ return new ForegroundColorSpan(ResourcesCompat.getColor(getContext().getResources(), R.color.silver, null));
+ }
+
+ return new ForegroundColorSpan(ResourcesCompat.getColor(getContext().getResources(), R.color.bronze, null));
+ }
+ */
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/ServerName.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/ServerName.java
new file mode 100644
index 0000000000..eabbcf5992
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/ServerName.java
@@ -0,0 +1,151 @@
+package com.skywire.skycoin.vpn.controls;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.text.SpannableStringBuilder;
+import android.text.Spanned;
+import android.text.style.ForegroundColorSpan;
+import android.text.style.RelativeSizeSpan;
+import android.util.AttributeSet;
+import android.util.TypedValue;
+import android.view.Gravity;
+import android.view.LayoutInflater;
+import android.widget.FrameLayout;
+import android.widget.TextView;
+
+import androidx.core.content.res.ResourcesCompat;
+
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.activities.servers.ServerLists;
+import com.skywire.skycoin.vpn.activities.servers.VpnServerForList;
+import com.skywire.skycoin.vpn.helpers.AlphaSpan;
+import com.skywire.skycoin.vpn.helpers.HelperFunctions;
+import com.skywire.skycoin.vpn.helpers.MaterialFontSpan;
+import com.skywire.skycoin.vpn.objects.ServerFlags;
+import com.skywire.skycoin.vpn.vpn.VPNServersPersistentData;
+
+public class ServerName extends FrameLayout {
+ private TextView text;
+
+ private String defaultName = "";
+ private boolean showConfigIcon = false;
+
+ public ServerName(Context context) {
+ super(context);
+ Initialize(context, null);
+ }
+ public ServerName(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ Initialize(context, attrs);
+ }
+ public ServerName(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ Initialize(context, attrs);
+ }
+
+ private void Initialize(Context context, AttributeSet attrs) {
+ LayoutInflater inflater = (LayoutInflater)context.getSystemService (Context.LAYOUT_INFLATER_SERVICE);
+ inflater.inflate(R.layout.view_server_name, this, true);
+
+ text = this.findViewById (R.id.text);
+
+ if (attrs != null) {
+ TypedArray attributes = context.getTheme().obtainStyledAttributes(
+ attrs,
+ R.styleable.ServerName,
+ 0, 0
+ );
+
+ boolean centerText = attributes.getBoolean(R.styleable.ServerName_center_text, false);
+ if (centerText) {
+ text.setGravity(Gravity.CENTER_HORIZONTAL);
+ }
+
+ String defaultName = attributes.getString(R.styleable.ServerName_default_name);
+ if (defaultName != null) {
+ this.defaultName = defaultName;
+ text.setText(defaultName);
+ }
+
+ float textSize = attributes.getDimensionPixelSize(R.styleable.ServerName_text_size, -1);
+ if (textSize != -1) {
+ text.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize);
+ }
+
+ showConfigIcon = attributes.getBoolean(R.styleable.ServerName_show_config_icon, false);
+
+ attributes.recycle();
+ }
+ }
+
+ public void setServer(VpnServerForList server, ServerLists listType, boolean doNotMarkCurrent) {
+ if (server == null) {
+ text.setText(defaultName);
+
+ return;
+ }
+
+ MaterialFontSpan materialFontSpan = new MaterialFontSpan(getContext());
+ RelativeSizeSpan relativeSizeSpan = new RelativeSizeSpan(0.75f);
+
+ int initialicons = 0;
+ boolean isCurrentServer = VPNServersPersistentData.getInstance().getCurrentServer() != null &&
+ server.pk.toLowerCase().equals(VPNServersPersistentData.getInstance().getCurrentServer().pk.toLowerCase());
+
+ SpannableStringBuilder finalText = new SpannableStringBuilder("");
+
+ if (isCurrentServer && !doNotMarkCurrent) {
+ finalText.append("\ue876 ");
+ initialicons += 1;
+ }
+ if (server.flag == ServerFlags.Blocked && listType != ServerLists.Blocked) {
+ finalText.append("\ue14c ");
+ finalText.setSpan(new ForegroundColorSpan(
+ ResourcesCompat.getColor(getResources(),R.color.red, null)),
+ initialicons * 2,
+ (initialicons * 2) + 2,
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
+ );
+ initialicons += 1;
+ }
+ if (server.flag == ServerFlags.Favorite && listType != ServerLists.Favorites) {
+ finalText.append("\ue838 ");
+ finalText.setSpan(new ForegroundColorSpan(
+ ResourcesCompat.getColor(getResources(),R.color.yellow, null)),
+ initialicons * 2,
+ (initialicons * 2) + 2,
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
+ );
+ initialicons += 1;
+ }
+ if (server.inHistory && listType != ServerLists.History && !isCurrentServer) {
+ finalText.append("\ue889 ");
+ initialicons += 1;
+ }
+ if (server.hasPassword) {
+ finalText.append("\ue899 ");
+ initialicons += 1;
+ }
+
+ if (initialicons != 0) {
+ finalText.setSpan(materialFontSpan, 0, initialicons * 2, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ finalText.setSpan(relativeSizeSpan, 0, initialicons * 2, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+
+ finalText.append(HelperFunctions.getServerName(server, defaultName));
+
+ if (showConfigIcon) {
+ finalText.append(" \ue8b8");
+
+ materialFontSpan = new MaterialFontSpan(getContext());
+ relativeSizeSpan = new RelativeSizeSpan(0.75f);
+ AlphaSpan alphaSpan = new AlphaSpan(128);
+
+ finalText.setSpan(materialFontSpan, finalText.length() - 2, finalText.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ finalText.setSpan(relativeSizeSpan, finalText.length() - 2, finalText.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ finalText.setSpan(alphaSpan, finalText.length() - 2, finalText.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+
+ text.setText(finalText);
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/ServerNotesModalWindow.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/ServerNotesModalWindow.java
new file mode 100644
index 0000000000..92007f9096
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/ServerNotesModalWindow.java
@@ -0,0 +1,69 @@
+package com.skywire.skycoin.vpn.controls;
+
+import android.app.Dialog;
+import android.content.Context;
+import android.os.Bundle;
+import android.view.View;
+import android.view.Window;
+import android.widget.TextView;
+
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.activities.servers.VpnServerForList;
+import com.skywire.skycoin.vpn.extensible.ClickEvent;
+import com.skywire.skycoin.vpn.helpers.HelperFunctions;
+
+public class ServerNotesModalWindow extends Dialog implements ClickEvent {
+ private TextView textNoteTitle;
+ private TextView textNote;
+ private TextView textPersonalNoteTitle;
+ private TextView textPersonalNote;
+
+ private ModalWindowButton buttonClose;
+
+ private VpnServerForList server;
+
+ public ServerNotesModalWindow(Context ctx, VpnServerForList server) {
+ super(ctx);
+
+ this.server = server;
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ requestWindowFeature(Window.FEATURE_NO_TITLE);
+ setContentView(R.layout.view_server_notes_modal);
+
+ textNoteTitle = findViewById(R.id.textNoteTitle);
+ textNote = findViewById(R.id.textNote);
+ textPersonalNoteTitle = findViewById(R.id.textPersonalNoteTitle);
+ textPersonalNote = findViewById(R.id.textPersonalNote);
+ buttonClose = findViewById(R.id.buttonClose);
+
+ if ((server.note != null && !server.note.trim().equals("")) && (server.personalNote != null && !server.personalNote.trim().equals(""))) {
+ textNote.setText(server.note);
+ textPersonalNote.setText(server.personalNote);
+ } else {
+ textNoteTitle.setVisibility(View.GONE);
+ textPersonalNoteTitle.setVisibility(View.GONE);
+ textPersonalNote.setVisibility(View.GONE);
+
+ if (server.note != null && !server.note.trim().equals("")) {
+ textNote.setText(server.note);
+ } else if (server.personalNote != null && !server.personalNote.trim().equals("")) {
+ textNote.setText(server.personalNote);
+ } else {
+ textNote.setVisibility(View.GONE);
+ }
+ }
+
+ buttonClose.setClickEventListener(this);
+
+ HelperFunctions.configureModalWindow(this);
+ }
+
+ @Override
+ public void onClick(View view) {
+ dismiss();
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/ServerPasswordModalWindow.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/ServerPasswordModalWindow.java
new file mode 100644
index 0000000000..b2407652bd
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/ServerPasswordModalWindow.java
@@ -0,0 +1,101 @@
+package com.skywire.skycoin.vpn.controls;
+
+import android.app.Dialog;
+import android.content.Context;
+import android.os.Bundle;
+import android.text.Editable;
+import android.text.TextWatcher;
+import android.view.KeyEvent;
+import android.view.View;
+import android.view.Window;
+import android.view.inputmethod.EditorInfo;
+import android.widget.EditText;
+
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.activities.servers.VpnServerForList;
+import com.skywire.skycoin.vpn.extensible.ClickEvent;
+import com.skywire.skycoin.vpn.helpers.HelperFunctions;
+import com.skywire.skycoin.vpn.objects.LocalServerData;
+import com.skywire.skycoin.vpn.vpn.VPNServersPersistentData;
+
+public class ServerPasswordModalWindow extends Dialog implements ClickEvent, TextWatcher {
+ private EditText editPassword;
+ private ModalWindowButton buttonCancel;
+ private ModalWindowButton buttonConfirm;
+
+ private VpnServerForList server;
+
+ public ServerPasswordModalWindow(Context ctx, VpnServerForList server) {
+ super(ctx);
+
+ this.server = server;
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ requestWindowFeature(Window.FEATURE_NO_TITLE);
+ setContentView(R.layout.view_server_password_modal);
+
+ editPassword = findViewById(R.id.editPassword);
+ buttonCancel = findViewById(R.id.buttonCancel);
+ buttonConfirm = findViewById(R.id.buttonConfirm);
+
+ editPassword.setOnEditorActionListener((v, actionId, event) -> {
+ if (
+ actionId == EditorInfo.IME_ACTION_DONE ||
+ (event != null && event.getAction() == KeyEvent.ACTION_DOWN && event.getKeyCode() == KeyEvent.KEYCODE_ENTER)
+ ) {
+ if (buttonConfirm.isEnabled()) {
+ makeChange();
+ dismiss();
+ }
+
+ return true;
+ }
+
+ return false;
+ });
+
+ editPassword.addTextChangedListener(this);
+
+ buttonCancel.setClickEventListener(this);
+ buttonConfirm.setClickEventListener(this);
+
+ buttonConfirm.setEnabled(false);
+
+ HelperFunctions.configureModalWindow(this);
+ }
+
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
+ @Override
+ public void afterTextChanged(Editable s) { }
+
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+ if (editPassword.getText() == null || editPassword.getText().toString().equals("")) {
+ buttonConfirm.setEnabled(false);
+ } else {
+ buttonConfirm.setEnabled(true);
+ }
+ }
+
+ @Override
+ public void onClick(View view) {
+ if (view.getId() == R.id.buttonConfirm) {
+ makeChange();
+ }
+
+ dismiss();
+ }
+
+ private void makeChange() {
+ LocalServerData localServerData = VPNServersPersistentData.getInstance().processFromList(server);
+
+ localServerData.password = editPassword.getText().toString();
+ VPNServersPersistentData.getInstance().updateServer(localServerData);
+
+ HelperFunctions.showToast(getContext().getString(R.string.server_password_changes_made_confirmation), true);
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/SettingsButton.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/SettingsButton.java
new file mode 100644
index 0000000000..b3a8735400
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/SettingsButton.java
@@ -0,0 +1,63 @@
+package com.skywire.skycoin.vpn.controls;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.MotionEvent;
+import android.view.View;
+import android.widget.TextView;
+
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.extensible.ButtonBase;
+
+public class SettingsButton extends ButtonBase implements View.OnTouchListener {
+ private TextView textIcon;
+
+ public SettingsButton(Context context) {
+ super(context);
+ }
+ public SettingsButton(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+ public SettingsButton(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ }
+
+ @Override
+ protected void Initialize (Context context, AttributeSet attrs) {
+ LayoutInflater inflater = (LayoutInflater)context.getSystemService (Context.LAYOUT_INFLATER_SERVICE);
+ inflater.inflate(R.layout.view_settings_button, this, true);
+
+ textIcon = this.findViewById (R.id.textIcon);
+
+ if (attrs != null) {
+ TypedArray attributes = context.getTheme().obtainStyledAttributes(
+ attrs,
+ R.styleable.SettingsButton,
+ 0, 0
+ );
+
+ boolean useNoteIcon = attributes.getBoolean(R.styleable.SettingsButton_use_note_icon, false);
+ if (useNoteIcon) {
+ textIcon.setText("\ue88f");
+ }
+
+ attributes.recycle();
+ }
+
+ setOnTouchListener(this);
+ setViewForCheckingClicks(this);
+ }
+
+ @Override
+ public boolean onTouch(View v, MotionEvent event) {
+ if (event.getAction() == MotionEvent.ACTION_DOWN) {
+ textIcon.setAlpha(0.5f);
+ } else if (event.getAction() == MotionEvent.ACTION_CANCEL || event.getAction() == MotionEvent.ACTION_POINTER_UP || event.getAction() == MotionEvent.ACTION_UP) {
+ textIcon.setAlpha(1.0f);
+ }
+
+ return false;
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/Tab.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/Tab.java
new file mode 100644
index 0000000000..28dba6efa2
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/Tab.java
@@ -0,0 +1,96 @@
+package com.skywire.skycoin.vpn.controls;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.drawable.RippleDrawable;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.MotionEvent;
+import android.view.View;
+import android.widget.FrameLayout;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.extensible.ButtonBase;
+
+public class Tab extends ButtonBase implements View.OnTouchListener {
+ private LinearLayout mainContainer;
+ private LinearLayout internalContainer;
+ private FrameLayout rightBorder;
+ private TextView textIcon;
+ private TextView textName;
+
+ private RippleDrawable rippleDrawable;
+
+ public Tab(Context context) {
+ super(context);
+ }
+ public Tab(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+ public Tab(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ }
+
+ @Override
+ protected void Initialize (Context context, AttributeSet attrs) {
+ LayoutInflater inflater = (LayoutInflater)context.getSystemService (Context.LAYOUT_INFLATER_SERVICE);
+ inflater.inflate(R.layout.view_tab, this, true);
+
+ mainContainer = this.findViewById (R.id.mainContainer);
+ internalContainer = this.findViewById (R.id.internalContainer);
+ rightBorder = this.findViewById (R.id.rightBorder);
+ textIcon = this.findViewById (R.id.textIcon);
+ textName = this.findViewById (R.id.textName);
+
+ rippleDrawable = (RippleDrawable) internalContainer.getBackground();
+
+ if (attrs != null) {
+ TypedArray attributes = context.getTheme().obtainStyledAttributes(
+ attrs,
+ R.styleable.Tab,
+ 0, 0
+ );
+
+ String iconText = attributes.getString(R.styleable.Tab_icon_text);
+ if (iconText != null) {
+ textIcon.setText(iconText);
+ }
+
+ textName.setText(attributes.getString(R.styleable.Tab_lower_text));
+
+ if (!attributes.getBoolean(R.styleable.Tab_show_right_border, true)) {
+ rightBorder.setVisibility(GONE);
+ }
+
+ attributes.recycle();
+ }
+
+ setOnTouchListener(this);
+ setViewForCheckingClicks(this);
+ }
+
+ public void changeState(boolean selected) {
+ if (selected) {
+ mainContainer.setBackgroundResource(R.color.bar_selected);
+ internalContainer.setBackground(null);
+ rippleDrawable = null;
+ this.setClickable(false);
+ } else {
+ mainContainer.setBackgroundResource(R.color.bar_background);
+ internalContainer.setBackgroundResource(R.drawable.box_ripple);
+ rippleDrawable = (RippleDrawable) internalContainer.getBackground();
+ this.setClickable(true);
+ }
+ }
+
+ @Override
+ public boolean onTouch(View v, MotionEvent event) {
+ if (rippleDrawable != null) {
+ rippleDrawable.setHotspot(event.getX(), event.getY());
+ }
+
+ return false;
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/TabletTopBar.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/TabletTopBar.java
new file mode 100644
index 0000000000..cc8d16befe
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/TabletTopBar.java
@@ -0,0 +1,119 @@
+package com.skywire.skycoin.vpn.controls;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.FrameLayout;
+import android.widget.LinearLayout;
+
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.extensible.ClickEvent;
+import com.skywire.skycoin.vpn.extensible.ClickWithIndexEvent;
+
+import java.io.Closeable;
+
+public class TabletTopBar extends FrameLayout implements ClickEvent, Closeable {
+ public TabletTopBar(Context context) {
+ super(context);
+ Initialize(context, null);
+ }
+ public TabletTopBar(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ Initialize(context, attrs);
+ }
+ public TabletTopBar(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ Initialize(context, attrs);
+ }
+
+ public static int statusTabIndex = 0;
+ public static int serversTabIndex = 1;
+ public static int settingsTabIndex = 2;
+
+ private TabletTopBarTab tabStatus;
+ private TabletTopBarTab tabServers;
+ private TabletTopBarTab tabSettings;
+ private TabletTopBarStats stats;
+
+ private ClickWithIndexEvent clickListener;
+
+ private void Initialize (Context context, AttributeSet attrs) {
+ LayoutInflater inflater = (LayoutInflater)context.getSystemService (Context.LAYOUT_INFLATER_SERVICE);
+ inflater.inflate(R.layout.view_tablet_top_bar, this, true);
+
+ tabStatus = this.findViewById (R.id.tabStatus);
+ tabServers = this.findViewById (R.id.tabServers);
+ tabSettings = this.findViewById (R.id.tabSettings);
+ stats = this.findViewById (R.id.stats);
+
+ stats.setVisibility(INVISIBLE);
+
+ tabStatus.setClickEventListener(this);
+ tabServers.setClickEventListener(this);
+ tabSettings.setClickEventListener(this);
+ }
+
+ public void onResume() {
+ if (stats.getVisibility() == VISIBLE) {
+ stats.onResume();
+ }
+ }
+
+ public void onPause() {
+ if (stats.getVisibility() == VISIBLE) {
+ stats.onPause();
+ }
+ }
+
+ public void setSelectedTab(int tabIndex) {
+ tabStatus.setSelected(false);
+ tabServers.setSelected(false);
+ tabSettings.setSelected(false);
+
+ if (tabIndex == statusTabIndex) {
+ tabStatus.setSelected(true);
+
+ if (stats.getVisibility() == VISIBLE) {
+ stats.setVisibility(INVISIBLE);
+ stats.onPause();
+ }
+ } else if (tabIndex == serversTabIndex) {
+ tabServers.setSelected(true);
+
+ if (stats.getVisibility() != VISIBLE) {
+ stats.setVisibility(VISIBLE);
+ stats.onResume();
+ }
+ } else if (tabIndex == settingsTabIndex) {
+ tabSettings.setSelected(true);
+
+ if (stats.getVisibility() != VISIBLE) {
+ stats.setVisibility(VISIBLE);
+ stats.onResume();
+ }
+ }
+ }
+
+ public void setClickWithIndexEventListener(ClickWithIndexEvent listener) {
+ clickListener = listener;
+ }
+
+ @Override
+ public void onClick(View view) {
+ if (clickListener != null) {
+ if (view.getId() == R.id.tabStatus) {
+ clickListener.onClickWithIndex(statusTabIndex, null);
+ } else if (view.getId() == R.id.tabServers) {
+ clickListener.onClickWithIndex(serversTabIndex, null);
+ } else if (view.getId() == R.id.tabSettings) {
+ clickListener.onClickWithIndex(settingsTabIndex, null);
+ }
+ }
+ }
+
+ @Override
+ public void close() {
+ stats.close();
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/TabletTopBarStats.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/TabletTopBarStats.java
new file mode 100644
index 0000000000..1b4503e73e
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/TabletTopBarStats.java
@@ -0,0 +1,148 @@
+package com.skywire.skycoin.vpn.controls;
+
+import android.animation.Animator;
+import android.animation.AnimatorInflater;
+import android.animation.AnimatorSet;
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.widget.FrameLayout;
+import android.widget.TextView;
+
+import androidx.core.content.ContextCompat;
+
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.helpers.Globals;
+import com.skywire.skycoin.vpn.helpers.HelperFunctions;
+import com.skywire.skycoin.vpn.vpn.VPNCoordinator;
+import com.skywire.skycoin.vpn.vpn.VPNGeneralPersistentData;
+import com.skywire.skycoin.vpn.vpn.VPNStates;
+
+import java.io.Closeable;
+
+import io.reactivex.rxjava3.disposables.Disposable;
+
+public class TabletTopBarStats extends FrameLayout implements Animator.AnimatorListener, Closeable {
+ private TextView textConnectionIconAnim;
+ private TextView textConnectionIcon;
+ private TextView textConnection;
+ private TextView textLatency;
+ private TextView textUploadSpeed;
+ private TextView textDownloadSpeed;
+
+ private VPNStates currentState = VPNStates.OFF;
+ private VPNCoordinator.ConnectionStats currentStats = new VPNCoordinator.ConnectionStats();
+ private Globals.DataUnits dataUnits = VPNGeneralPersistentData.getDataUnits();
+
+ private AnimatorSet animSet;
+
+ private boolean animPaused = false;
+ private boolean closed = false;
+ private Disposable eventsSubscription;
+ private Disposable statsSubscription;
+ private Disposable dataUnitsSubscription;
+
+ public TabletTopBarStats(Context context) {
+ super(context);
+ Initialize(context, null);
+ }
+ public TabletTopBarStats(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ Initialize(context, attrs);
+ }
+ public TabletTopBarStats(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ Initialize(context, attrs);
+ }
+
+ protected void Initialize (Context context, AttributeSet attrs) {
+ LayoutInflater inflater = (LayoutInflater)context.getSystemService (Context.LAYOUT_INFLATER_SERVICE);
+ inflater.inflate(R.layout.view_tablet_top_bar_stats, this, true);
+
+ textConnectionIconAnim = this.findViewById (R.id.textConnectionIconAnim);
+ textConnectionIcon = this.findViewById (R.id.textConnectionIcon);
+ textConnection = this.findViewById (R.id.textConnection);
+ textLatency = this.findViewById (R.id.textLatency);
+ textUploadSpeed = this.findViewById (R.id.textUploadSpeed);
+ textDownloadSpeed = this.findViewById (R.id.textDownloadSpeed);
+
+ animSet = (AnimatorSet) AnimatorInflater.loadAnimator(getContext(), R.animator.anim_state);
+ animSet.setTarget(textConnectionIconAnim);
+ }
+
+ public void onResume() {
+ if (!closed) {
+ animPaused = false;
+ animSet.addListener(this);
+ animSet.start();
+
+ updateData();
+
+ eventsSubscription = VPNCoordinator.getInstance().getEventsObservable().subscribe(response -> {
+ currentState = response.state;
+ updateData();
+ });
+
+ statsSubscription = VPNCoordinator.getInstance().getConnectionStats().subscribe(stats -> {
+ currentStats = stats;
+ updateData();
+ });
+
+ dataUnitsSubscription = VPNGeneralPersistentData.getDataUnitsObservable().subscribe(response -> {
+ dataUnits = response;
+ updateData();
+ });
+ }
+ }
+
+ public void onPause() {
+ animPaused = true;
+ animSet.removeAllListeners();
+ animSet.cancel();
+
+ eventsSubscription.dispose();
+ statsSubscription.dispose();
+ dataUnitsSubscription.dispose();
+ }
+
+ @Override
+ public void onAnimationStart(Animator animation) { }
+ @Override
+ public void onAnimationCancel(Animator animation) { }
+ @Override
+ public void onAnimationRepeat(Animator animation) { }
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ if (!closed && !animPaused) {
+ animSet.start();
+ }
+ }
+
+ private void updateData() {
+ int stateText = VPNStates.getTitleForState(currentState);
+ if (stateText != -1) {
+ textConnection.setText(stateText);
+ } else {
+ textConnection.setText("---");
+ }
+
+ int stateColor = ContextCompat.getColor(getContext(), VPNStates.getColorForStateTitle(stateText));
+ textConnectionIconAnim.setTextColor(stateColor);
+ textConnection.setTextColor(stateColor);
+ textConnectionIcon.setTextColor(stateColor);
+
+ textLatency.setText(HelperFunctions.getLatencyValue(currentStats.currentLatency));
+ textDownloadSpeed.setText(HelperFunctions.computeDataAmountString(currentStats.currentDownloadSpeed, true, dataUnits != Globals.DataUnits.OnlyBytes));
+ textUploadSpeed.setText(HelperFunctions.computeDataAmountString(currentStats.currentUploadSpeed, true, dataUnits != Globals.DataUnits.OnlyBytes));
+ }
+
+ @Override
+ public void close() {
+ closed = true;
+
+ if (eventsSubscription != null) {
+ eventsSubscription.dispose();
+ statsSubscription.dispose();
+ }
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/TabletTopBarTab.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/TabletTopBarTab.java
new file mode 100644
index 0000000000..19ef475e1b
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/TabletTopBarTab.java
@@ -0,0 +1,94 @@
+package com.skywire.skycoin.vpn.controls;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.drawable.RippleDrawable;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.MotionEvent;
+import android.view.View;
+import android.widget.FrameLayout;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.extensible.ButtonBase;
+
+public class TabletTopBarTab extends ButtonBase implements View.OnTouchListener {
+ private FrameLayout mainContainer;
+ private LinearLayout internalContainer;
+ private TextView textIcon;
+ private TextView textLabel;
+
+ private RippleDrawable rippleDrawable;
+
+ public TabletTopBarTab(Context context) {
+ super(context);
+ }
+ public TabletTopBarTab(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+ public TabletTopBarTab(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ }
+
+ @Override
+ protected void Initialize (Context context, AttributeSet attrs) {
+ LayoutInflater inflater = (LayoutInflater)context.getSystemService (Context.LAYOUT_INFLATER_SERVICE);
+ inflater.inflate(R.layout.view_tablet_top_bar_tab, this, true);
+
+ mainContainer = this.findViewById (R.id.mainContainer);
+ internalContainer = this.findViewById (R.id.internalContainer);
+ textIcon = this.findViewById (R.id.textIcon);
+ textLabel = this.findViewById (R.id.textLabel);
+
+ mainContainer.setClipToOutline(true);
+
+ if (attrs != null) {
+ TypedArray attributes = context.getTheme().obtainStyledAttributes(
+ attrs,
+ R.styleable.TabletTopBarTab,
+ 0, 0
+ );
+
+ String iconText = attributes.getString(R.styleable.TabletTopBarTab_icon_text);
+ if (iconText != null) {
+ textIcon.setText(iconText);
+ }
+
+ textLabel.setText(attributes.getString(R.styleable.TabletTopBarTab_label));
+
+ attributes.recycle();
+ }
+
+ setOnTouchListener(this);
+ setViewForCheckingClicks(this);
+
+ setSelected(false);
+ }
+
+ public void setSelected(boolean selected) {
+ if (selected) {
+ textIcon.setAlpha(1f);
+ textLabel.setAlpha(1f);
+ internalContainer.setBackgroundResource(R.drawable.current_server_rounded_box);
+ rippleDrawable = null;
+ setClickable(false);
+ } else {
+ textIcon.setAlpha(0.5f);
+ textLabel.setAlpha(0.5f);
+ internalContainer.setBackgroundResource(R.drawable.current_server_ripple);
+ rippleDrawable = (RippleDrawable) internalContainer.getBackground();
+ setClickable(true);
+ }
+ }
+
+ @Override
+ public boolean onTouch(View v, MotionEvent event) {
+ if (rippleDrawable != null) {
+ rippleDrawable.setHotspot(event.getX(), event.getY());
+ }
+
+ return false;
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/TopBar.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/TopBar.java
new file mode 100644
index 0000000000..4938b3e218
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/TopBar.java
@@ -0,0 +1,94 @@
+package com.skywire.skycoin.vpn.controls;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.extensible.ClickEvent;
+import com.skywire.skycoin.vpn.extensible.ClickWithIndexEvent;
+import com.skywire.skycoin.vpn.helpers.UiMaterialIcons;
+
+public class TopBar extends LinearLayout implements ClickEvent {
+ public TopBar(Context context) {
+ super(context);
+ Initialize(context, null);
+ }
+ public TopBar(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ Initialize(context, attrs);
+ }
+ public TopBar(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ Initialize(context, attrs);
+ }
+
+ private TopBarButton buttonLeft;
+ private ImageView imageIcon;
+ private TextView textTitle;
+
+ private ClickWithIndexEvent clickListener;
+ private boolean goBack = false;
+
+ private void Initialize (Context context, AttributeSet attrs) {
+ LayoutInflater inflater = (LayoutInflater)context.getSystemService (Context.LAYOUT_INFLATER_SERVICE);
+ inflater.inflate(R.layout.view_top_bar, this, true);
+
+ buttonLeft = this.findViewById (R.id.buttonLeft);
+ imageIcon = this.findViewById (R.id.imageIcon);
+ textTitle = this.findViewById (R.id.textTitle);
+
+ buttonLeft.setClickEventListener(this);
+
+ if (attrs != null) {
+ TypedArray attributes = context.getTheme().obtainStyledAttributes(
+ attrs,
+ R.styleable.TopBar,
+ 0, 0);
+
+ String title = attributes.getString(R.styleable.TopBar_title);
+ if (title == null || title.trim() == "") {
+ textTitle.setVisibility(GONE);
+ } else {
+ imageIcon.setVisibility(GONE);
+ textTitle.setText(title);
+ }
+
+ int leftButtonIcon = attributes.getInteger(R.styleable.TopBar_left_button_icon, -1);
+ if (leftButtonIcon == 0) {
+ buttonLeft.setIcon(UiMaterialIcons.MENU);
+ } else if (leftButtonIcon == 1) {
+ buttonLeft.setIcon(UiMaterialIcons.BACK);
+ goBack = true;
+ } else {
+ buttonLeft.setVisibility(GONE);
+ }
+
+ attributes.recycle();
+ } else {
+ textTitle.setVisibility(GONE);
+ buttonLeft.setVisibility(GONE);
+ }
+ }
+
+ public void setClickWithIndexEventListener(ClickWithIndexEvent listener) {
+ clickListener = listener;
+ }
+
+ @Override
+ public void onClick(View view) {
+ if (clickListener != null) {
+ clickListener.onClickWithIndex(0, null);
+ }
+
+ if (goBack) {
+ ((Activity)getContext()).finish();
+ }
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/TopBarButton.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/TopBarButton.java
new file mode 100644
index 0000000000..2b1af828b7
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/TopBarButton.java
@@ -0,0 +1,60 @@
+package com.skywire.skycoin.vpn.controls;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.widget.TextView;
+
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.extensible.ButtonBase;
+import com.skywire.skycoin.vpn.helpers.UiMaterialIcons;
+
+public class TopBarButton extends ButtonBase {
+ public TopBarButton(Context context) {
+ super(context);
+ }
+ public TopBarButton(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+ public TopBarButton(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ }
+
+ private TextView textIcon;
+
+ @Override
+ protected void Initialize (Context context, AttributeSet attrs) {
+ LayoutInflater inflater = (LayoutInflater)context.getSystemService (Context.LAYOUT_INFLATER_SERVICE);
+ inflater.inflate(R.layout.view_top_bar_button, this, true);
+
+ textIcon = this.findViewById (R.id.textIcon);
+
+ if (attrs != null) {
+ TypedArray attributes = context.getTheme().obtainStyledAttributes(
+ attrs,
+ R.styleable.TopBarButton,
+ 0, 0);
+
+ if (attributes.getInteger(R.styleable.TopBarButton_material_icon, 0) == 0) {
+ textIcon.setText("\ue5d2");
+ } else {
+ textIcon.setText("\ue5c4");
+ }
+
+ attributes.recycle();
+ } else {
+ textIcon.setText("\ue5d2");
+ }
+
+ setViewForCheckingClicks(this);
+ }
+
+ public void setIcon(UiMaterialIcons icon) {
+ if (icon == UiMaterialIcons.MENU) {
+ textIcon.setText("\ue5d2");
+ } else {
+ textIcon.setText("\ue5c4");
+ }
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/TopTab.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/TopTab.java
new file mode 100644
index 0000000000..03369d5cec
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/TopTab.java
@@ -0,0 +1,22 @@
+package com.skywire.skycoin.vpn.controls;
+
+import android.content.Context;
+import android.view.LayoutInflater;
+import android.widget.FrameLayout;
+import android.widget.TextView;
+
+import com.skywire.skycoin.vpn.R;
+
+public class TopTab extends FrameLayout {
+ private TextView text;
+
+ public TopTab(Context context, int textResource) {
+ super(context);
+
+ LayoutInflater inflater = (LayoutInflater)context.getSystemService (Context.LAYOUT_INFLATER_SERVICE);
+ inflater.inflate(R.layout.view_top_tab, this, true);
+
+ text = this.findViewById (R.id.text);
+ text.setText(textResource);
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/options/OptionsItem.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/options/OptionsItem.java
new file mode 100644
index 0000000000..ff8903cecf
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/options/OptionsItem.java
@@ -0,0 +1,116 @@
+package com.skywire.skycoin.vpn.controls.options;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.drawable.RippleDrawable;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.MotionEvent;
+import android.view.View;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.extensible.ListButtonBase;
+
+public class OptionsItem extends ListButtonBase implements View.OnTouchListener {
+ public static class SelectableOption {
+ public String icon;
+ public Integer drawableId;
+ public String label;
+ public int translatableLabelId = -1;
+ public boolean disabled = false;
+ }
+
+ private LinearLayout mainContainer;
+ private ImageView imageBitmap;
+ private TextView textIcon;
+ private TextView text;
+
+ private RippleDrawable rippleDrawable;
+
+ public OptionsItem(Context context) {
+ super(context);
+ }
+ public OptionsItem(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+ public OptionsItem(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ }
+
+ @Override
+ protected void Initialize (Context context, AttributeSet attrs) {
+ LayoutInflater inflater = (LayoutInflater)context.getSystemService (Context.LAYOUT_INFLATER_SERVICE);
+ inflater.inflate(R.layout.view_options_item, this, true);
+
+ mainContainer = this.findViewById (R.id.mainContainer);
+ imageBitmap = this.findViewById (R.id.imageBitmap);
+ textIcon = this.findViewById (R.id.textIcon);
+ text = this.findViewById (R.id.text);
+
+ rippleDrawable = (RippleDrawable) mainContainer.getBackground();
+
+ setOnTouchListener(this);
+
+ if (attrs != null) {
+ TypedArray attributes = context.getTheme().obtainStyledAttributes(
+ attrs,
+ R.styleable.OptionsItem,
+ 0, 0
+ );
+
+ String iconText = attributes.getString(R.styleable.OptionsItem_icon_text);
+ if (iconText != null) {
+ textIcon.setText(iconText);
+ }
+
+ text.setText(attributes.getString(R.styleable.OptionsItem_text));
+
+ attributes.recycle();
+ }
+
+ setViewForCheckingClicks(this);
+ }
+
+ public void setParams(SelectableOption params) {
+ if (params.icon != null) {
+ textIcon.setText(params.icon);
+ textIcon.setVisibility(VISIBLE);
+ imageBitmap.setVisibility(GONE);
+ } else {
+ textIcon.setVisibility(GONE);
+
+ if (params.drawableId != null) {
+ imageBitmap.setImageResource(params.drawableId);
+ imageBitmap.setVisibility(VISIBLE);
+ } else {
+ imageBitmap.setVisibility(GONE);
+ }
+ }
+
+ if (params.translatableLabelId != -1) {
+ text.setText(params.translatableLabelId);
+ } else if (params.label != null) {
+ text.setText(params.label);
+ }
+
+ if (params.disabled) {
+ this.setAlpha(0.5f);
+ this.setClickable(false);
+ } else {
+ this.setAlpha(1f);
+ this.setClickable(true);
+ }
+ }
+
+ @Override
+ public boolean onTouch(View v, MotionEvent event) {
+ if (rippleDrawable != null) {
+ rippleDrawable.setHotspot(event.getX(), event.getY());
+ }
+
+ return false;
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/options/OptionsModalWindow.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/options/OptionsModalWindow.java
new file mode 100644
index 0000000000..0f7075fba6
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/options/OptionsModalWindow.java
@@ -0,0 +1,69 @@
+package com.skywire.skycoin.vpn.controls.options;
+
+import android.app.Dialog;
+import android.content.Context;
+import android.os.Bundle;
+import android.view.Window;
+import android.widget.LinearLayout;
+
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.controls.ModalBase;
+import com.skywire.skycoin.vpn.extensible.ClickWithIndexEvent;
+import com.skywire.skycoin.vpn.helpers.HelperFunctions;
+
+import java.util.ArrayList;
+
+public class OptionsModalWindow extends Dialog implements ClickWithIndexEvent {
+ public interface OptionSelected {
+ void optionSelected(int selectedIndex);
+ }
+
+ private String title;
+ private ModalBase modalBase;
+ private LinearLayout container;
+
+ private ArrayList options;
+ private OptionSelected event;
+
+ public OptionsModalWindow(Context ctx, String title, ArrayList options, OptionSelected event) {
+ super(ctx);
+
+ this.title = title;
+ this.options = options;
+ this.event = event;
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ requestWindowFeature(Window.FEATURE_NO_TITLE);
+ setContentView(R.layout.view_options);
+
+ modalBase = findViewById(R.id.modalBase);
+ container = findViewById(R.id.container);
+
+ if (title != null) {
+ modalBase.setTitleString(title);
+ }
+
+ int i = 0;
+ for (OptionsItem.SelectableOption option : options) {
+ OptionsItem view = new OptionsItem(getContext());
+ view.setParams(option);
+ view.setIndex(i++);
+ view.setClickWithIndexEventListener(this);
+ container.addView(view);
+ }
+
+ HelperFunctions.configureModalWindow(this);
+ }
+
+ @Override
+ public void onClickWithIndex(int index, Void data) {
+ if (event != null) {
+ event.optionSelected(index);
+ }
+
+ dismiss();
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/extensible/ButtonBase.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/extensible/ButtonBase.java
new file mode 100644
index 0000000000..03c82af9db
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/extensible/ButtonBase.java
@@ -0,0 +1,71 @@
+package com.skywire.skycoin.vpn.extensible;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.View;
+import android.widget.RelativeLayout;
+
+import com.skywire.skycoin.vpn.controls.BoxRowLayout;
+import com.skywire.skycoin.vpn.helpers.ClickTimeManagement;
+import com.skywire.skycoin.vpn.helpers.Globals;
+
+import java.util.concurrent.TimeUnit;
+
+import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
+import io.reactivex.rxjava3.core.Observable;
+import io.reactivex.rxjava3.schedulers.Schedulers;
+
+public abstract class ButtonBase extends RelativeLayout implements View.OnClickListener {
+ public ButtonBase(Context context) {
+ super(context);
+ Initialize(context, null);
+ }
+ public ButtonBase(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ Initialize(context, attrs);
+ }
+ public ButtonBase(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ Initialize(context, attrs);
+ }
+
+ private ClickEvent clickListener;
+ private ClickTimeManagement buttonTimeManager = new ClickTimeManagement();
+
+ abstract protected void Initialize (Context context, AttributeSet attrs);
+
+ protected void setViewForCheckingClicks(View v) {
+ v.setOnClickListener(this);
+ }
+
+ protected void setClickableBoxView(BoxRowLayout v) {
+ v.setClickEventListener(view -> {
+ if (clickListener != null) {
+ clickListener.onClick(this);
+ }
+ });
+ }
+
+ public void setUseBigFastClickPrevention(boolean useBigFastClickPrevention) {
+ if (useBigFastClickPrevention) {
+ buttonTimeManager.setDelay(ClickTimeManagement.normalFastClickPreventionDelay);
+ } else {
+ buttonTimeManager.setDelay(Globals.CLICK_DELAY_MS);
+ }
+ }
+
+ public void setClickEventListener(ClickEvent listener) {
+ clickListener = listener;
+ }
+
+ @Override
+ public void onClick(View view) {
+ if (clickListener != null && buttonTimeManager.canClick()) {
+ buttonTimeManager.informClickMade();
+ Observable.just(1).delay(Globals.CLICK_DELAY_MS, TimeUnit.MILLISECONDS)
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(v -> clickListener.onClick(this));
+ }
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/extensible/ClickEvent.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/extensible/ClickEvent.java
new file mode 100644
index 0000000000..2de332ff98
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/extensible/ClickEvent.java
@@ -0,0 +1,7 @@
+package com.skywire.skycoin.vpn.extensible;
+
+import android.view.View;
+
+public interface ClickEvent {
+ void onClick(View view);
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/extensible/ClickWithIndexEvent.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/extensible/ClickWithIndexEvent.java
new file mode 100644
index 0000000000..bbac776754
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/extensible/ClickWithIndexEvent.java
@@ -0,0 +1,5 @@
+package com.skywire.skycoin.vpn.extensible;
+
+public interface ClickWithIndexEvent {
+ void onClickWithIndex(int index, T data);
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/extensible/ListButtonBase.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/extensible/ListButtonBase.java
new file mode 100644
index 0000000000..2bcfac3593
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/extensible/ListButtonBase.java
@@ -0,0 +1,81 @@
+package com.skywire.skycoin.vpn.extensible;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.View;
+import android.widget.RelativeLayout;
+
+import com.skywire.skycoin.vpn.controls.BoxRowLayout;
+import com.skywire.skycoin.vpn.helpers.ClickTimeManagement;
+import com.skywire.skycoin.vpn.helpers.Globals;
+
+import java.util.concurrent.TimeUnit;
+
+import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
+import io.reactivex.rxjava3.core.Observable;
+import io.reactivex.rxjava3.schedulers.Schedulers;
+
+public abstract class ListButtonBase extends RelativeLayout implements View.OnClickListener {
+ public ListButtonBase(Context context) {
+ super(context);
+ Initialize(context, null);
+ }
+ public ListButtonBase(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ Initialize(context, attrs);
+ }
+ public ListButtonBase(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ Initialize(context, attrs);
+ }
+
+ protected DataType dataForEvent;
+ private int index;
+ private ClickWithIndexEvent clickListener;
+ private ClickTimeManagement buttonTimeManager = new ClickTimeManagement();
+
+ abstract protected void Initialize (Context context, AttributeSet attrs);
+
+ protected void setViewForCheckingClicks(View v) {
+ v.setOnClickListener(this);
+ }
+
+ protected void setClickableBoxView(BoxRowLayout v) {
+ v.setClickEventListener(view -> {
+ if (clickListener != null) {
+ clickListener.onClickWithIndex(index, dataForEvent);
+ }
+ });
+ }
+
+ public void setUseBigFastClickPrevention(boolean useBigFastClickPrevention) {
+ if (useBigFastClickPrevention) {
+ buttonTimeManager.setDelay(ClickTimeManagement.normalFastClickPreventionDelay);
+ } else {
+ buttonTimeManager.setDelay(Globals.CLICK_DELAY_MS);
+ }
+ }
+
+ public void setIndex(int index) {
+ this.index = index;
+ }
+
+ public int getIndex() {
+ return index;
+ }
+
+ public void setClickWithIndexEventListener(ClickWithIndexEvent listener) {
+ clickListener = listener;
+ }
+
+ @Override
+ public void onClick(View view) {
+ if (clickListener != null && buttonTimeManager.canClick()) {
+ buttonTimeManager.informClickMade();
+ Observable.just(1).delay(Globals.CLICK_DELAY_MS, TimeUnit.MILLISECONDS)
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(v -> clickListener.onClickWithIndex(index, dataForEvent));
+ }
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/extensible/ListViewHolder.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/extensible/ListViewHolder.java
new file mode 100644
index 0000000000..c3fe237e0a
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/extensible/ListViewHolder.java
@@ -0,0 +1,11 @@
+package com.skywire.skycoin.vpn.extensible;
+
+import android.view.View;
+
+import androidx.recyclerview.widget.RecyclerView;
+
+public class ListViewHolder extends RecyclerView.ViewHolder {
+ public ListViewHolder(T v) {
+ super(v);
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/helpers/AlphaSpan.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/helpers/AlphaSpan.java
new file mode 100644
index 0000000000..9839df1fa6
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/helpers/AlphaSpan.java
@@ -0,0 +1,24 @@
+package com.skywire.skycoin.vpn.helpers;
+
+import android.text.TextPaint;
+import android.text.style.TypefaceSpan;
+
+public class AlphaSpan extends TypefaceSpan {
+ private int alpha;
+
+ public AlphaSpan(int alpha) {
+ super("");
+
+ this.alpha = alpha;
+ }
+
+ @Override
+ public void updateDrawState(TextPaint paint) {
+ paint.setAlpha(alpha);
+ }
+
+ @Override
+ public void updateMeasureState(TextPaint paint) {
+ paint.setAlpha(alpha);
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/helpers/BoxRowTypes.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/helpers/BoxRowTypes.java
new file mode 100644
index 0000000000..b4b9339fad
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/helpers/BoxRowTypes.java
@@ -0,0 +1,8 @@
+package com.skywire.skycoin.vpn.helpers;
+
+public enum BoxRowTypes {
+ TOP,
+ MIDDLE,
+ BOTTOM,
+ SINGLE,
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/helpers/ClickTimeManagement.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/helpers/ClickTimeManagement.java
new file mode 100644
index 0000000000..a8ffd434ec
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/helpers/ClickTimeManagement.java
@@ -0,0 +1,40 @@
+package com.skywire.skycoin.vpn.helpers;
+
+import java.util.concurrent.TimeUnit;
+
+import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
+import io.reactivex.rxjava3.core.Observable;
+import io.reactivex.rxjava3.disposables.Disposable;
+import io.reactivex.rxjava3.schedulers.Schedulers;
+
+public class ClickTimeManagement {
+ public static final int normalFastClickPreventionDelay = 700;
+
+ private Disposable timeSubscription;
+ private int delay = normalFastClickPreventionDelay;
+
+ public void setDelay(int delay) {
+ this.delay = delay;
+ }
+
+ public void informClickMade() {
+ removeDelay();
+
+ timeSubscription = Observable.just(1).delay(delay, TimeUnit.MILLISECONDS)
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(v -> timeSubscription = null);
+ }
+
+ public boolean canClick() {
+ return timeSubscription == null;
+ }
+
+ public void removeDelay() {
+ if (timeSubscription != null) {
+ timeSubscription.dispose();
+ }
+
+ timeSubscription = null;
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/helpers/CountriesList.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/helpers/CountriesList.java
new file mode 100644
index 0000000000..285b53ad4e
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/helpers/CountriesList.java
@@ -0,0 +1,267 @@
+package com.skywire.skycoin.vpn.helpers;
+
+import com.skywire.skycoin.vpn.App;
+import com.skywire.skycoin.vpn.R;
+
+import java.util.HashMap;
+
+public class CountriesList {
+ private static HashMap countries = new HashMap() {{
+ put("AF", "Afghanistan");
+ put("AX", "Aland Islands");
+ put("AL", "Albania");
+ put("DZ", "Algeria");
+ put("AS", "American Samoa");
+ put("AD", "Andorra");
+ put("AO", "Angola");
+ put("AI", "Anguilla");
+ put("AQ", "Antarctica");
+ put("AG", "Antigua and Barbuda");
+ put("AR", "Argentina");
+ put("AM", "Armenia");
+ put("AW", "Aruba");
+ put("AU", "Australia");
+ put("AT", "Austria");
+ put("AZ", "Azerbaijan");
+ put("BS", "Bahamas");
+ put("BH", "Bahrain");
+ put("BD", "Bangladesh");
+ put("BB", "Barbados");
+ put("BY", "Belarus");
+ put("BE", "Belgium");
+ put("BZ", "Belize");
+ put("BJ", "Benin");
+ put("BM", "Bermuda");
+ put("BT", "Bhutan");
+ put("BO", "Bolivia");
+ put("BA", "Bosnia and Herzegovina");
+ put("BW", "Botswana");
+ put("BV", "Bouvet Island");
+ put("BR", "Brazil");
+ put("IO", "British Indian Ocean Territory");
+ put("BN", "Brunei Darussalam");
+ put("BG", "Bulgaria");
+ put("BF", "Burkina Faso");
+ put("BI", "Burundi");
+ put("KH", "Cambodia");
+ put("CM", "Cameroon");
+ put("CA", "Canada");
+ put("CV", "Cape Verde");
+ put("KY", "Cayman Islands");
+ put("CF", "Central African Republic");
+ put("TD", "Chad");
+ put("CL", "Chile");
+ put("CN", "China");
+ put("CX", "Christmas Island");
+ put("CC", "Cocos (Keeling) Islands");
+ put("CO", "Colombia");
+ put("KM", "Comoros");
+ put("CG", "Congo");
+ put("CD", "Congo, Democratic Republic");
+ put("CK", "Cook Islands");
+ put("CR", "Costa Rica");
+ put("CI", "Cote D'Ivoire");
+ put("HR", "Croatia");
+ put("CU", "Cuba");
+ put("CY", "Cyprus");
+ put("CZ", "Czech Republic");
+ put("DK", "Denmark");
+ put("DJ", "Djibouti");
+ put("DM", "Dominica");
+ put("DO", "Dominican Republic");
+ put("EC", "Ecuador");
+ put("EG", "Egypt");
+ put("SV", "El Salvador");
+ put("GQ", "Equatorial Guinea");
+ put("ER", "Eritrea");
+ put("EE", "Estonia");
+ put("ET", "Ethiopia");
+ put("FK", "Falkland Islands (Malvinas)");
+ put("FO", "Faroe Islands");
+ put("FJ", "Fiji");
+ put("FI", "Finland");
+ put("FR", "France");
+ put("GF", "French Guiana");
+ put("PF", "French Polynesia");
+ put("TF", "French Southern Territories");
+ put("GA", "Gabon");
+ put("GM", "Gambia");
+ put("GE", "Georgia");
+ put("DE", "Germany");
+ put("GH", "Ghana");
+ put("GI", "Gibraltar");
+ put("GR", "Greece");
+ put("GL", "Greenland");
+ put("GD", "Grenada");
+ put("GP", "Guadeloupe");
+ put("GU", "Guam");
+ put("GT", "Guatemala");
+ put("GG", "Guernsey");
+ put("GN", "Guinea");
+ put("GW", "Guinea-Bissau");
+ put("GY", "Guyana");
+ put("HT", "Haiti");
+ put("HM", "Heard Island and Mcdonald Islands");
+ put("VA", "Holy See (Vatican City State)");
+ put("HN", "Honduras");
+ put("HK", "Hong Kong");
+ put("HU", "Hungary");
+ put("IS", "Iceland");
+ put("IN", "India");
+ put("ID", "Indonesia");
+ put("IR", "Iran");
+ put("IQ", "Iraq");
+ put("IE", "Ireland");
+ put("IM", "Isle of Man");
+ put("IL", "Israel");
+ put("IT", "Italy");
+ put("JM", "Jamaica");
+ put("JP", "Japan");
+ put("JE", "Jersey");
+ put("JO", "Jordan");
+ put("KZ", "Kazakhstan");
+ put("KE", "Kenya");
+ put("KI", "Kiribati");
+ put("KP", "Korea (North)");
+ put("KR", "Korea (South)");
+ put("XK", "Kosovo");
+ put("KW", "Kuwait");
+ put("KG", "Kyrgyzstan");
+ put("LA", "Laos");
+ put("LV", "Latvia");
+ put("LB", "Lebanon");
+ put("LS", "Lesotho");
+ put("LR", "Liberia");
+ put("LY", "Libyan Arab Jamahiriya");
+ put("LI", "Liechtenstein");
+ put("LT", "Lithuania");
+ put("LU", "Luxembourg");
+ put("MO", "Macao");
+ put("MK", "Macedonia");
+ put("MG", "Madagascar");
+ put("MW", "Malawi");
+ put("MY", "Malaysia");
+ put("MV", "Maldives");
+ put("ML", "Mali");
+ put("MT", "Malta");
+ put("MH", "Marshall Islands");
+ put("MQ", "Martinique");
+ put("MR", "Mauritania");
+ put("MU", "Mauritius");
+ put("YT", "Mayotte");
+ put("MX", "Mexico");
+ put("FM", "Micronesia");
+ put("MD", "Moldova");
+ put("MC", "Monaco");
+ put("MN", "Mongolia");
+ put("MS", "Montserrat");
+ put("MA", "Morocco");
+ put("MZ", "Mozambique");
+ put("MM", "Myanmar");
+ put("NA", "Namibia");
+ put("NR", "Nauru");
+ put("NP", "Nepal");
+ put("NL", "Netherlands");
+ put("AN", "Netherlands Antilles");
+ put("NC", "New Caledonia");
+ put("NZ", "New Zealand");
+ put("NI", "Nicaragua");
+ put("NE", "Niger");
+ put("NG", "Nigeria");
+ put("NU", "Niue");
+ put("NF", "Norfolk Island");
+ put("MP", "Northern Mariana Islands");
+ put("NO", "Norway");
+ put("OM", "Oman");
+ put("PK", "Pakistan");
+ put("PW", "Palau");
+ put("PS", "Palestinian Territory, Occupied");
+ put("PA", "Panama");
+ put("PG", "Papua New Guinea");
+ put("PY", "Paraguay");
+ put("PE", "Peru");
+ put("PH", "Philippines");
+ put("PN", "Pitcairn");
+ put("PL", "Poland");
+ put("PT", "Portugal");
+ put("PR", "Puerto Rico");
+ put("QA", "Qatar");
+ put("RE", "Reunion");
+ put("RO", "Romania");
+ put("RU", "Russian Federation");
+ put("RW", "Rwanda");
+ put("SH", "Saint Helena");
+ put("KN", "Saint Kitts and Nevis");
+ put("LC", "Saint Lucia");
+ put("PM", "Saint Pierre and Miquelon");
+ put("VC", "Saint Vincent and the Grenadines");
+ put("WS", "Samoa");
+ put("SM", "San Marino");
+ put("ST", "Sao Tome and Principe");
+ put("SA", "Saudi Arabia");
+ put("SN", "Senegal");
+ put("RS", "Serbia");
+ put("ME", "Montenegro");
+ put("SC", "Seychelles");
+ put("SL", "Sierra Leone");
+ put("SG", "Singapore");
+ put("SK", "Slovakia");
+ put("SI", "Slovenia");
+ put("SB", "Solomon Islands");
+ put("SO", "Somalia");
+ put("ZA", "South Africa");
+ put("GS", "South Georgia and the South Sandwich Islands");
+ put("ES", "Spain");
+ put("LK", "Sri Lanka");
+ put("SD", "Sudan");
+ put("SR", "Suriname");
+ put("SJ", "Svalbard and Jan Mayen");
+ put("SZ", "Swaziland");
+ put("SE", "Sweden");
+ put("CH", "Switzerland");
+ put("SY", "Syrian Arab Republic");
+ put("TW", "Taiwan, Province of China");
+ put("TJ", "Tajikistan");
+ put("TZ", "Tanzania");
+ put("TH", "Thailand");
+ put("TL", "Timor-Leste");
+ put("TG", "Togo");
+ put("TK", "Tokelau");
+ put("TO", "Tonga");
+ put("TT", "Trinidad and Tobago");
+ put("TN", "Tunisia");
+ put("TR", "Turkey");
+ put("TM", "Turkmenistan");
+ put("TC", "Turks and Caicos Islands");
+ put("TV", "Tuvalu");
+ put("UG", "Uganda");
+ put("UA", "Ukraine");
+ put("AE", "United Arab Emirates");
+ put("GB", "United Kingdom");
+ put("US", "United States");
+ put("UM", "United States Minor Outlying Islands");
+ put("UY", "Uruguay");
+ put("UZ", "Uzbekistan");
+ put("VU", "Vanuatu");
+ put("VE", "Venezuela");
+ put("VN", "Viet Nam");
+ put("VG", "Virgin Islands, British");
+ put("VI", "Virgin Islands, U.S.");
+ put("WF", "Wallis and Futuna");
+ put("EH", "Western Sahara");
+ put("YE", "Yemen");
+ put("ZM", "Zambia");
+ put("ZW", "Zimbabwe");
+ put("ZZ", "Unknown");
+ }};
+
+ public static String getCountryName(String cuntryCode) {
+ cuntryCode = cuntryCode.toUpperCase();
+
+ if (!cuntryCode.equals("ZZ") && countries.containsKey(cuntryCode)) {
+ return countries.get(cuntryCode);
+ }
+
+ return App.getContext().getText(R.string.general_unknown).toString();
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/helpers/Globals.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/helpers/Globals.java
new file mode 100644
index 0000000000..d1942445a6
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/helpers/Globals.java
@@ -0,0 +1,70 @@
+package com.skywire.skycoin.vpn.helpers;
+
+import androidx.annotation.NonNull;
+
+/**
+ * Constant values used in various parts of the app.
+ */
+public class Globals {
+ /**
+ * Time to wait before sending a click event after the user clicks a button. This is for
+ * allowing the UI to show the click effect.
+ */
+ public static final int CLICK_DELAY_MS = 150;
+ /**
+ * Address of the local Skywire node.
+ */
+ public static final String LOCAL_VISOR_ADDRESS = "localhost";
+ /**
+ * Port of the local Skywire node.
+ */
+ public static final int LOCAL_VISOR_PORT = 7890;
+
+ /**
+ * Addresses used for checking if the device has internet connectivity. Any number of
+ * addresses, but at least 1, can be used. Addresses will be checked sequentially and only
+ * until being able to connect with one.
+ */
+ public static final String[] INTERNET_CHECKING_ADDRESSES = new String[]{"https://dmsg.discovery.skywire.skycoin.com", "https://www.skycoin.com"};
+
+ /**
+ * Options for how to show the VPN data transmission stats.
+ */
+ public enum DataUnits {
+ BitsSpeedAndBytesVolume,
+ OnlyBytes,
+ OnlyBits,
+ }
+
+ /**
+ * List with all the possible app selection modes. Each option has an associated string value.
+ */
+ public enum AppFilteringModes {
+ /**
+ * All apps must be protected by the VPN service, no matter which apps have been selected
+ * by the user.
+ */
+ PROTECT_ALL("PROTECT_ALL"),
+ /**
+ * Only the apps selected by the user must be protected by the VPN service.
+ */
+ PROTECT_SELECTED("PROTECT_SELECTED"),
+ /**
+ * Apps selected by the user must NOT be protected by the VPN service. All other apps
+ * must be protected.
+ */
+ IGNORE_SELECTED("IGNORE_SELECTED");
+
+ private final String val;
+
+ AppFilteringModes(final String val) {
+ this.val = val;
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ return val;
+ }
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/helpers/HelperFunctions.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/helpers/HelperFunctions.java
new file mode 100644
index 0000000000..21e3bcb783
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/helpers/HelperFunctions.java
@@ -0,0 +1,662 @@
+package com.skywire.skycoin.vpn.helpers;
+
+import android.app.Activity;
+import android.app.Dialog;
+import android.app.PendingIntent;
+import android.content.ClipData;
+import android.content.ClipboardManager;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.ResolveInfo;
+import android.content.res.Resources;
+import android.graphics.Color;
+import android.graphics.drawable.ColorDrawable;
+import android.os.Handler;
+import android.os.Looper;
+import android.util.TypedValue;
+import android.view.Window;
+import android.view.WindowManager;
+import android.widget.Toast;
+
+import androidx.core.content.ContextCompat;
+
+import com.skywire.skycoin.vpn.App;
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.activities.main.MainActivity;
+import com.skywire.skycoin.vpn.activities.servers.ServerLists;
+import com.skywire.skycoin.vpn.activities.servers.VpnServerForList;
+import com.skywire.skycoin.vpn.controls.ConfirmationModalWindow;
+import com.skywire.skycoin.vpn.controls.EditServerValueModalWindow;
+import com.skywire.skycoin.vpn.controls.ServerInfoModalWindow;
+import com.skywire.skycoin.vpn.controls.ServerPasswordModalWindow;
+import com.skywire.skycoin.vpn.controls.options.OptionsItem;
+import com.skywire.skycoin.vpn.controls.options.OptionsModalWindow;
+import com.skywire.skycoin.vpn.network.ApiClient;
+import com.skywire.skycoin.vpn.objects.LocalServerData;
+import com.skywire.skycoin.vpn.objects.ServerFlags;
+import com.skywire.skycoin.vpn.vpn.VPNCoordinator;
+import com.skywire.skycoin.vpn.vpn.VPNGeneralPersistentData;
+import com.skywire.skycoin.vpn.vpn.VPNServersPersistentData;
+
+import java.text.DecimalFormat;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+
+import io.reactivex.rxjava3.core.Observable;
+import skywiremob.Skywiremob;
+
+/**
+ * General helper functions for different parts of the app.
+ */
+public class HelperFunctions {
+ public enum WidthTypes {
+ SMALL,
+ BIG,
+ BIGGER,
+ }
+
+ // Helpers for showing only a max number of decimals.
+ public static final DecimalFormat twoDecimalsFormatter = new DecimalFormat("#.##");
+ public static final DecimalFormat oneDecimalsFormatter = new DecimalFormat("#.#");
+ public static final DecimalFormat zeroDecimalsFormatter = new DecimalFormat("#");
+
+ // Last toast notification shown.
+ private static Toast lastToast;
+
+ /**
+ * Displays debug information about an error in the console. It includes the several details.
+ * @param prefix Text to show before the error details.
+ * @param e Error.
+ */
+ public static void logError(String prefix, Throwable e) {
+ // Print the basic error msgs.
+ StringBuilder errorMsg = new StringBuilder(prefix + ": " + e.getMessage() + "\n");
+ errorMsg.append(e.toString()).append("\n");
+
+ // Print the stack.
+ StackTraceElement[] stackTrace = e.getStackTrace();
+ for (StackTraceElement stackTraceElement : stackTrace) {
+ errorMsg.append(stackTraceElement.toString()).append("\n");
+ }
+
+ // Display in the console.
+ Skywiremob.printString(errorMsg.toString());
+ }
+
+ /**
+ * Displays an error msg in the console.
+ * @param prefix Text to show before the error msg.
+ * @param errorText Error msg.
+ */
+ public static void logError(String prefix, String errorText) {
+ String errorMsg = prefix + ": " + errorText;
+ Skywiremob.printString(errorMsg);
+ }
+
+ /**
+ * Shows a toast notification. Can be used from background threads.
+ * @param text Text for the notification.
+ * @param shortDuration If the duration of the notification must be short (true) or
+ * long (false).
+ */
+ public static void showToast(String text, boolean shortDuration) {
+ // Run in the UI thread.
+ Handler handler = new Handler(Looper.getMainLooper());
+ handler.post(() -> {
+ // Close the previous notification.
+ if (lastToast != null) {
+ lastToast.cancel();
+ }
+
+ // Show the notification.
+ lastToast = Toast.makeText(App.getContext(), text, shortDuration ? Toast.LENGTH_SHORT : Toast.LENGTH_LONG);
+ lastToast.show();
+ });
+ }
+
+ /**
+ * Gets the list of the app launchers installed in the device. More than one entry may share
+ * the same package name. The current app is ignored.
+ */
+ public static List getDeviceAppsList() {
+ Intent mainIntent = new Intent(Intent.ACTION_MAIN, null);
+ mainIntent.addCategory(Intent.CATEGORY_LAUNCHER);
+
+ String packageName = App.getContext().getPackageName();
+ ArrayList response = new ArrayList<>();
+
+ // Get all the entries in the device which coincide with the intent.
+ for (ResolveInfo app : App.getContext().getPackageManager().queryIntentActivities( mainIntent, 0)) {
+ if (!app.activityInfo.packageName.equals(packageName)) {
+ response.add(app);
+ }
+ }
+
+ return response;
+ }
+
+ /**
+ * Filters a list of package names and returns only the ones which are from launchers
+ * currently installed in the device. The current app is ignored.
+ * @param apps List to filter.
+ * @return Filtered list.
+ */
+ public static HashSet filterAvailableApps(HashSet apps) {
+ HashSet availableApps = new HashSet<>();
+ for (ResolveInfo app : getDeviceAppsList()) {
+ availableApps.add(app.activityInfo.packageName);
+ }
+
+ HashSet response = new HashSet<>();
+ for (String app : apps) {
+ if (availableApps.contains(app)) {
+ response.add(app);
+ }
+ }
+
+ return response;
+ }
+
+ /**
+ * Closes the provided activity if the VPN service is running. If the activity is closed,
+ * a toast is shown.
+ * @param activity Activity to close.
+ * @return True if the activity was closed, false if not.
+ */
+ public static boolean closeActivityIfServiceRunning(Activity activity) {
+ if (VPNCoordinator.getInstance().isServiceRunning()) {
+ HelperFunctions.showToast(App.getContext().getString(R.string.vpn_already_running_warning), true);
+ activity.finish();
+
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Checks if there is connection via internet to at least one of the testing URLs set in the
+ * globals class.
+ * @param logError If true and there is an error checking the connection, the error will
+ * be logged.
+ * @return Observable which emits if there is connection or not.
+ */
+ public static Observable checkInternetConnectivity(boolean logError) {
+ return checkInternetConnectivity(0, logError);
+ }
+
+ /**
+ * Internal function for checking if there is internet connectivity, recursively.
+ * @param urlIndex Index of the testing URL to check.
+ * @param logError If the error, if any, must be logged at the end of the operation.
+ */
+ private static Observable checkInternetConnectivity(int urlIndex, boolean logError) {
+ return ApiClient.checkConnection(Globals.INTERNET_CHECKING_ADDRESSES[urlIndex])
+ // If there is a valid response, return true.
+ .map(response -> true)
+ .onErrorResumeNext(err -> {
+ // If there is an error and there are more testing URLs, continue to the next step.
+ if (urlIndex < Globals.INTERNET_CHECKING_ADDRESSES.length - 1) {
+ return checkInternetConnectivity(urlIndex + 1, logError);
+ }
+
+ if (logError) {
+ HelperFunctions.logError("Checking network connectivity", err);
+ }
+
+ return Observable.just(false);
+ });
+ }
+
+ /**
+ * Returns an intent for opening the app.
+ */
+ public static PendingIntent getOpenAppPendingIntent() {
+ final Intent openAppIntent = new Intent(App.getContext(), MainActivity.class);
+ openAppIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ openAppIntent.setAction(Intent.ACTION_MAIN);
+ openAppIntent.addCategory(Intent.CATEGORY_LAUNCHER);
+
+ return PendingIntent.getActivity(App.getContext(), 0, openAppIntent, PendingIntent.FLAG_UPDATE_CURRENT);
+ }
+
+ /**
+ * Allows to convert a bytes value to KB, MB, GB, etc. It considers 1024, and not 1000, a K.
+ * @param bytes Amount of data to process, in bytes.
+ * @param calculatePerSecond If true, the result will have "/s" added at the end.
+ * @param useBits If the data must be shown in bits (true) or bytes (false).
+ */
+ public static String computeDataAmountString(double bytes, boolean calculatePerSecond, boolean useBits) {
+ double current = (double)bytes;
+
+ // Set the correct units.
+ String[] scales;
+ if (calculatePerSecond) {
+ if (useBits) {
+ scales = new String[]{" b/s", " Kb/s", " Mb/s", " Gb/s", " Tb/s", "Pb/s", "Eb/s", "Zb/s", "Yb/s"};
+ } else {
+ scales = new String[]{" B/s", " KB/s", " MB/s", " GB/s", " TB/s", "PB/s", "EB/s", "ZB/s", "YB/s"};
+ }
+ } else {
+ if (useBits) {
+ scales = new String[]{" b", " Kb", " Mb", " Gb", " Tb", "Pb", "Eb", "Zb", "Yb"};
+ } else {
+ scales = new String[]{" B", " KB", " MB", " GB", " TB", "PB", "EB", "ZB", "YB"};
+ }
+ }
+
+ // Convert to bits, if needed.
+ if (useBits) {
+ current *= 8;
+ }
+
+ // Divide the speed by 1024 until getting an appropriate scale to return.
+ for (int i = 0; i < scales.length - 1; i++) {
+ if (current < 1024) {
+ // Return decimals depending on how long the number is.
+ if (current < 10) {
+ return twoDecimalsFormatter.format(current) + scales[i];
+ } else if (current < 100) {
+ return oneDecimalsFormatter.format(current) + scales[i];
+ }
+
+ return zeroDecimalsFormatter.format(current) + scales[i];
+ }
+
+ current /= 1024;
+ }
+
+ return current + scales[scales.length - 1];
+ }
+
+ public static String getLatencyValue(double latency) {
+ String initialPart;
+ String lastPart;
+
+ if (latency >= 1000) {
+ initialPart = oneDecimalsFormatter.format(latency / 1000);
+ lastPart = App.getContext().getString(R.string.general_seconds_abbreviation);
+ } else {
+ initialPart = oneDecimalsFormatter.format(latency);
+ lastPart = App.getContext().getString(R.string.general_milliseconds_abbreviation);
+ }
+
+ return initialPart + lastPart;
+ }
+
+ public static int getFlagResourceId(String countryCode) {
+ if (countryCode.toLowerCase() != "do") {
+ int flagResourceId = App.getContext().getResources().getIdentifier(
+ countryCode.toLowerCase(),
+ "drawable",
+ App.getContext().getPackageName()
+ );
+
+ if (flagResourceId != 0) {
+ return flagResourceId;
+ } else {
+ return R.drawable.zz;
+ }
+ } else {
+ return R.drawable.do_flag;
+ }
+ }
+
+ // TODO: for currently commented fields, must be deleted or reactivated depending on what happens to the fields.
+ /*
+ public static int getCongestionNumberColor(int congestion) {
+ if (congestion < 60) {
+ return ContextCompat.getColor(App.getContext(), R.color.green);
+ } else if (congestion < 90) {
+ return ContextCompat.getColor(App.getContext(), R.color.yellow);
+ }
+
+ return ContextCompat.getColor(App.getContext(), R.color.red);
+ }
+
+ public static int getLatencyNumberColor(int latency) {
+ if (latency < 200) {
+ return ContextCompat.getColor(App.getContext(), R.color.green);
+ } else if (latency < 350) {
+ return ContextCompat.getColor(App.getContext(), R.color.yellow);
+ }
+
+ return ContextCompat.getColor(App.getContext(), R.color.red);
+ }
+
+ public static int getHopsNumberColor(int hops) {
+ if (hops < 5) {
+ return ContextCompat.getColor(App.getContext(), R.color.green);
+ } else if (hops < 9) {
+ return ContextCompat.getColor(App.getContext(), R.color.yellow);
+ }
+
+ return ContextCompat.getColor(App.getContext(), R.color.red);
+ }
+ */
+ public static void configureModalWindow(Dialog modal) {
+ Window window = modal.getWindow();
+ window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));
+ window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE);
+
+ WidthTypes screenWidthType = getWidthType(modal.getContext());
+ if (screenWidthType != WidthTypes.SMALL) {
+ int width = (int)TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP,
+ 500,
+ modal.getContext().getResources().getDisplayMetrics()
+ );
+
+ WindowManager.LayoutParams params = window.getAttributes();
+ params.width = width;
+ params.height = WindowManager.LayoutParams.WRAP_CONTENT;
+ window.setAttributes(params);
+ }
+ }
+
+ public static boolean showBackgroundForVerticalScreen() {
+ double proportion = (double)Resources.getSystem().getDisplayMetrics().widthPixels / (double)Resources.getSystem().getDisplayMetrics().heightPixels;
+ if (proportion > 1.1) {
+ return false;
+ }
+
+ return true;
+ }
+
+ public static WidthTypes getWidthType(Context ctx) {
+ int screenWidthInDP = (int)(Resources.getSystem().getDisplayMetrics().widthPixels / ctx.getResources().getDisplayMetrics().density);
+
+ if (screenWidthInDP >= 1100) {
+ return WidthTypes.BIGGER;
+ } else if (screenWidthInDP >= 800) {
+ return WidthTypes.BIG;
+ }
+
+ return WidthTypes.SMALL;
+ }
+
+ public static int getTabletExtraHorizontalPadding(Context ctx) {
+ WidthTypes widthType = getWidthType(ctx);
+
+ if (widthType == WidthTypes.BIGGER) {
+ return (int) TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP,
+ 100,
+ ctx.getResources().getDisplayMetrics()
+ );
+ } else if (widthType == WidthTypes.BIG) {
+ return (int)TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP,
+ 40,
+ ctx.getResources().getDisplayMetrics()
+ );
+ }
+
+ return 0;
+ }
+
+ public static boolean prepareAndStartVpn(Activity requestingActivity, LocalServerData server) {
+ if (server.flag == ServerFlags.Blocked) {
+ HelperFunctions.showToast(requestingActivity.getString(R.string.general_starting_blocked_server_error) + server.pk, false);
+
+ return false;
+ }
+
+ long err = Skywiremob.isPKValid(server.pk).getCode();
+ if (err != Skywiremob.ErrCodeNoError) {
+ HelperFunctions.showToast(requestingActivity.getString(R.string.vpn_coordinator_invalid_credentials_error) + server.pk, false);
+ return false;
+ } else {
+ Skywiremob.printString("PK is correct");
+ }
+
+ Globals.AppFilteringModes selectedMode = VPNGeneralPersistentData.getAppsSelectionMode();
+ if (selectedMode != Globals.AppFilteringModes.PROTECT_ALL) {
+ HashSet selectedApps = HelperFunctions.filterAvailableApps(VPNGeneralPersistentData.getAppList(new HashSet<>()));
+
+ if (selectedApps.size() == 0) {
+ if (selectedMode == Globals.AppFilteringModes.PROTECT_SELECTED) {
+ HelperFunctions.showToast(requestingActivity.getString(R.string.vpn_no_apps_to_protect_warning), false);
+ } else {
+ HelperFunctions.showToast(requestingActivity.getString(R.string.vpn_no_apps_to_ignore_warning), false);
+ }
+ }
+ }
+
+ VPNCoordinator.getInstance().startVPN(
+ requestingActivity,
+ server
+ );
+
+ return true;
+ }
+
+ public static String getServerName(VpnServerForList server, String defaultName) {
+ if ((server.name == null || server.name.trim().equals("")) && (server.customName == null || server.customName.trim().equals(""))) {
+ return defaultName;
+ } else if (server.name != null && !server.name.trim().equals("") && (server.customName == null || server.customName.trim().equals(""))) {
+ return server.name;
+ } else if (server.customName != null && !server.customName.trim().equals("") && (server.name == null || server.name.trim().equals(""))) {
+ return server.customName;
+ }
+
+ return server.customName + " - " + server.name;
+ }
+
+ public static String getServerNote(LocalServerData server) {
+ String note = "";
+ if (server.note != null && !server.note.trim().equals("")) {
+ note = server.note;
+ }
+ if (server.personalNote != null && !server.personalNote.trim().equals("")) {
+ if (note.length() > 0) {
+ note += " - ";
+ }
+ note += server.personalNote;
+ }
+
+ return note.length() > 0 ? note : null;
+ }
+
+ public static void showServerOptions(Context ctx, VpnServerForList server, ServerLists listType) {
+ ArrayList options = new ArrayList();
+ ArrayList optionCodes = new ArrayList();
+
+ OptionsItem.SelectableOption option = new OptionsItem.SelectableOption();
+ option.icon = "\ue88e";
+ option.translatableLabelId = R.string.tmp_server_options_view_info;
+ options.add(option);
+ optionCodes.add(10);
+ option = new OptionsItem.SelectableOption();
+ option.icon = "\ue14d";
+ option.translatableLabelId = R.string.tmp_server_options_copy_pk;
+ options.add(option);
+ optionCodes.add(11);
+ option = new OptionsItem.SelectableOption();
+ option.icon = "\ue3c9";
+ option.translatableLabelId = R.string.tmp_server_options_name;
+ options.add(option);
+ optionCodes.add(101);
+ option = new OptionsItem.SelectableOption();
+ option.icon = "\ue8d2";
+ option.translatableLabelId = R.string.tmp_server_options_note;
+ options.add(option);
+ optionCodes.add(102);
+
+ if (server.hasPassword) {
+ option = new OptionsItem.SelectableOption();
+ option.icon = "\ue898";
+ option.translatableLabelId = R.string.tmp_server_options_remove_password;
+ options.add(option);
+ optionCodes.add(201);
+
+ option = new OptionsItem.SelectableOption();
+ option.icon = "\ue899";
+ option.translatableLabelId = R.string.tmp_server_options_change_password;
+ options.add(option);
+ optionCodes.add(202);
+ } else {
+ if (server.enteredManually) {
+ option = new OptionsItem.SelectableOption();
+ option.icon = "\ue899";
+ option.translatableLabelId = R.string.tmp_server_options_add_password;
+ options.add(option);
+ optionCodes.add(202);
+ }
+ }
+
+ if (server.flag != ServerFlags.Favorite) {
+ option = new OptionsItem.SelectableOption();
+ option.icon = "\ue838";
+ option.translatableLabelId = R.string.tmp_server_options_make_favorite;
+ options.add(option);
+ optionCodes.add(1);
+ }
+
+ if (server.flag == ServerFlags.Favorite) {
+ option = new OptionsItem.SelectableOption();
+ option.icon = "\ue83a";
+ option.translatableLabelId = R.string.tmp_server_options_remove_from_favorites;
+ options.add(option);
+ optionCodes.add(-1);
+ }
+
+ if (server.flag != ServerFlags.Blocked) {
+ option = new OptionsItem.SelectableOption();
+ option.icon = "\ue925";
+ option.translatableLabelId = R.string.tmp_server_options_block;
+ options.add(option);
+ optionCodes.add(2);
+ }
+
+ if (server.flag == ServerFlags.Blocked) {
+ option = new OptionsItem.SelectableOption();
+ option.icon = "\ue8dc";
+ option.translatableLabelId = R.string.tmp_server_options_unblock;
+ options.add(option);
+ optionCodes.add(-2);
+ }
+
+ if (server.inHistory) {
+ option = new OptionsItem.SelectableOption();
+ option.icon = "\ue872";
+ option.translatableLabelId = R.string.tmp_server_options_remove_from_history;
+ options.add(option);
+ optionCodes.add(-3);
+ }
+
+ OptionsModalWindow modal = new OptionsModalWindow(ctx, null, options, (int selectedOption) -> {
+ LocalServerData savedVersion_ = VPNServersPersistentData.getInstance().getSavedVersion(server.pk);
+ if (savedVersion_ == null) {
+ savedVersion_ = VPNServersPersistentData.getInstance().processFromList(server);
+ }
+
+ final LocalServerData savedVersion = savedVersion_;
+
+ if (optionCodes.get(selectedOption) > 200) {
+ if (VPNCoordinator.getInstance().isServiceRunning() && VPNServersPersistentData.getInstance().getCurrentServer().pk.equals(savedVersion.pk)) {
+ HelperFunctions.showToast(App.getContext().getText(R.string.general_server_running_error).toString(), true);
+ return;
+ }
+
+ if (optionCodes.get(selectedOption) == 201) {
+ ConfirmationModalWindow confirmationModal = new ConfirmationModalWindow(
+ ctx,
+ R.string.tmp_server_options_remove_password_confirmation,
+ R.string.tmp_confirmation_yes,
+ R.string.tmp_confirmation_no,
+ () -> {
+ VPNServersPersistentData.getInstance().removePassword(savedVersion.pk);
+ HelperFunctions.showToast(ctx.getString(R.string.tmp_server_options_remove_password_done), true);
+ }
+ );
+ confirmationModal.show();
+ } else {
+ ServerPasswordModalWindow passwordModal = new ServerPasswordModalWindow(
+ ctx,
+ server
+ );
+ passwordModal.show();
+ }
+ } else if (optionCodes.get(selectedOption) > 100) {
+ EditServerValueModalWindow valueModal = new EditServerValueModalWindow(
+ ctx,
+ optionCodes.get(selectedOption) == 101,
+ server
+ );
+ valueModal.show();
+ } else if (optionCodes.get(selectedOption) == 10) {
+ ServerInfoModalWindow infoModal = new ServerInfoModalWindow(ctx, server, listType);
+ infoModal.show();
+ } else if (optionCodes.get(selectedOption) == 11) {
+ ClipboardManager clipboard = (ClipboardManager)ctx.getSystemService(Context.CLIPBOARD_SERVICE);
+ ClipData clipData = ClipData.newPlainText("", server.pk);
+ clipboard.setPrimaryClip(clipData);
+ HelperFunctions.showToast(ctx.getString(R.string.general_copied), true);
+ } else if (optionCodes.get(selectedOption) == 1) {
+ if (server.flag != ServerFlags.Blocked) {
+ VPNServersPersistentData.getInstance().changeFlag(savedVersion, ServerFlags.Favorite);
+ HelperFunctions.showToast(ctx.getString(R.string.tmp_server_options_make_favorite_done), true);
+ return;
+ }
+
+ ConfirmationModalWindow confirmationModal = new ConfirmationModalWindow(
+ ctx,
+ R.string.tmp_server_options_make_favorite_from_blocked_confirmation,
+ R.string.tmp_confirmation_yes,
+ R.string.tmp_confirmation_no,
+ () -> {
+ VPNServersPersistentData.getInstance().changeFlag(savedVersion, ServerFlags.Favorite);
+ HelperFunctions.showToast(ctx.getString(R.string.tmp_server_options_make_favorite_done), true);
+ }
+ );
+ confirmationModal.show();
+ } else if (optionCodes.get(selectedOption) == -1) {
+ VPNServersPersistentData.getInstance().changeFlag(savedVersion, ServerFlags.None);
+ HelperFunctions.showToast(ctx.getString(R.string.tmp_server_options_remove_from_favorites_done), true);
+ } else if (optionCodes.get(selectedOption) == 2) {
+ if (VPNServersPersistentData.getInstance().getCurrentServer() != null &&
+ VPNServersPersistentData.getInstance().getCurrentServer().pk.toLowerCase().equals(server.pk.toLowerCase())
+ ) {
+ HelperFunctions.showToast(ctx.getString(R.string.tmp_server_options_block_error), true);
+ return;
+ }
+
+ if (server.flag != ServerFlags.Favorite) {
+ VPNServersPersistentData.getInstance().changeFlag(savedVersion, ServerFlags.Blocked);
+ HelperFunctions.showToast(ctx.getString(R.string.tmp_server_options_block_done), true);
+ return;
+ }
+
+ ConfirmationModalWindow confirmationModal = new ConfirmationModalWindow(
+ ctx,
+ R.string.tmp_server_options_block_favorite_confirmation,
+ R.string.tmp_confirmation_yes,
+ R.string.tmp_confirmation_no,
+ () -> {
+ VPNServersPersistentData.getInstance().changeFlag(savedVersion, ServerFlags.Blocked);
+ HelperFunctions.showToast(ctx.getString(R.string.tmp_server_options_block_done), true);
+ }
+ );
+ confirmationModal.show();
+ } else if (optionCodes.get(selectedOption) == -2) {
+ VPNServersPersistentData.getInstance().changeFlag(savedVersion, ServerFlags.None);
+ HelperFunctions.showToast(ctx.getString(R.string.tmp_server_options_unblock_done), true);
+ } else if (optionCodes.get(selectedOption) == -3) {
+ ConfirmationModalWindow confirmationModal = new ConfirmationModalWindow(
+ ctx,
+ R.string.tmp_server_options_remove_from_history_confirmation,
+ R.string.tmp_confirmation_yes,
+ R.string.tmp_confirmation_no,
+ () -> {
+ VPNServersPersistentData.getInstance().removeFromHistory(savedVersion.pk);
+ HelperFunctions.showToast(ctx.getString(R.string.tmp_server_options_remove_from_history_done), true);
+ }
+ );
+ confirmationModal.show();
+ }
+ });
+ modal.show();
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/helpers/MaterialFontSpan.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/helpers/MaterialFontSpan.java
new file mode 100644
index 0000000000..f4d5f6ce16
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/helpers/MaterialFontSpan.java
@@ -0,0 +1,32 @@
+package com.skywire.skycoin.vpn.helpers;
+
+import android.content.Context;
+import android.graphics.Typeface;
+import android.text.TextPaint;
+import android.text.style.TypefaceSpan;
+
+import androidx.core.content.res.ResourcesCompat;
+
+import com.skywire.skycoin.vpn.R;
+
+public class MaterialFontSpan extends TypefaceSpan {
+ private static Typeface materialFont;
+
+ public MaterialFontSpan(Context context) {
+ super("");
+
+ if (materialFont == null) {
+ materialFont = ResourcesCompat.getFont(context, R.font.material_font);
+ }
+ }
+
+ @Override
+ public void updateDrawState(TextPaint paint) {
+ paint.setTypeface(materialFont);
+ }
+
+ @Override
+ public void updateMeasureState(TextPaint paint) {
+ paint.setTypeface(materialFont);
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/helpers/Notifications.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/helpers/Notifications.java
new file mode 100644
index 0000000000..a0e7501dc3
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/helpers/Notifications.java
@@ -0,0 +1,162 @@
+package com.skywire.skycoin.vpn.helpers;
+
+import android.app.Notification;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.Context;
+
+import androidx.core.app.NotificationCompat;
+
+import com.skywire.skycoin.vpn.App;
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.vpn.VPNGeneralPersistentData;
+import com.skywire.skycoin.vpn.vpn.VPNStates;
+
+import io.reactivex.rxjava3.disposables.Disposable;
+import skywiremob.Skywiremob;
+
+/**
+ * Constant values and helper functions for showing notifications.
+ */
+public class Notifications {
+ /**
+ * ID of the notification channel for showing the VPN service status.
+ */
+ public static final String NOTIFICATION_CHANNEL_ID = "SkywireVPN";
+ /**
+ * ID of the notification channel for showing alerts and errors.
+ */
+ public static final String ALERT_NOTIFICATION_CHANNEL_ID = "SkywireVPNAlerts";
+
+ /**
+ * ID of the VPN service status notification.
+ */
+ public static final int SERVICE_STATUS_NOTIFICATION_ID = 1;
+ /**
+ * ID of the notification for informing about errors while trying to automatically start the
+ * VPN service during boot.
+ */
+ public static final int AUTOSTART_ALERT_NOTIFICATION_ID = 10;
+ /**
+ * ID of the generic error notifications.
+ */
+ public static final int ERROR_NOTIFICATION_ID = 50;
+
+ /**
+ * Units used for showing the data transmission stats.
+ */
+ private static Globals.DataUnits dataUnits = VPNGeneralPersistentData.getDataUnits();
+ /**
+ * Subscription for updating the data transmission stats.
+ */
+ private static Disposable dataUnitsSubscription;
+
+ /**
+ * Closes all the alert and error notifications created by the app. Only notifications with
+ * the IDs defined in this class will be closed.
+ */
+ public static void removeAllAlertNotifications() {
+ NotificationManager notificationManager = (NotificationManager) App.getContext().getSystemService(Context.NOTIFICATION_SERVICE);
+
+ notificationManager.cancel(AUTOSTART_ALERT_NOTIFICATION_ID);
+ notificationManager.cancel(ERROR_NOTIFICATION_ID);
+ }
+
+ /**
+ * Creates and shows an alert notification.
+ * @param ID Notification ID. Please use one of the IDs defined in this class.
+ * @param title Notification title.
+ * @param content Main notification text.
+ * @param contentIntent Intent for when the user presses the notification.
+ */
+ public static void showAlertNotification(int ID, String title, String content, PendingIntent contentIntent) {
+ // Create the style for a multiline notification. It will be ignore if the OS does not
+ // support it.
+ NotificationCompat.BigTextStyle bigTextStyle = new NotificationCompat.BigTextStyle()
+ .setBigContentTitle(title)
+ .bigText(content);
+
+ // Create the notification.
+ Notification notification = new NotificationCompat.Builder(App.getContext(), ALERT_NOTIFICATION_CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_error)
+ .setContentTitle(title)
+ .setContentText(content)
+ .setStyle(bigTextStyle)
+ .setContentIntent(contentIntent)
+ .build();
+
+ // Show it.
+ NotificationManager notificationManager = (NotificationManager)App.getContext().getSystemService(Context.NOTIFICATION_SERVICE);
+ notificationManager.notify(ID, notification);
+ }
+
+ /**
+ * Creates a notification for displaying the current state of the VPN service. The notification
+ * is returned, not displayed.
+ * @param currentState Current state of the VPN service.
+ * @param protectionEnabled If the network protection has already been activated.
+ * @return The created notification.
+ */
+ public static Notification createStatusNotification(VPNStates currentState, boolean protectionEnabled) {
+ // Start updating the data transmission stats, if needed.
+ if (dataUnitsSubscription == null) {
+ dataUnitsSubscription = VPNGeneralPersistentData.getDataUnitsObservable().subscribe(response -> {
+ dataUnits = response;
+ });
+ }
+
+ // The title is always "preparing", unless the state indicates the service is connected,
+ // disconnecting or restoring. For the state numeric values, check the emun documentation.
+ int title = R.string.vpn_service_state_preparing;
+ if (currentState == VPNStates.CONNECTED) {
+ title = VPNStates.getTitleForState(currentState);
+ } else {
+ if (currentState.val() >= VPNStates.DISCONNECTING.val()) {
+ title = R.string.vpn_service_state_finishing;
+ } else if (currentState.val() >= VPNStates.RESTORING_VPN.val() && currentState.val() < VPNStates.DISCONNECTING.val()) {
+ title = R.string.vpn_service_state_restoring;
+ }
+ }
+
+ // Main text for the notification.
+ String text = App.getContext().getString(VPNStates.getDescriptionForState(currentState));
+ // If connected, the connection stats are shown as the main text.
+ if (currentState == VPNStates.CONNECTED) {
+ text = "\u2191" + HelperFunctions.computeDataAmountString(Skywiremob.vpnBandwidthSent(), true, dataUnits != Globals.DataUnits.OnlyBytes);
+ text += " \u2193" + HelperFunctions.computeDataAmountString(Skywiremob.vpnBandwidthReceived(), true, dataUnits != Globals.DataUnits.OnlyBytes);
+ text += " \u2194" + HelperFunctions.getLatencyValue(Skywiremob.vpnLatency());
+ }
+
+ // The lines icon indicates that the service is disconnected and the network protection is
+ // not active. The filed icon indicates that the service is connected and working. The
+ // alert icon indicates that the network protection is active, but the VPN service is still
+ // not working. The error icon is used only if an error stopped the service.
+ int icon = R.drawable.ic_lines;
+ if (protectionEnabled) {
+ if (currentState == VPNStates.CONNECTED) {
+ icon = R.drawable.ic_filled;
+ } else {
+ icon = R.drawable.ic_alert;
+ }
+ }
+ if (currentState == VPNStates.ERROR || currentState == VPNStates.BLOCKING_ERROR) {
+ icon = R.drawable.ic_error;
+ }
+
+ // Create the style for a multiline notification. It will be ignore if the OS does not
+ // support it.
+ NotificationCompat.BigTextStyle bigTextStyle = new NotificationCompat.BigTextStyle()
+ .bigText(text)
+ .setBigContentTitle(App.getContext().getString(title));
+
+ return new NotificationCompat.Builder(App.getContext(), NOTIFICATION_CHANNEL_ID)
+ .setSmallIcon(icon)
+ .setContentTitle(App.getContext().getString(title))
+ .setContentText(text)
+ .setStyle(bigTextStyle)
+ .setContentIntent(HelperFunctions.getOpenAppPendingIntent())
+ .setOnlyAlertOnce(true)
+ .setSound(null)
+ .build();
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/helpers/UiMaterialIcons.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/helpers/UiMaterialIcons.java
new file mode 100644
index 0000000000..e1883ed109
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/helpers/UiMaterialIcons.java
@@ -0,0 +1,6 @@
+package com.skywire.skycoin.vpn.helpers;
+
+public enum UiMaterialIcons {
+ MENU,
+ BACK,
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/network/ApiClient.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/network/ApiClient.java
new file mode 100644
index 0000000000..9b385f3b07
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/network/ApiClient.java
@@ -0,0 +1,69 @@
+package com.skywire.skycoin.vpn.network;
+
+import com.skywire.skycoin.vpn.network.models.IpModel;
+import com.skywire.skycoin.vpn.network.models.VpnServerModel;
+
+import java.util.List;
+
+import io.reactivex.rxjava3.core.Observable;
+import io.reactivex.rxjava3.schedulers.Schedulers;
+import retrofit2.Response;
+import retrofit2.Retrofit;
+import retrofit2.adapter.rxjava3.RxJava3CallAdapterFactory;
+import retrofit2.converter.gson.GsonConverterFactory;
+import retrofit2.converter.scalars.ScalarsConverterFactory;
+import retrofit2.http.GET;
+import retrofit2.http.Query;
+import retrofit2.http.Url;
+
+public class ApiClient {
+
+ private interface ApiInterface {
+ @GET("services")
+ Observable>> getVpnServers(@Query("type") String type);
+
+ @GET
+ Observable> checkConnection(@Url String url);
+
+ @GET
+ Observable> checkCurrentIp(@Url String url);
+ }
+
+ private interface RawTextApiInterface {
+ @GET
+ Observable> checkIpCountry(@Url String url);
+ }
+
+ public static final String BASE_URL = "https://service.discovery.skycoin.com/api/";
+
+ private static final Retrofit retrofit = new Retrofit.Builder()
+ .baseUrl(BASE_URL)
+ .addConverterFactory(GsonConverterFactory.create())
+ .addCallAdapterFactory(RxJava3CallAdapterFactory.createWithScheduler(Schedulers.io()))
+ .build();
+
+ private static final Retrofit rawTextRetrofit = new Retrofit.Builder()
+ .baseUrl(BASE_URL)
+ .addConverterFactory(ScalarsConverterFactory.create())
+ .addCallAdapterFactory(RxJava3CallAdapterFactory.createWithScheduler(Schedulers.io()))
+ .build();
+
+ private static final ApiInterface apiService = retrofit.create(ApiInterface.class);
+ private static final RawTextApiInterface rawTextApiService = rawTextRetrofit.create(RawTextApiInterface.class);
+
+ public static Observable>> getVpnServers() {
+ return apiService.getVpnServers("vpn");
+ }
+
+ public static Observable> checkConnection(String url) {
+ return apiService.checkConnection(url);
+ }
+
+ public static Observable> getCurrentIp() {
+ return apiService.checkCurrentIp("https://api.ipify.org/?format=json");
+ }
+
+ public static Observable> getIpCountry(String ip) {
+ return rawTextApiService.checkIpCountry("https://ip2c.org/" + ip);
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/network/models/GeoInfoModel.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/network/models/GeoInfoModel.java
new file mode 100644
index 0000000000..d8c0671639
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/network/models/GeoInfoModel.java
@@ -0,0 +1,8 @@
+package com.skywire.skycoin.vpn.network.models;
+
+public class GeoInfoModel {
+ public Double lat;
+ public Double lon;
+ public String country;
+ public String region;
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/network/models/IpModel.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/network/models/IpModel.java
new file mode 100644
index 0000000000..e797c1fdf4
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/network/models/IpModel.java
@@ -0,0 +1,5 @@
+package com.skywire.skycoin.vpn.network.models;
+
+public class IpModel {
+ public String ip;
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/network/models/VpnServerModel.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/network/models/VpnServerModel.java
new file mode 100644
index 0000000000..b54869a887
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/network/models/VpnServerModel.java
@@ -0,0 +1,6 @@
+package com.skywire.skycoin.vpn.network.models;
+
+public class VpnServerModel {
+ public String addr;
+ public GeoInfoModel geo;
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/objects/LocalServerData.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/objects/LocalServerData.java
new file mode 100644
index 0000000000..518e1fc393
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/objects/LocalServerData.java
@@ -0,0 +1,18 @@
+package com.skywire.skycoin.vpn.objects;
+
+import java.util.Date;
+
+public class LocalServerData {
+ public String countryCode;
+ public String name;
+ public String customName;
+ public String pk;
+ public Date lastUsed;
+ public boolean inHistory;
+ public ServerFlags flag;
+ public String location;
+ public String note;
+ public String personalNote;
+ public String password;
+ public boolean enteredManually;
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/objects/ManualVpnServerData.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/objects/ManualVpnServerData.java
new file mode 100644
index 0000000000..2255fbd223
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/objects/ManualVpnServerData.java
@@ -0,0 +1,8 @@
+package com.skywire.skycoin.vpn.objects;
+
+public class ManualVpnServerData {
+ public String name;
+ public String password;
+ public String pk;
+ public String note;
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/objects/ServerFlags.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/objects/ServerFlags.java
new file mode 100644
index 0000000000..3a2ea5fc6c
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/objects/ServerFlags.java
@@ -0,0 +1,7 @@
+package com.skywire.skycoin.vpn.objects;
+
+public enum ServerFlags {
+ None,
+ Favorite,
+ Blocked
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/objects/ServerRatings.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/objects/ServerRatings.java
new file mode 100644
index 0000000000..8d178160ae
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/objects/ServerRatings.java
@@ -0,0 +1,38 @@
+package com.skywire.skycoin.vpn.objects;
+
+import com.skywire.skycoin.vpn.R;
+
+// TODO: for currently commented fields, must be deleted or reactivated depending on what happens to the fields.
+//public enum ServerRatings {
+// Gold,
+// Silver,
+// Bronze;
+//
+// /**
+// * Allows to get the resource ID of the string corresponding to the rating. If no resource is
+// * found for the rating, -1 is returned.
+// */
+// public static int getTextForRating(ServerRatings rating) {
+// if (rating == Gold) {
+// return R.string.rating_gold;
+// } else if (rating == Silver) {
+// return R.string.rating_silver;
+// } else if (rating == Bronze) {
+// return R.string.rating_bronze;
+// }
+//
+// return -1;
+// }
+//
+// public static int getNumberForRating(ServerRatings rating) {
+// if (rating == Gold) {
+// return 2;
+// } else if (rating == Silver) {
+// return 1;
+// } else if (rating == Bronze) {
+// return 0;
+// }
+//
+// return -1;
+// }
+//}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/vpn/SkywireVPNConnection.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/vpn/SkywireVPNConnection.java
new file mode 100644
index 0000000000..fca375bd86
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/vpn/SkywireVPNConnection.java
@@ -0,0 +1,312 @@
+package com.skywire.skycoin.vpn.vpn;
+
+import com.skywire.skycoin.vpn.helpers.Globals;
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.App;
+import com.skywire.skycoin.vpn.helpers.HelperFunctions;
+
+import java.io.Closeable;
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.nio.channels.DatagramChannel;
+
+import io.reactivex.rxjava3.core.Observable;
+import io.reactivex.rxjava3.core.ObservableEmitter;
+import io.reactivex.rxjava3.core.ObservableOnSubscribe;
+import io.reactivex.rxjava3.disposables.Disposable;
+import io.reactivex.rxjava3.schedulers.Schedulers;
+import skywiremob.Skywiremob;
+
+/**
+ * Class in charge of finishing starting the visor and connect it with the VPN work interface,
+ * to make the VPN functional.
+ */
+public class SkywireVPNConnection implements Closeable {
+ /**
+ * Object for controlling the local visor.
+ */
+ private final VisorRunnable visorRunnable;
+ /**
+ * Current VPN work interface.
+ */
+ private VPNWorkInterface vpnInterface;
+ /**
+ * Tunnel for communicating with the local visor.
+ */
+ private DatagramChannel tunnel = null;
+
+ /**
+ * Allows to know if any of the procedures for sending and receiving data finished.
+ */
+ private boolean managerFinished = false;
+ /**
+ * Error message returned during the last call to the function for making the VPN connection
+ * work, if any.
+ */
+ private String lastError = null;
+ /**
+ * Last error returned by a procedure for sending or receiving data in another thread, if any.
+ */
+ private Throwable operationError = null;
+ /**
+ * Observable used by this instance to make the VPN connection work.
+ */
+ private Observable observable;
+
+ private Disposable sendingProcedureSubscription;
+ private Disposable receivingProcedureSubscription;
+
+ public SkywireVPNConnection(
+ VisorRunnable visorRunnable,
+ VPNWorkInterface vpnInterface
+ ) {
+ this.visorRunnable = visorRunnable;
+ this.vpnInterface = vpnInterface;
+ }
+
+ /**
+ * Stops all operations and frees the resources used by this instance.
+ */
+ @Override
+ public void close() {
+ closeConnection();
+ }
+
+ /**
+ * Creates an observable with the procedure for finishing the visor initialization and
+ * connecting the VPN interface with it, which makes the whole VPN protection start working.
+ * @return Observable which emits the current state, using the constants defined in VPNStates.
+ * The observable is not expected to complete, just emit and return errors.
+ */
+ public Observable getObservable() {
+ // A new observable is created only if needed.
+ if (observable == null) {
+ observable = Observable.create((ObservableOnSubscribe) emitter -> {
+ try {
+ Skywiremob.printString("Starting VPN connection");
+
+ if (VPNGeneralPersistentData.getMustRestartVpn()) {
+ // The code will restart the connection in case of problem, but only if
+ // the connection was established during the last attempt.
+ while (true) {
+ // Stop if the emitter is no longer valid.
+ if (emitter.isDisposed()) { return; }
+
+ lastError = null;
+
+ // Break if the attempt was not able to finish the connection.
+ if (!run(emitter)) {
+ break;
+ }
+
+ // Retry after a small delay.
+ emitter.onNext(VPNStates.RESTORING_VPN);
+ if (emitter.isDisposed()) {
+ return;
+ }
+ Thread.sleep(2000);
+ }
+ } else {
+ // Try to make the connection one time only.
+ run(emitter);
+ }
+
+ // Finish with an error.
+ if (lastError == null) {
+ HelperFunctions.logError("VPN connection", "The connection has been closed unexpectedly.");
+ if (emitter.isDisposed()) { return; }
+ emitter.onError(new Exception(App.getContext().getString(R.string.vpn_connection_finished_error)));
+ } else {
+ HelperFunctions.logError("VPN connection", lastError);
+ if (emitter.isDisposed()) { return; }
+ emitter.onError(new Exception(lastError));
+ }
+ } catch (Exception e) {
+ HelperFunctions.logError("The VPN connection failed, exiting", e);
+ if (!emitter.isDisposed()) {
+ emitter.onError(e);
+ }
+ }
+
+ // This should never happen, as an error should have been reported before.
+ if (emitter.isDisposed()) { return; }
+ emitter.onComplete();
+ });
+ }
+
+ return observable;
+ }
+
+ /**
+ * Finish the visor initialization and connects the VPN interface with it, establishing the
+ * VPN connection. It is expected to run indefinitely and return only in case of error.
+ * @return True if the connections was established before the function finished.
+ */
+ private boolean run(ObservableEmitter parentEmitter) {
+ boolean connected = false;
+
+ managerFinished = false;
+
+ // Reset the error vars, to indicate that no errors have occurred during this execution of
+ // the function.
+ lastError = null;
+ operationError = null;
+
+ // TODO: delete if the code for protecting the sockets is removed.
+ // String protectErrorMsg = App.getContext().getString(R.string.vpn_socket_protection_error);
+
+ try {
+ // Finish the visor initialization.
+ visorRunnable.runVpnClient(parentEmitter);
+
+ // Create a DatagramChannel for connecting with the local visor.
+ if (parentEmitter.isDisposed()) { return connected; }
+ tunnel = DatagramChannel.open();
+
+ // TODO: this code is used for protecting the sockets (make them bypass vpn protection)
+ // needed for configuration, to avoid infinite loops. This is not currently needed
+ // because there is an exception that covers the entire application. The code remains
+ // here as a precaution and should be removed in the future.
+ /*
+ // Protect the tunnel before connecting to avoid loopback.
+ if (parentEmitter.isDisposed()) { return connected; }
+ if (!service.protect(tunnel.socket())) {
+ HelperFunctions.logError(getTag(), "Cannot protect the app-visor socket");
+ throw new IllegalStateException(protectErrorMsg);
+ }
+ while(true) {
+ if (parentEmitter.isDisposed()) { return connected; }
+
+ int fd = (int) Skywiremob.nextDmsgSocket();
+ if (fd == 0) { break; }
+
+ Skywiremob.printString("PRINTING FD " + fd);
+ if (!service.protect(fd)) {
+ HelperFunctions.logError(getTag(), "Cannot protect the socket for " + fd);
+ throw new IllegalStateException(protectErrorMsg);
+ }
+ }
+ */
+
+ // Connect to the local visor.
+ if (parentEmitter.isDisposed()) { return connected; }
+ tunnel.connect(new InetSocketAddress(Globals.LOCAL_VISOR_ADDRESS, Globals.LOCAL_VISOR_PORT));
+
+ // Inform the local socket address to Skywiremob.
+ // NOTE: this function should work in old Android versions, but there is a bug, at
+ // least in Android API 17, which makes the port to always be 0, that is why the app
+ // requires Android API 21+ to run. Maybe creating the socket by hand would allow to
+ // support older versions.
+ if (parentEmitter.isDisposed()) { return connected; }
+ Skywiremob.setMobileAppAddr(tunnel.socket().getLocalSocketAddress().toString());
+
+ // Make the data operations synchronous.
+ tunnel.configureBlocking(true);
+ // Configure the virtual network interface. This activates the VPN protection in the
+ // OS, if it is being done for the first time.
+ if (parentEmitter.isDisposed()) { return connected; }
+ vpnInterface.configure(VPNWorkInterface.Modes.WORKING);
+ // Inform the connection.
+ if (parentEmitter.isDisposed()) { return connected; }
+ connected = true;
+ parentEmitter.onNext(VPNStates.CONNECTED);
+
+ Skywiremob.printString("The VPN connection is forwarding packets on Android");
+
+ // Create an observable for sending data in another thread.
+ sendingProcedureSubscription = VPNDataManager.createObservable(vpnInterface, tunnel, true)
+ .subscribeOn(Schedulers.newThread()).subscribe(
+ val -> {},
+ err -> {
+ synchronized (this) {
+ // Save the error, to use it below.
+ if (operationError == null) {
+ operationError = err;
+ }
+ }
+
+ stopWaiting();
+ },
+ () -> stopWaiting()
+ );
+ // Create an observable for receiving data in another thread.
+ receivingProcedureSubscription = VPNDataManager.createObservable(vpnInterface, tunnel, false)
+ .subscribeOn(Schedulers.newThread()).subscribe(
+ val -> {},
+ err -> {
+ synchronized (this) {
+ // Save the error, to use it below.
+ if (operationError == null) {
+ operationError = err;
+ }
+ }
+
+ stopWaiting();
+ },
+ () -> stopWaiting()
+ );
+
+ synchronized (this) {
+ // Stop the thread until receiving a signal. If the observable is disposed while
+ // the thread is still waiting, an error will be thrown and it will be caught below.
+ if (!managerFinished) {
+ this.wait();
+ }
+
+ // If an error was saved while the thread was waiting, throw it.
+ if (operationError != null) {
+ throw operationError;
+ }
+ }
+ } catch (Throwable e) {
+ // Report the error.
+ if (!parentEmitter.isDisposed()) {
+ HelperFunctions.logError("VPN connector work procedure", e);
+ lastError = e.getLocalizedMessage();
+ }
+ } finally {
+ // CLose the connection.
+ closeConnection();
+ }
+
+ return connected;
+ }
+
+ /**
+ * Reactivates the thread after being stopped in the run() function.
+ */
+ private void stopWaiting() {
+ synchronized (this) {
+ managerFinished = true;
+
+ try {
+ this.notify();
+ } catch (Exception e) { }
+ }
+ }
+
+ /**
+ * Closes any open connection, stops the VPN client and stops the the pending threads.
+ */
+ private void closeConnection() {
+ if (sendingProcedureSubscription != null) {
+ sendingProcedureSubscription.dispose();
+ }
+ if (receivingProcedureSubscription != null) {
+ receivingProcedureSubscription.dispose();
+ }
+
+ visorRunnable.stopVpnConnection();
+
+ if (tunnel != null) {
+ try {
+ tunnel.close();
+ tunnel = null;
+ } catch (IOException e) {
+ HelperFunctions.logError("Unable to close tunnel used by the VPN connection", e);
+ }
+ }
+
+ stopWaiting();
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/vpn/SkywireVPNService.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/vpn/SkywireVPNService.java
new file mode 100644
index 0000000000..ad5ebe9b9b
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/vpn/SkywireVPNService.java
@@ -0,0 +1,508 @@
+package com.skywire.skycoin.vpn.vpn;
+
+import android.app.NotificationManager;
+import android.content.Context;
+import android.content.Intent;
+import android.net.VpnService;
+import android.os.Bundle;
+import android.os.Message;
+import android.os.Messenger;
+
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.App;
+import com.skywire.skycoin.vpn.helpers.HelperFunctions;
+import com.skywire.skycoin.vpn.helpers.Notifications;
+import com.skywire.skycoin.vpn.objects.ServerFlags;
+
+import java.util.concurrent.TimeUnit;
+
+import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
+import io.reactivex.rxjava3.core.Observable;
+import io.reactivex.rxjava3.disposables.Disposable;
+import io.reactivex.rxjava3.schedulers.Schedulers;
+import skywiremob.Skywiremob;
+
+/**
+ * Service in charge of making the VPN protection work, even if the UI is closed.
+ */
+public class SkywireVPNService extends VpnService {
+ /**
+ * Action that must be sent to the service for starting the VPN connection. If
+ * the connection has already been started, it continues running normally.
+ */
+ public static final String ACTION_CONNECT = "com.skywire.android.vpn.START";
+ /**
+ * Action that must be sent to the service for stopping the VPN connection. The procedure may
+ * take some time to complete, so the state events must be monitored.
+ */
+ public static final String ACTION_DISCONNECT = "com.skywire.android.vpn.STOP";
+
+ /**
+ * Param returned by the service as part of the state updates, for including the error
+ * message, if the state includes one.
+ */
+ public static final String ERROR_MSG_PARAM = "ErrorMsg";
+ /**
+ * Param returned by the service as part of the state updates, for informing if the service is
+ * running because the OS requested it (true) or was started by the app itself (false).
+ */
+ public static final String STARTED_BY_THE_SYSTEM_PARAM = "StartedByTheSystem";
+ /**
+ * Param returned by the service as part of the state updates, for informing if it has received
+ * a request for completely stopping the service. The request may have not been made by
+ * the user.
+ */
+ public static final String STOP_REQUESTED_PARAM = "StopRequested";
+
+ /**
+ * ID of the last instance of the service. This is needed because a new instance may be
+ * created by the OS while the previous one is still being destroyed and in those cases it is
+ * necessary to stop making some operations in the old instance.
+ */
+ public static int lastInstanceID = 0;
+ /**
+ * ID of this object instance. If it is not equal to lastInstanceID, this is not the
+ * latest instance.
+ */
+ public int instanceID = 0;
+
+ /**
+ * Object for showing notifications.
+ */
+ private final NotificationManager notificationManager = (NotificationManager) App.getContext().getSystemService(Context.NOTIFICATION_SERVICE);
+
+ /**
+ * Instance for communicating with the VPN coordinator class.
+ */
+ private Messenger messenger;
+
+ /**
+ * Object in charge of performing the steps needed for making the VPN protection work.
+ */
+ private VPNRunnable vpnRunnable;
+ /**
+ * Current VPN work interface.
+ */
+ private VPNWorkInterface vpnInterface;
+
+ /**
+ * Current state of the VPN protection.
+ */
+ private VPNStates currentState = VPNStates.STARTING;
+
+ /**
+ * If the service is running because the OS requested it (true) or was started by the app
+ * itself (false).
+ */
+ private boolean startedByTheSystem = false;
+ /**
+ * If true, a condition that makes it not possible to start the service was detected, so
+ * the option for retrying the connection must be ignored.
+ */
+ private boolean impossibleToStart = false;
+ /**
+ * If there was a request for completely stopping the service.
+ */
+ private boolean stopRequested = false;
+ /**
+ * If the service has already been destroyed. The code may still be running cleaning procedures.
+ */
+ private boolean serviceDestroyed = false;
+
+ /**
+ * Msg of the last error detected by this instance.
+ */
+ private String lastErrorMsg = "";
+
+ private Disposable updateNotificationSubscription;
+ private Disposable restartingSubscription;
+ private Disposable vpnRunnableSubscription;
+
+ /**
+ * Informs the current state to the VPN coordinator, updates the state notification and shows
+ * toast notifications, if needed. It also updates the current state var.
+ */
+ private void informNewState(VPNStates newState) {
+ // Cancel the operation if there is a newer instance of the service.
+ if (lastInstanceID != instanceID) {
+ return;
+ }
+
+ // Create a new message for informing the VPN coordinator about the new state.
+ Message msg = Message.obtain();
+ msg.what = newState.val();
+
+ // Add the additional data to the message.
+ Bundle dataBundle = new Bundle();
+ dataBundle.putBoolean(STARTED_BY_THE_SYSTEM_PARAM, startedByTheSystem);
+ dataBundle.putBoolean(STOP_REQUESTED_PARAM, stopRequested);
+
+ // Get the last error from vpnRunnable.getLastErrorMsg(). The lastErrorMsg must be used
+ // to avoid errors because vpnRunnable may be null.
+ lastErrorMsg = vpnRunnable != null ? vpnRunnable.getLastErrorMsg() : lastErrorMsg;
+ dataBundle.putString(ERROR_MSG_PARAM, lastErrorMsg);
+
+ msg.setData(dataBundle);
+
+ // Show toast notifications for certain states if the UI is not being shown.
+ if (!App.displayingUI() && currentState != newState) {
+ // Only if the service has not been destroyed.
+ if (!serviceDestroyed && (newState == VPNStates.CONNECTED ||
+ newState == VPNStates.RESTORING_VPN ||
+ newState == VPNStates.RESTORING_SERVICE ||
+ newState == VPNStates.ERROR ||
+ newState == VPNStates.BLOCKING_ERROR))
+ {
+ HelperFunctions.showToast(getString(VPNStates.getDescriptionForState(newState)), false);
+ }
+
+ // Even if the service has been destroyed.
+ if (newState == VPNStates.DISCONNECTED || newState == VPNStates.DISCONNECTING || newState == VPNStates.OFF) {
+ HelperFunctions.showToast(getString(VPNStates.getDescriptionForState(newState)), false);
+ }
+ }
+
+ currentState = newState;
+
+ // Send the message to the VPN coordinator.
+ try {
+ messenger.send(msg);
+ } catch (Exception e) { }
+
+ // Update the notification.
+ updateForegroundNotification();
+
+ // Procedure for periodically updating the notification with the connection stats, if the
+ // VPN protection is active.
+ if (updateNotificationSubscription != null) {
+ updateNotificationSubscription.dispose();
+ }
+ if (newState == VPNStates.CONNECTED) {
+ updateNotificationSubscription = Observable.interval(2000, TimeUnit.MILLISECONDS)
+ .subscribeOn(Schedulers.newThread())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(val -> updateForegroundNotification());
+ }
+ }
+
+ /**
+ * Function that must be called when there are changes in the state of the VPN protection. It
+ * processes the new state, makes some preparations and informs it.
+ */
+ private void updateState(VPNStates newState) {
+ // State that will be reported at the end of the function. It may be modified.
+ VPNStates processedState = newState;
+
+ // If the current state is for indicating an error and the new state is for indicating
+ // that the VPN protection is being disconnected, the current state is maintained, to
+ // avoid replacing the error indications, which is more useful than a generic indication
+ // about the service being stopped. This also prevents the code from "forgetting" that
+ // there was an error, which may be important later.
+ if (processedState.val() >= 200 && processedState.val() < 300 && currentState.val() >= 400 && currentState.val() <= 500) {
+ processedState = currentState;
+ }
+
+ boolean failedBecausePassword = false;
+ // If the state indicates that vpnRunnable finished, remove the instance.
+ if (processedState.val() >= 300 && processedState.val() < 400) {
+ // Check if the process finished due to an error cause by a wrong password. This data is
+ // used if the protection has to be restarted.
+ if (vpnRunnable != null && vpnRunnable.getIfPasswordFailed()) {
+ failedBecausePassword = true;
+ }
+ vpnRunnable = null;
+ if (vpnRunnableSubscription != null) {
+ vpnRunnableSubscription.dispose();
+ }
+ }
+
+ // Only needed if the service is not forced to terminate.
+ if (!stopRequested && !serviceDestroyed) {
+ // If the new state is for informing about an error.
+ if (processedState.val() >= 400 && processedState.val() < 500) {
+ if (VPNGeneralPersistentData.getMustRestartVpn() && !impossibleToStart) {
+ // If the option for restarting the protection automatically is active, update
+ // the state.
+ processedState = VPNStates.RESTORING_SERVICE;
+ } else if (processedState == VPNStates.ERROR) {
+ // If the error was not a blocking one, which would mean that the network must
+ // remain blocked, indicate that the service must be closed after closing
+ // the VPN.
+ stopRequested = true;
+ }
+ }
+
+ // If the service is being restored, hide the states about the connection being
+ // closed and restored.
+ if (currentState == VPNStates.RESTORING_SERVICE) {
+ // Restart the whole VPN connection after a small delay when receiving the state
+ // indicating that vpnRunnable finished. If the error was because the password was
+ // wrong, the delay is much longer.
+ if (processedState.val() >= 300 && processedState.val() < 400) {
+ int delay = failedBecausePassword ? 60000 : 1;
+ restartingSubscription = Observable.just(0).delay(delay, TimeUnit.MILLISECONDS)
+ .subscribeOn(Schedulers.newThread())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(val -> runVpn());
+ }
+
+ if (processedState.val() >= 150 && processedState.val() < 400) {
+ processedState = VPNStates.RESTORING_SERVICE;
+ }
+ } else {
+ // If the service is not being restored, close the whole service when receiving
+ // the state indicating that vpnRunnable finished.
+ if (processedState.val() >= 300 && processedState.val() < 400) {
+ processedState = currentState;
+ finishIfAppropriate();
+ }
+ }
+ } else {
+ // Close the whole service when receiving the state indicating that
+ // vpnRunnable finished.
+ if (processedState.val() >= 300 && processedState.val() < 400) {
+ processedState = currentState;
+ finishIfAppropriate();
+ }
+ }
+
+ // Inform the new state to the VPN coordinator and update the notifications.
+ informNewState(processedState);
+ }
+
+ /**
+ * Function called by the OS just after receiving an instruction for starting the service.
+ */
+ @Override
+ public int onStartCommand(Intent intent, int flags, int startId) {
+ // Update the ID of this instance, to make sure no old instance is considered newer than
+ // this one.
+ lastInstanceID += 1;
+ instanceID = lastInstanceID;
+
+ if (intent != null && ACTION_DISCONNECT.equals(intent.getAction())) {
+ // If this function was called to stop the VPN protection.
+
+ stopRequested = true;
+
+ // Stop the connection. If it was already stopped, finish the service directly.
+ if (vpnRunnable != null) {
+ vpnRunnable.disconnect();
+ } else {
+ finishIfAppropriate();
+ }
+
+ // Needed for informing the new value of the stopRequested var.
+ updateState(currentState);
+ } else {
+ // If the function was not called for stopping the VPN protection, it is considered
+ // that it was called for starting it. In this case, the instruction for starting the
+ // service may have been made by the OS or the app itself. if the ACTION_CONNECT action
+ // is not detected, it is considered that the request was made by the OS.
+
+ // Get the object for communicating with the VPN coordinator.
+ if (messenger == null) {
+ messenger = VPNCoordinator.getInstance().getCommunicationMessenger();
+ }
+
+ if (vpnInterface == null) {
+ // Become a foreground service. Background services can be VPN services too, but
+ // they can be killed by background check before getting a chance to
+ // receive onRevoke().
+ makeForeground();
+
+ vpnInterface = new VPNWorkInterface(this);
+ }
+
+ // If the option for blocking the network while configuring the service is active or
+ // the request was made by the OS, the VPN work interface is configured, to block all
+ // network connections. The action is always made when the service is started by the OS
+ // because the OS will only stop the service after the user request it if the interface
+ // is configured (appears like a bug in the OS).
+ if (!vpnInterface.alreadyConfigured() && (VPNGeneralPersistentData.getProtectBeforeConnected() || intent == null || !ACTION_CONNECT.equals(intent.getAction()))) {
+ try {
+ vpnInterface.configure(VPNWorkInterface.Modes.BLOCKING);
+ } catch (Exception e) {
+ // Report the error and finish the service.
+ HelperFunctions.logError("Configuring VPN work interface before connecting", e);
+ lastErrorMsg = getString(R.string.vpn_service_network_protection_error);
+ updateState(VPNStates.ERROR);
+ finishIfAppropriate();
+
+ return START_NOT_STICKY;
+ }
+
+ if (intent == null || !ACTION_CONNECT.equals(intent.getAction())) {
+ HelperFunctions.showToast(getString(R.string.vpn_service_network_unavailable_warning), false);
+ }
+ }
+
+ // Update if the service was started by the OS and notify it in a state event. Note
+ // that this code updates the previous value if the service was originally started by
+ // the app, this is intended.
+ if (intent == null || !ACTION_CONNECT.equals(intent.getAction())) {
+ startedByTheSystem = true;
+ }
+ updateState(currentState);
+
+ // Check if no server has been selected and if the selected server has been blocked.
+ String errorMsg = null;
+ if (
+ VPNServersPersistentData.getInstance().getCurrentServer() == null ||
+ VPNServersPersistentData.getInstance().getCurrentServer().pk == null ||
+ VPNServersPersistentData.getInstance().getCurrentServer().pk.trim().equals("")
+ ) {
+ errorMsg = App.getContext().getText(R.string.skywiremob_error_no_server).toString();
+ } else if (VPNServersPersistentData.getInstance().getCurrentServer().flag == ServerFlags.Blocked) {
+ errorMsg = App.getContext().getText(R.string.skywiremob_error_server_blocked).toString();
+ }
+
+ // If any of the previous conditions was found, put the service in error state.
+ if (errorMsg != null) {
+ HelperFunctions.logError("Starting VPN service", errorMsg);
+ lastErrorMsg = errorMsg;
+ impossibleToStart = true;
+ updateState(VPNStates.ERROR);
+ } else {
+ // Start the VPN protection.
+ runVpn();
+ }
+ }
+
+ return START_NOT_STICKY;
+ }
+
+ /**
+ * Function called by the OS when the service is destroyed.
+ */
+ @Override
+ public void onDestroy() {
+ Skywiremob.printString("VPN service destroyed.");
+ serviceDestroyed = true;
+
+ // Stop the connection. If it was already stopped, finish the service directly.
+ if (vpnRunnable != null) {
+ vpnRunnable.disconnect();
+ } else {
+ finishIfAppropriate();
+ }
+ }
+
+ /**
+ * Function called by the OS when the user revokes the permission for the VPN.
+ */
+ @Override
+ public void onRevoke() {
+ super.onRevoke();
+ Skywiremob.printString("onRevoke called");
+ // Destroy the service.
+ this.stopSelf();
+ }
+
+ /**
+ * Starts the VPN protection, if it is not already active or starting.
+ */
+ private void runVpn() {
+ if (vpnRunnable == null) {
+ vpnRunnable = new VPNRunnable(vpnInterface);
+ }
+
+ if (vpnRunnableSubscription != null) {
+ vpnRunnableSubscription.dispose();
+ }
+
+ // Initialize the VPN. Also, get and process the state updates.
+ vpnRunnableSubscription = vpnRunnable.start().subscribe(state -> updateState(state));
+ }
+
+ /**
+ * Cleans the resources used by the service and stops it, but only if vpnRunnable
+ * already finished.
+ */
+ private void finishIfAppropriate() {
+ if (vpnRunnable == null) {
+ if (vpnInterface == null ||
+ !vpnInterface.alreadyConfigured() ||
+ stopRequested ||
+ serviceDestroyed ||
+ currentState.val() < 400 ||
+ currentState.val() >= 500 ||
+ !VPNGeneralPersistentData.getKillSwitchActivated()
+ ) {
+ // Steps that must be performed only if there is no a newer instance of the service.
+ if (lastInstanceID == instanceID) {
+ // Clean the VPN interface (which stops blocking the network connections).
+ if (vpnInterface != null) {
+ vpnInterface.close();
+
+ // Create another interface and close it immediately to avoid a bug in
+ // older Android versions when the app is added to the ignore list.
+ vpnInterface = new VPNWorkInterface(this);
+ try {
+ vpnInterface.configure(VPNWorkInterface.Modes.DELETING);
+ } catch (Exception e) { }
+ vpnInterface.close();
+ }
+
+ // Remove the state notification.
+ notificationManager.cancel(Notifications.SERVICE_STATUS_NOTIFICATION_ID);
+
+ // Report the new state after a delay, to avoid interferences with any new
+ // state reported by the code which called this function.
+ Observable.just(0).delay(100, TimeUnit.MILLISECONDS)
+ .subscribeOn(Schedulers.newThread())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(val -> updateState(VPNStates.OFF));
+
+ // If there was an error in the last execution, the UI is not being displayed
+ // and the kill switch is not active, show a notification informing that
+ // the VPN protection was terminated due to an error.
+ if (!App.displayingUI() && !VPNGeneralPersistentData.getKillSwitchActivated() && VPNGeneralPersistentData.getLastError(null) != null) {
+ Notifications.showAlertNotification(
+ Notifications.ERROR_NOTIFICATION_ID,
+ getString(R.string.general_app_name),
+ getString(R.string.general_connection_error),
+ HelperFunctions.getOpenAppPendingIntent()
+ );
+ }
+ }
+
+ // Remove the objects and close the subscriptions.
+ vpnInterface = null;
+ vpnRunnable = null;
+ if (vpnRunnableSubscription != null) {
+ vpnRunnableSubscription.dispose();
+ }
+ if (restartingSubscription != null) {
+ restartingSubscription.dispose();
+ }
+
+ // Terminate the service.
+ stopForeground(true);
+ stopSelf();
+ }
+ }
+ }
+
+ /**
+ * Updates the state notification shown while the service is running in the foreground.
+ */
+ private void updateForegroundNotification() {
+ if (!serviceDestroyed) {
+ notificationManager.notify(
+ Notifications.SERVICE_STATUS_NOTIFICATION_ID,
+ Notifications.createStatusNotification(currentState, vpnInterface != null && vpnInterface.alreadyConfigured())
+ );
+ }
+ }
+
+ /**
+ * Converts the service into a foreground service, to prevent it to be destroyed by the OS.
+ */
+ private void makeForeground() {
+ startForeground(
+ Notifications.SERVICE_STATUS_NOTIFICATION_ID,
+ Notifications.createStatusNotification(currentState, vpnInterface != null && vpnInterface.alreadyConfigured())
+ );
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/vpn/VPNCoordinator.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/vpn/VPNCoordinator.java
new file mode 100644
index 0000000000..5689830d4a
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/vpn/VPNCoordinator.java
@@ -0,0 +1,315 @@
+package com.skywire.skycoin.vpn.vpn;
+
+import android.app.Activity;
+import android.app.ActivityManager;
+import android.content.Context;
+import android.content.Intent;
+import android.net.VpnService;
+import android.os.Build;
+import android.os.Handler;
+import android.os.Message;
+import android.os.Messenger;
+
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.App;
+import com.skywire.skycoin.vpn.helpers.HelperFunctions;
+import com.skywire.skycoin.vpn.helpers.Notifications;
+import com.skywire.skycoin.vpn.objects.LocalServerData;
+
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.concurrent.TimeUnit;
+
+import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
+import io.reactivex.rxjava3.core.Observable;
+import io.reactivex.rxjava3.disposables.Disposable;
+import io.reactivex.rxjava3.schedulers.Schedulers;
+import io.reactivex.rxjava3.subjects.BehaviorSubject;
+import skywiremob.Skywiremob;
+
+import static android.app.Activity.RESULT_OK;
+
+/**
+ * Class for communication between the app UI and the VPN service. It is accessed via a singleton.
+ */
+public class VPNCoordinator implements Handler.Callback {
+ public static class ConnectionStats {
+ public Date lastConnectionDate = null;
+ public long currentDownloadSpeed = 0;
+ public long currentUploadSpeed = 0;
+ public long currentLatency = 0;
+ public long totalDownloadedData = 0;
+ public long totalUploadedData = 0;
+ public ArrayList downloadSpeedHistory = new ArrayList<>();
+ public ArrayList uploadSpeedHistory = new ArrayList<>();
+ public ArrayList latencyHistory = new ArrayList<>();
+
+ public ConnectionStats() {
+ for (int i = 0; i < 10; i++) {
+ downloadSpeedHistory.add(0L);
+ uploadSpeedHistory.add(0L);
+ latencyHistory.add(0L);
+ }
+ }
+ }
+
+ /**
+ * Value the onActivityResult function will get after asking the user for permission.
+ */
+ public static final int VPN_PREPARATION_REQUEST_CODE = 10100;
+
+ /**
+ * Singleton instance.
+ */
+ private static final VPNCoordinator instance = new VPNCoordinator();
+ /**
+ * Gets the singleton for using the class.
+ */
+ public static VPNCoordinator getInstance() { return instance; }
+
+ private Disposable updateStatsSubscription;
+
+ private ConnectionStats connectionStats = new ConnectionStats();
+
+ /**
+ * App context.
+ */
+ private final Context ctx = App.getContext();
+
+ /**
+ * Handler used for receiving messages from the VPN service.
+ */
+ private final Handler serviceCommunicationHandler;
+ /**
+ * Subject for sending events via RxJava, indicating the current state of the VPN service.
+ */
+ private final BehaviorSubject eventsSubject = BehaviorSubject.create();
+
+ private final BehaviorSubject connectionStatsSubject = BehaviorSubject.create();
+
+ private VPNCoordinator() {
+ serviceCommunicationHandler = new Handler(this);
+
+ // Add a default current state.
+ eventsSubject.onNext(new VPNStates.StateInfo(VPNStates.OFF, false, false));
+ }
+
+ public Observable getConnectionStats() {
+ return connectionStatsSubject.hide();
+ }
+
+ /**
+ * Handles the messages received from the VPN service.
+ */
+ @Override
+ public boolean handleMessage(Message msg) {
+ // Save the error as the one which made the last execution of the VPN service fail.
+ // Must be done before sending the event.
+ String errorMsg = msg.getData().getString(SkywireVPNService.ERROR_MSG_PARAM);
+ if (errorMsg != null && !errorMsg.equals("") && !errorMsg.equals(VPNGeneralPersistentData.getLastError(null))) {
+ VPNGeneralPersistentData.setLastError(errorMsg);
+ }
+
+ if (updateStatsSubscription == null) {
+ continuallyUpdateStats();
+ }
+
+ if (VPNStates.valueOf(msg.what) == VPNStates.CONNECTED) {
+ // Erase the error which made not possible to connect the last time.
+ VPNGeneralPersistentData.removeLastError();
+
+ if (connectionStats.lastConnectionDate == null) {
+ connectionStats.lastConnectionDate = new Date();
+ }
+ } else {
+ if (VPNStates.valueOf(msg.what) == VPNStates.DISCONNECTED || VPNStates.valueOf(msg.what) == VPNStates.OFF) {
+ if (updateStatsSubscription != null) {
+ updateStatsSubscription.dispose();
+ updateStatsSubscription = null;
+ }
+
+ connectionStats = new ConnectionStats();
+ connectionStatsSubject.onNext(connectionStats);
+ } else {
+ connectionStats.lastConnectionDate = null;
+ }
+ }
+
+ // Create the state object with the params returned by the VPN service.
+ VPNStates.StateInfo state = new VPNStates.StateInfo(
+ VPNStates.valueOf(msg.what),
+ msg.getData().getBoolean(SkywireVPNService.STARTED_BY_THE_SYSTEM_PARAM),
+ msg.getData().getBoolean(SkywireVPNService.STOP_REQUESTED_PARAM)
+ );
+
+ // Inform the new state.
+ eventsSubject.onNext(state);
+
+ return true;
+ }
+
+ private void continuallyUpdateStats() {
+ if (updateStatsSubscription != null) {
+ updateStatsSubscription.dispose();
+ }
+
+ sendStats();
+
+ updateStatsSubscription = Observable.interval(1000L, TimeUnit.MILLISECONDS)
+ .subscribeOn(Schedulers.newThread())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(val -> {
+ sendStats();
+ });
+ }
+
+ private void sendStats() {
+ connectionStats.currentDownloadSpeed = Skywiremob.vpnBandwidthReceived();
+ connectionStats.downloadSpeedHistory.remove(0);
+ connectionStats.downloadSpeedHistory.add(connectionStats.currentDownloadSpeed);
+
+ connectionStats.currentUploadSpeed = Skywiremob.vpnBandwidthSent();
+ connectionStats.uploadSpeedHistory.remove(0);
+ connectionStats.uploadSpeedHistory.add(connectionStats.currentUploadSpeed);
+
+ connectionStats.currentLatency = Skywiremob.vpnLatency();
+ connectionStats.latencyHistory.remove(0);
+ connectionStats.latencyHistory.add(connectionStats.currentLatency);
+
+ connectionStatsSubject.onNext(connectionStats);
+ }
+
+ /**
+ * Allows to know if the VPN service is currently running.
+ */
+ public boolean isServiceRunning() {
+ ActivityManager manager = (ActivityManager) App.getContext().getSystemService(Context.ACTIVITY_SERVICE);
+ for (ActivityManager.RunningServiceInfo service : manager.getRunningServices(Integer.MAX_VALUE)) {
+ // Check if any of the running services is the VPN service.
+ if (SkywireVPNService.class.getName().equals(service.service.getClassName())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Returns an observable that emits every time the state of the VPN service changes. The
+ * observable does not emit errors and never completes.
+ */
+ public Observable getEventsObservable() {
+ return eventsSubject.hide();
+ }
+
+ /**
+ * Makes the preparations and starts the VPN service. If it is already running, nothing happens.
+ * @param requestingActivity Activity requesting the service to be started. Please note
+ * that the onActivityResult function of that activity may be called with the value of
+ * VPN_PREPARATION_REQUEST_CODE as the first param. In that case the activity must call the
+ * onActivityResult function of this instance with all the params, to be able to process
+ * permission requests
+ * @param server Data about the remote visor.
+ */
+ public void startVPN(Activity requestingActivity, LocalServerData server) {
+ if (!isServiceRunning()) {
+ // Save the remote visor and password.
+ VPNServersPersistentData.getInstance().modifyCurrentServer(server);
+ VPNServersPersistentData.getInstance().updateHistory();
+
+ // As the service will be started again, erase the error which made it fail the last
+ // time it ran, to indicate that no error has stopped the current instance.
+ VPNGeneralPersistentData.removeLastError();
+
+ eventsSubject.onNext(new VPNStates.StateInfo(VPNStates.STARTING, false, false));
+
+ // Get the permission request intent from the OS.
+ Intent intent = VpnService.prepare(requestingActivity);
+ if (intent != null) {
+ // Ask for permission before continuing.
+ requestingActivity.startActivityForResult(intent, VPN_PREPARATION_REQUEST_CODE);
+ } else {
+ starVpnServiceIfNeeded();
+ }
+ }
+ }
+
+ /**
+ * Function for starting the VPN service after boot. If the service is already running,
+ * nothing happens.
+ */
+ public void activateAutostart() {
+ if (!isServiceRunning()) {
+ // Check if permission is needed. If it is, fail.
+ Intent intent = VpnService.prepare(ctx);
+ if (intent != null) {
+ HelperFunctions.showToast(ctx.getString(R.string.general_autostart_failed_error), false);
+
+ String errorMsg = ctx.getString(R.string.general_no_permissions_error);
+ VPNGeneralPersistentData.setLastError(errorMsg);
+
+ Notifications.showAlertNotification(
+ Notifications.AUTOSTART_ALERT_NOTIFICATION_ID,
+ ctx.getString(R.string.general_app_name),
+ errorMsg,
+ HelperFunctions.getOpenAppPendingIntent()
+ );
+
+ return;
+ }
+
+ // As the service will be started again, erase the error which made it fail the last
+ // time it ran, to indicate that no error has stopped the current instance.
+ VPNGeneralPersistentData.removeLastError();
+
+ starVpnServiceIfNeeded();
+ }
+ }
+
+ /**
+ * Asks the VPN service to stop. It will not be stopped immediately, the state change events
+ * must be checked for knowing when it is really stopped.
+ */
+ public void stopVPN() {
+ ctx.startService(getServiceIntent().setAction(SkywireVPNService.ACTION_DISCONNECT));
+ }
+
+ /**
+ * Must be called by the activity used for calling startVPN, if the same function is called
+ * in the activity and the value of VPN_PREPARATION_REQUEST_CODE was received as request.
+ * The same params received in the activity must be provided.
+ */
+ public void onActivityResult(int request, int result, Intent data) {
+ if (request == VPN_PREPARATION_REQUEST_CODE) {
+ if (result == RESULT_OK) {
+ starVpnServiceIfNeeded();
+ } else {
+ eventsSubject.onNext(new VPNStates.StateInfo(VPNStates.OFF, false, true));
+ }
+ }
+ }
+
+ /**
+ * Starts the VPN service if it is not already running.
+ */
+ private void starVpnServiceIfNeeded() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ ctx.startForegroundService(getServiceIntent().setAction(SkywireVPNService.ACTION_CONNECT));
+ } else {
+ ctx.startService(getServiceIntent().setAction(SkywireVPNService.ACTION_CONNECT));
+ }
+ }
+
+ /**
+ * Gets the VPN service intent, without action.
+ */
+ private Intent getServiceIntent() {
+ return new Intent(ctx, SkywireVPNService.class);
+ }
+
+ /**
+ * Gets a Messenger object for communicating with this instance.
+ */
+ public Messenger getCommunicationMessenger() {
+ return new Messenger(serviceCommunicationHandler);
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/vpn/VPNDataManager.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/vpn/VPNDataManager.java
new file mode 100644
index 0000000000..9ebee7bff6
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/vpn/VPNDataManager.java
@@ -0,0 +1,85 @@
+package com.skywire.skycoin.vpn.vpn;
+
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.InterruptedIOException;
+import java.nio.ByteBuffer;
+import java.nio.channels.DatagramChannel;
+
+import io.reactivex.rxjava3.core.Observable;
+import io.reactivex.rxjava3.core.ObservableOnSubscribe;
+
+/**
+ * Helper class for creating an observable for sending or getting data to or from the visor.
+ */
+public class VPNDataManager {
+ /**
+ * Creates an observable for sending or getting data to or from the visor.
+ * @param vpnInterface Interface currently used for the VPN connection.
+ * @param tunnel Socket for communicating with the visor.
+ * @param forSending True if the observable will be used for sending the data from the OS to the
+ * visor, false if it is for sending the data from the visor to the OS.
+ */
+ static public Observable createObservable(VPNWorkInterface vpnInterface, DatagramChannel tunnel, boolean forSending) {
+ return Observable.create((ObservableOnSubscribe) emitter -> {
+ // Streams for receiving and sending packages.
+ final FileInputStream in;
+ final FileOutputStream out;
+ // Only the stream needed is initialized.
+ if (forSending) {
+ in = vpnInterface.getInputStream();
+ out = null;
+ } else {
+ in = null;
+ out = vpnInterface.getOutputStream();
+ }
+
+ ByteBuffer packet = ByteBuffer.allocate(Short.MAX_VALUE);
+
+ // Get or send data while the emitter is still valid.
+ while(!emitter.isDisposed()) {
+ try {
+ if (forSending) {
+ // Read the outgoing packet from the input stream. The operation must block
+ // blocks the thread.
+ int length = in.read(packet.array());
+ if (length > 0) {
+ // Write the outgoing packet to the tunnel.
+ packet.limit(length);
+ tunnel.write(packet);
+ packet.clear();
+ }
+ }
+
+ if (!forSending) {
+ // Read the incoming packet from the visor socket. The operation must block
+ // blocks the thread.
+ int length = tunnel.read(packet);
+ if (length > 0) {
+ // Ignore control messages, which start with zero.
+ if (packet.get(0) != 0) {
+ // Write the incoming packet to the output stream.
+ out.write(packet.array(), 0, length);
+ }
+ packet.clear();
+ }
+ }
+ } catch (InterruptedIOException e) {
+ // This error is thrown if there is a timeout while waiting data from the socket.
+ // It is ignored so that the loop repeats itself to wait for data again.
+ } catch (Exception e) {
+ // Emit the error only if the emitter is still valid.
+ if (!emitter.isDisposed()) {
+ emitter.onError(e);
+ return;
+ }
+
+ break;
+ }
+ }
+
+ // Indicate the observable finished.
+ emitter.onComplete();
+ });
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/vpn/VPNGeneralPersistentData.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/vpn/VPNGeneralPersistentData.java
new file mode 100644
index 0000000000..e15b58c19e
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/vpn/VPNGeneralPersistentData.java
@@ -0,0 +1,238 @@
+package com.skywire.skycoin.vpn.vpn;
+
+import android.content.SharedPreferences;
+
+import androidx.preference.PreferenceManager;
+
+import com.google.gson.Gson;
+import com.skywire.skycoin.vpn.App;
+import com.skywire.skycoin.vpn.helpers.Globals;
+
+import java.util.HashSet;
+
+import io.reactivex.rxjava3.core.Observable;
+import io.reactivex.rxjava3.subjects.BehaviorSubject;
+
+/**
+ * Helper class for saving and getting general data related to the VPN to and from the
+ * persistent storage.
+ */
+public class VPNGeneralPersistentData {
+ // Keys for persistent storage.
+ private static final String LAST_ERROR = "lastError";
+ private static final String DATA_UNITS = "dataUnits";
+ private static final String CUSTOM_DNS = "customDns";
+ private static final String APPS_SELECTION_MODE = "appsMode";
+ private static final String APPS_LIST = "appsList";
+ private static final String SHOW_IP = "showIp";
+ private static final String KILL_SWITCH = "killSwitch";
+ private static final String RESTART_VPN = "restartVpn";
+ private static final String START_ON_BOOT = "startOnBoot";
+ private static final String PROTECT_BEFORE_CONNECTED = "protectBeforeConnected";
+
+ private static final SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(App.getContext());
+
+ private static BehaviorSubject dataUnitsSubject;
+
+ /////////////////////////////////////////////////////////////
+ // Setters.
+ /////////////////////////////////////////////////////////////
+
+ /**
+ * Saves the message of the error which caused the VPN service to fail the last time it
+ * ran, if any.
+ */
+ public static void setLastError(String val) {
+ settings.edit().putString(LAST_ERROR, val).apply();
+ }
+
+ /**
+ * Saves the data units that must be shown in the UI.
+ */
+ public static void setDataUnits(Globals.DataUnits val) {
+ Gson gson = new Gson();
+ String valString = gson.toJson(val);
+ settings.edit().putString(DATA_UNITS, valString).apply();
+
+ // Inform the change.
+ if (dataUnitsSubject != null) {
+ dataUnitsSubject.onNext(val);
+ }
+ }
+
+ /**
+ * Saves the IP of the custom DNS server.
+ */
+ public static void setCustomDns(String val) {
+ settings.edit().putString(CUSTOM_DNS, val).apply();
+ }
+
+ /**
+ * Saves the mode the VPN service must use to protect or ignore the apps selected by the user.
+ */
+ public static void setAppsSelectionMode(Globals.AppFilteringModes val) {
+ settings.edit().putString(APPS_SELECTION_MODE, val.toString()).apply();
+ }
+
+ /**
+ * Saves the list with the package names of all apps selected by the user in the app list.
+ */
+ public static void setAppList(HashSet val) {
+ settings.edit().putStringSet(APPS_LIST, val).apply();
+ }
+
+ /**
+ * Sets if the functionality for showing the IP must be active.
+ */
+ public static void setShowIpActivated(boolean val) {
+ settings.edit().putBoolean(SHOW_IP, val).apply();
+ }
+
+ /**
+ * Sets if the kill switch functionality must be active.
+ */
+ public static void setKillSwitchActivated(boolean val) {
+ settings.edit().putBoolean(KILL_SWITCH, val).apply();
+ }
+
+ /**
+ * Sets if the VPN connection must be automatically restarted if there is an error.
+ */
+ public static void setMustRestartVpn(boolean val) {
+ settings.edit().putBoolean(RESTART_VPN, val).apply();
+ }
+
+ /**
+ * Sets if the VPN protection must be activated as soon as possible after booting the OS.
+ */
+ public static void setStartOnBoot(boolean val) {
+ settings.edit().putBoolean(START_ON_BOOT, val).apply();
+ }
+
+ /**
+ * Sets if the network protection must be activated just after starting the VPN service, which
+ * would disable the internet connectivity for the rest of the apps while configuring the visor.
+ */
+ public static void setProtectBeforeConnected(boolean val) {
+ settings.edit().putBoolean(PROTECT_BEFORE_CONNECTED, val).apply();
+ }
+
+ /////////////////////////////////////////////////////////////
+ // Getters.
+ /////////////////////////////////////////////////////////////
+
+ /**
+ * Gets the message of the error which caused the VPN service to fail the last time it
+ * ran, if any.
+ * @param defaultValue Value to return if no saved data is found.
+ */
+ public static String getLastError(String defaultValue) {
+ return settings.getString(LAST_ERROR, defaultValue);
+ }
+
+ /**
+ * Returns the data units that must be shown in the UI. If the user has not changed
+ * the setting, it returns DataUnits.BitsSpeedAndBytesVolume by default.
+ */
+ public static Globals.DataUnits getDataUnits() {
+ Gson gson = new Gson();
+ String savedVal = settings.getString(DATA_UNITS, null);
+ if (savedVal != null) {
+ return gson.fromJson(savedVal, Globals.DataUnits.class);
+ }
+
+ return Globals.DataUnits.BitsSpeedAndBytesVolume;
+ }
+
+ /**
+ * Emits every time the data units that must be shown in the UI are changed. It emits the most
+ * recent value immediately after subscription.
+ */
+ public static Observable getDataUnitsObservable() {
+ if (dataUnitsSubject == null) {
+ dataUnitsSubject = BehaviorSubject.create();
+ dataUnitsSubject.onNext(getDataUnits());
+ }
+
+ return dataUnitsSubject.hide();
+ }
+
+ /**
+ * Gets the IP of the custom DNS server.
+ */
+ public static String getCustomDns() {
+ return settings.getString(CUSTOM_DNS, null);
+ }
+
+ /**
+ * Gets the mode the VPN service must use to protect or ignore the apps selected by the user.
+ */
+ public static Globals.AppFilteringModes getAppsSelectionMode() {
+ String savedValue = settings.getString(APPS_SELECTION_MODE, null);
+
+ if (savedValue == null || savedValue.equals(Globals.AppFilteringModes.PROTECT_ALL.toString())) {
+ return Globals.AppFilteringModes.PROTECT_ALL;
+ } else if (savedValue.equals(Globals.AppFilteringModes.PROTECT_SELECTED.toString())) {
+ return Globals.AppFilteringModes.PROTECT_SELECTED;
+ } else if (savedValue.equals(Globals.AppFilteringModes.IGNORE_SELECTED.toString())) {
+ return Globals.AppFilteringModes.IGNORE_SELECTED;
+ }
+
+ return Globals.AppFilteringModes.PROTECT_ALL;
+ }
+
+ /**
+ * Gets the list with the package names of all apps selected by the user in the app list.
+ * @param defaultValue Value to return if no saved data is found.
+ */
+ public static HashSet getAppList(HashSet defaultValue) {
+ return new HashSet<>(settings.getStringSet(APPS_LIST, defaultValue));
+ }
+
+ /**
+ * Gets if the functionality for showing the IP must be active.
+ */
+ public static boolean getShowIpActivated() {
+ return settings.getBoolean(SHOW_IP, true);
+ }
+
+ /**
+ * Gets if the kill switch functionality must be active.
+ */
+ public static boolean getKillSwitchActivated() {
+ return settings.getBoolean(KILL_SWITCH, true);
+ }
+
+ /**
+ * Gets if the VPN connection must be automatically restarted if there is an error.
+ */
+ public static boolean getMustRestartVpn() {
+ return settings.getBoolean(RESTART_VPN, true);
+ }
+
+ /**
+ * Gets if the VPN protection must be activated as soon as possible after booting the OS.
+ */
+ public static boolean getStartOnBoot() {
+ return settings.getBoolean(START_ON_BOOT, false);
+ }
+
+ /**
+ * Gets if the network protection must be activated just after starting the VPN service, which
+ * would disable the internet connectivity for the rest of the apps while configuring the visor.
+ */
+ public static boolean getProtectBeforeConnected() {
+ return settings.getBoolean(PROTECT_BEFORE_CONNECTED, true);
+ }
+
+ /////////////////////////////////////////////////////////////
+ // Other operations.
+ /////////////////////////////////////////////////////////////
+
+ /**
+ * Removes the message of the error which caused the VPN service to fail the last time it ran.
+ */
+ public static void removeLastError() {
+ settings.edit().remove(LAST_ERROR).apply();
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/vpn/VPNRunnable.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/vpn/VPNRunnable.java
new file mode 100644
index 0000000000..8eab908b09
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/vpn/VPNRunnable.java
@@ -0,0 +1,354 @@
+package com.skywire.skycoin.vpn.vpn;
+
+import com.skywire.skycoin.vpn.App;
+import com.skywire.skycoin.vpn.helpers.HelperFunctions;
+import com.skywire.skycoin.vpn.R;
+
+import java.util.concurrent.TimeUnit;
+
+import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
+import io.reactivex.rxjava3.core.Observable;
+import io.reactivex.rxjava3.core.ObservableOnSubscribe;
+import io.reactivex.rxjava3.disposables.Disposable;
+import io.reactivex.rxjava3.schedulers.Schedulers;
+import io.reactivex.rxjava3.subjects.BehaviorSubject;
+import skywiremob.Skywiremob;
+
+/**
+ * Class for configuring most of the VPN protection. After creating an instance, the start method
+ * can be used to start a series of steps for configuring the local visor and creating the VPN
+ * connection. Each instance can be used one time only, so a new instance must be created for
+ * starting the VPN protection again.
+ */
+public class VPNRunnable {
+ /**
+ * Current VPN work interface.
+ */
+ private final VPNWorkInterface vpnInterface;
+ /**
+ * Object for controlling the local visor.
+ */
+ private VisorRunnable visor;
+ /**
+ * Object for connecting the visor with the VPN work interface, to make the VPN functional.
+ */
+ private SkywireVPNConnection vpnConnection;
+
+ /**
+ * If the procedure to wait for the visor to be available already finished.
+ */
+ private boolean waitAvailableFinished = false;
+ /**
+ * If the procedure to wait for having network connectivity already finished.
+ */
+ private boolean waitNetworkFinished = false;
+
+ /**
+ * If the disconnection procedure already started.
+ */
+ private boolean disconnectionStarted = false;
+ /**
+ * Counts how many consecutive times the visor was detected as shut down while disconnecting.
+ */
+ private int disconnectionVerifications = 0;
+
+ /**
+ * Subject for informing about the state of the VPN protection.
+ */
+ private final BehaviorSubject eventsSubject = BehaviorSubject.create();
+ /**
+ * Subject for informing about the state of the VPN protection.
+ */
+ private Observable eventsObservable;
+
+ /**
+ * Msg string of the last error detected by this instance.
+ */
+ private String lastErrorMsg;
+
+ private Disposable waitingSubscription;
+ private Disposable visorTimeoutSubscription;
+
+ /**
+ * Constructor.
+ * @param vpnInterface VPN work interface to use. This class will only configure it when
+ * stabilising the connection, so it will have to be configured before
+ * using this constructor if the network must be blocked before that.
+ * Also, this class will not unblock the network after disconnecting, that
+ * will have to be done by external code.
+ */
+ public VPNRunnable(VPNWorkInterface vpnInterface) {
+ eventsSubject.onNext(VPNStates.OFF);
+ this.vpnInterface = vpnInterface;
+ }
+
+ /**
+ * Starts the initialization procedure for the VPN protection, if it has not already
+ * been started.
+ * @return Observable for knowing the current state of the VPN protection. The operation is not
+ * started by the subscription, it starts just for calling the function, so there is no need
+ * for observing in another thread.
+ */
+ public Observable start() {
+ if (eventsObservable == null) {
+ // Prepare for sending events.
+ eventsSubject.onNext(VPNStates.STARTING);
+ eventsObservable = eventsSubject.hide();
+ }
+
+ // Go to the first step.
+ waitForVisorToBeAvailableIfNeeded();
+
+ return eventsObservable;
+ }
+
+ /**
+ * Allows to know if the initialization failed because the server refused the password.
+ */
+ public boolean getIfPasswordFailed() {
+ return visor != null ? visor.getIfPasswordFailed() : false;
+ }
+
+ /**
+ * Waits for the visor to be totally stopped. After that, goes to the next step for
+ * starting the VPN protection. If this step was already finished, the function does nothing.
+ */
+ private void waitForVisorToBeAvailableIfNeeded() {
+ if (!waitAvailableFinished) {
+ // Avoid having multiple simultaneous procedures.
+ if (waitingSubscription != null) {
+ waitingSubscription.dispose();
+ }
+
+ // Check if the local visor is not running. If true, continue to the next step.
+ if (!Skywiremob.isVisorStarting() && !Skywiremob.isVisorRunning()) {
+ waitAvailableFinished = true;
+ checkInternetConnectionIfNeeded(true);
+ } else {
+ // Update the state.
+ if (eventsSubject.getValue() != VPNStates.WAITING_PREVIOUS_INSTANCE_STOP) {
+ Skywiremob.printString("WAITING FOR THE PREVIOUS INSTANCE TO BE FULLY STOPPED");
+ eventsSubject.onNext(VPNStates.WAITING_PREVIOUS_INSTANCE_STOP);
+ }
+
+ // Retry after a delay.
+ waitingSubscription = Observable.just(0).delay(1000, TimeUnit.MILLISECONDS)
+ .subscribeOn(Schedulers.newThread())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(val -> waitForVisorToBeAvailableIfNeeded());
+ }
+ }
+ }
+
+ /**
+ * Waits until there is connection via internet to at least one of the testing URLs set in the
+ * globals class. After that, goes to the next step for starting the VPN protection. If this
+ * step was already finished, the function does nothing.
+ * @param firstTry True if the function is not being called automatically by the function
+ * itself, to retry the operation.
+ */
+ private void checkInternetConnectionIfNeeded(boolean firstTry) {
+ if (!waitNetworkFinished) {
+ Skywiremob.printString("CHECKING CONNECTION");
+
+ // Update the state.
+ if (firstTry) {
+ eventsSubject.onNext(VPNStates.CHECKING_CONNECTIVITY);
+ }
+
+ // Avoid having multiple simultaneous procedures.
+ if (waitingSubscription != null) {
+ waitingSubscription.dispose();
+ }
+
+ // Check if there is connection.
+ waitingSubscription = HelperFunctions.checkInternetConnectivity(firstTry)
+ .subscribeOn(Schedulers.newThread())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(hasInternetConnection -> {
+ if (hasInternetConnection) {
+ // Go to the next step.
+ waitNetworkFinished = true;
+ startVisorIfNeeded();
+ } else {
+ eventsSubject.onNext(VPNStates.WAITING_FOR_CONNECTIVITY);
+ waitingSubscription.dispose();
+
+ // Retry after a delay.
+ waitingSubscription = Observable.just(0).delay(1000, TimeUnit.MILLISECONDS)
+ .subscribeOn(Schedulers.newThread())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(val -> checkInternetConnectionIfNeeded(false));
+ }
+ });
+ }
+ }
+
+ /**
+ * Starts the local visor. After that, goes to the next step for starting the VPN protection.
+ * If this step was already started, the function does nothing.
+ */
+ private void startVisorIfNeeded() {
+ if (visor == null) {
+ Skywiremob.printString("STARTING VISOR");
+
+ // Create the instance for managing the local visor.
+ visor = new VisorRunnable();
+
+ if (waitingSubscription != null) {
+ waitingSubscription.dispose();
+ }
+
+ // Start the local visor and listen to the state changes.
+ waitingSubscription = visor.runVisor()
+ .subscribeOn(Schedulers.newThread())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(state -> {
+ eventsSubject.onNext(state);
+
+ // Create an observable which stops the operation if there is no progress after
+ // some time. The observable is reset after each state change.
+ if (visorTimeoutSubscription != null) {
+ visorTimeoutSubscription.dispose();
+ }
+ visorTimeoutSubscription = Observable.just(0).delay(45000, TimeUnit.MILLISECONDS)
+ .subscribeOn(Schedulers.newThread())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(val -> {
+ // Cancel the operation.
+ HelperFunctions.logError("VPN service", "Timeout preparing the visor.");
+ putInErrorState(App.getContext().getString(R.string.vpn_timeout_error));
+ });
+ }, err -> {
+ // Report the error.
+ if (visorTimeoutSubscription != null) {
+ visorTimeoutSubscription.dispose();
+ }
+ putInErrorState(err.getLocalizedMessage());
+ }, () -> {
+ // Go to the next step.
+ visorTimeoutSubscription.dispose();
+ startConnection();
+ });
+ }
+ }
+
+ /**
+ * Starts the VPN connection, which finishes making the VPN protection functional.
+ */
+ private void startConnection() {
+ if (vpnConnection == null) {
+ // Create the instance for managing the connection.
+ vpnConnection = new SkywireVPNConnection(visor, vpnInterface);
+
+ waitingSubscription.dispose();
+
+ // Make the connection work. Also, check the state changes.
+ waitingSubscription = vpnConnection.getObservable()
+ .subscribeOn(Schedulers.newThread())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(
+ val -> {
+ // Inform the state changes.
+ eventsSubject.onNext(val);
+ }, err -> {
+ // Close the connection (this does not means that the network
+ // will be unblocked) and inform about the error.
+ putInErrorState(err.getLocalizedMessage());
+ }, () -> {
+ // This event is not expected, but it would mean that the vpn connection
+ // is not longer active.
+ HelperFunctions.logError("VPN connection ended unexpectedly", "VPN connection ended unexpectedly");
+ disconnect();
+ }
+ );
+ }
+ }
+
+ /**
+ * Reverts all the steps made by this class, which means closing the connection and stopping
+ * the visor. If the network connections were blocked, that does not change, as this function
+ * does not make changes to the VPN work interface. Calling this function again after the
+ * first call does nothing.
+ */
+ public void disconnect() {
+ if (!disconnectionStarted) {
+ disconnectionStarted = true;
+
+ Skywiremob.printString("DISCONNECTING VPN RUNNABLE");
+
+ // Inform the new state.
+ eventsSubject.onNext(VPNStates.DISCONNECTING);
+
+ // Remove the subscriptions and close the vpn connection.
+ if (waitingSubscription != null) {
+ waitingSubscription.dispose();
+ }
+ if (visorTimeoutSubscription != null) {
+ visorTimeoutSubscription.dispose();
+ }
+ if (this.vpnConnection != null) {
+ this.vpnConnection.close();
+ }
+
+ // Stop the visor in another thread.
+ Observable.create((ObservableOnSubscribe) emitter -> {
+ if (visor != null) {
+ visor.startStoppingVisor();
+ }
+ emitter.onComplete();
+ }).subscribeOn(Schedulers.newThread()).subscribe(val -> {});
+
+ // Wait until the visor is completely stopped. 2 consecutive checks must be passed,
+ // to avoid a very unlikely but possible race condition.
+ Observable.timer(100, TimeUnit.MILLISECONDS).repeatUntil(() -> {
+ if (!Skywiremob.isVisorStarting() && !Skywiremob.isVisorRunning()) {
+ if (disconnectionVerifications == 2) {
+ return true;
+ } else {
+ disconnectionVerifications += 1;
+ }
+ } else {
+ if (disconnectionVerifications != 0) {
+ if (visor != null) {
+ visor.startStoppingVisor();
+ }
+ }
+
+ disconnectionVerifications = 0;
+ }
+
+ return false;
+ })
+ .subscribeOn(Schedulers.newThread())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(val -> {}, err -> {}, () -> eventsSubject.onNext(VPNStates.DISCONNECTED));
+ }
+ }
+
+ /**
+ * Informs about an error and closes the VPN connection.
+ * @param errorMsg Msg string of the error.
+ */
+ private void putInErrorState(String errorMsg) {
+ lastErrorMsg = errorMsg;
+
+ // If the network is already blocked and the kill switch is active, inform that the
+ // current error will close the VPN connection but the network will still be blocked until
+ // the user stops the service manually. That behavior is not managed by this class.
+ if (!vpnInterface.alreadyConfigured() || !VPNGeneralPersistentData.getKillSwitchActivated()) {
+ eventsSubject.onNext(VPNStates.ERROR);
+ } else {
+ eventsSubject.onNext(VPNStates.BLOCKING_ERROR);
+ }
+
+ disconnect();
+ }
+
+ /**
+ * Returns the msg of the last error detected by the current instance.
+ */
+ public String getLastErrorMsg() {
+ return lastErrorMsg;
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/vpn/VPNServersPersistentData.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/vpn/VPNServersPersistentData.java
new file mode 100644
index 0000000000..3aaf0d4fc9
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/vpn/VPNServersPersistentData.java
@@ -0,0 +1,322 @@
+package com.skywire.skycoin.vpn.vpn;
+
+import android.content.SharedPreferences;
+
+import androidx.preference.PreferenceManager;
+
+import com.google.gson.Gson;
+import com.google.gson.reflect.TypeToken;
+import com.skywire.skycoin.vpn.App;
+import com.skywire.skycoin.vpn.activities.servers.VpnServerForList;
+import com.skywire.skycoin.vpn.objects.LocalServerData;
+import com.skywire.skycoin.vpn.objects.ManualVpnServerData;
+import com.skywire.skycoin.vpn.objects.ServerFlags;
+
+import java.lang.reflect.Type;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.Date;
+import java.util.HashMap;
+
+import io.reactivex.rxjava3.core.Observable;
+import io.reactivex.rxjava3.subjects.ReplaySubject;
+
+/**
+ * Helper class for saving and getting data related to the VPN servers to and from the
+ * persistent storage.
+ */
+public class VPNServersPersistentData {
+ /**
+ * Singleton instance.
+ */
+ private static final VPNServersPersistentData instance = new VPNServersPersistentData();
+ /**
+ * Gets the singleton for using the class.
+ */
+ public static VPNServersPersistentData getInstance() { return instance; }
+
+ private final int maxHistoryElements = 30;
+
+ // Keys for persistent storage.
+ private final String CURRENT_SERVER_PK = "serverPK";
+ private final String SERVER_LIST = "serverList";
+
+ private SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(App.getContext());
+
+ private String currentServerPk;
+ private HashMap serversMap;
+
+ private ReplaySubject currentServerSubject = ReplaySubject.createWithSize(1);
+ private ReplaySubject> historySubject = ReplaySubject.createWithSize(1);
+ private ReplaySubject> favoritesSubject = ReplaySubject.createWithSize(1);
+ private ReplaySubject> blockedSubject = ReplaySubject.createWithSize(1);
+
+ private VPNServersPersistentData() {
+ currentServerPk = settings.getString(CURRENT_SERVER_PK, "");
+
+ String serversList = settings.getString(SERVER_LIST, null);
+ if (serversList != null) {
+ Gson gson = new Gson();
+ Type mapType = new TypeToken>() {}.getType();
+ serversMap = gson.fromJson(serversList, mapType);
+
+ LocalServerData currentServer = this.serversMap.get(currentServerPk);
+ this.currentServerSubject.onNext(currentServer != null ? currentServer : new LocalServerData());
+ } else {
+ serversMap = new HashMap<>();
+ this.currentServerSubject.onNext(new LocalServerData());
+ }
+
+ this.launchListEvents();
+ }
+
+ public LocalServerData getCurrentServer() {
+ return serversMap.get(this.currentServerPk);
+ }
+
+ public Observable getCurrentServerObservable() {
+ return currentServerSubject.hide();
+ }
+
+ public Observable> history() {
+ return this.historySubject.hide();
+ }
+
+ public Observable> favorites() {
+ return this.favoritesSubject.hide();
+ }
+
+ public Observable> blocked() {
+ return this.blockedSubject.hide();
+ }
+
+ public LocalServerData getSavedVersion(String pk) {
+ return this.serversMap.get(pk);
+ }
+
+ public void updateFromDiscovery(ArrayList serverList) {
+ for (VpnServerForList server : serverList) {
+ if (this.serversMap.containsKey(server.pk)) {
+ LocalServerData savedServer = this.serversMap.get(server.pk);
+
+ savedServer.countryCode = server.countryCode;
+ savedServer.name = server.name;
+ savedServer.location = server.location;
+ savedServer.note = server.note;
+ }
+ }
+
+ this.saveData();
+ }
+
+ public void updateServer(LocalServerData server) {
+ this.serversMap.put(server.pk, server);
+ this.cleanServers();
+ this.saveData();
+ }
+
+ public LocalServerData processFromList(VpnServerForList newServer) {
+ LocalServerData retrievedServer = this.serversMap.get(newServer.pk);
+ if (retrievedServer != null) {
+ retrievedServer.countryCode = newServer.countryCode;
+ retrievedServer.name = newServer.name;
+ retrievedServer.location = newServer.location;
+ retrievedServer.note = newServer.note;
+
+ this.saveData();
+
+ return retrievedServer;
+ }
+
+ LocalServerData response = new LocalServerData();
+ response.countryCode = newServer.countryCode;
+ response.name = newServer.name;
+ response.customName = null;
+ response.pk = newServer.pk;
+ response.lastUsed = new Date(0);
+ response.inHistory = false;
+ response.flag = ServerFlags.None;
+ response.location = newServer.location;
+ response.personalNote = null;
+ response.note = newServer.note;
+ response.enteredManually = false;
+ response.password = null;
+
+ return response;
+ }
+
+ public LocalServerData processFromManual(ManualVpnServerData newServer) {
+ LocalServerData retrievedServer = this.serversMap.get(newServer.pk);
+ if (retrievedServer != null) {
+ retrievedServer.password = newServer.password;
+ retrievedServer.customName = newServer.name;
+ retrievedServer.personalNote = newServer.note;
+ retrievedServer.enteredManually = true;
+
+ this.saveData();
+
+ return retrievedServer;
+ }
+
+ LocalServerData response = new LocalServerData();
+ response.countryCode = "zz";
+ response.name = null;
+ response.customName = newServer.name;
+ response.pk = newServer.pk;
+ response.lastUsed = new Date(0);
+ response.inHistory = false;
+ response.flag = ServerFlags.None;
+ response.location = null;
+ response.personalNote = newServer.note;
+ response.note = null;
+ response.enteredManually = true;
+ response.password = newServer.password;
+
+ return response;
+ }
+
+ public void changeFlag(LocalServerData server, ServerFlags flag) {
+ LocalServerData retrievedServer = this.serversMap.get(server.pk);
+ if (retrievedServer != null) {
+ server = retrievedServer;
+ }
+
+ if (server.flag == flag) {
+ return;
+ }
+ server.flag = flag;
+
+ if (!this.serversMap.containsKey(server.pk)) {
+ this.serversMap.put(server.pk, server);
+ }
+
+ this.cleanServers();
+ this.saveData();
+ }
+
+ public void removePassword(String pk) {
+ LocalServerData retrievedServer = this.serversMap.get(pk);
+ if (retrievedServer == null || retrievedServer.password == null || retrievedServer.password.equals("")) {
+ return;
+ }
+
+ retrievedServer.password = null;
+ this.cleanServers();
+ this.saveData();
+ }
+
+ public void removeFromHistory(String pk) {
+ LocalServerData retrievedServer = this.serversMap.get(pk);
+ if (retrievedServer == null || !retrievedServer.inHistory) {
+ return;
+ }
+
+ retrievedServer.inHistory = false;
+ this.cleanServers();
+ this.saveData();
+ }
+
+ public void modifyCurrentServer(LocalServerData newServer) {
+ if (!this.serversMap.containsKey(newServer.pk)) {
+ this.serversMap.put(newServer.pk, newServer);
+ }
+
+ this.currentServerPk = newServer.pk;
+
+ LocalServerData currentServer = this.serversMap.get(currentServerPk);
+ this.currentServerSubject.onNext(currentServer);
+
+ this.cleanServers();
+ this.saveData();
+ }
+
+ public void updateHistory() {
+ LocalServerData currentServer = this.serversMap.get(currentServerPk);
+ // This should not happen.
+ if (currentServer == null) {
+ return;
+ }
+
+ currentServer.lastUsed = new Date();
+ currentServer.inHistory = true;
+
+ // Make a list with the servers in the history and sort it by usage date.
+ ArrayList historyList = new ArrayList();
+ for (LocalServerData server : serversMap.values()) {
+ if (server.inHistory) {
+ historyList.add(server);
+ }
+ }
+ Comparator comparator = (a, b) -> (int)((b.lastUsed.getTime() - a.lastUsed.getTime()) / 1000);
+ Collections.sort(historyList, comparator);
+
+ // Remove from the history the old servers.
+ int historyElementsFound = 0;
+ for (LocalServerData server : historyList) {
+ if (historyElementsFound < this.maxHistoryElements) {
+ historyElementsFound += 1;
+ } else {
+ server.inHistory = false;
+ }
+ }
+
+ this.cleanServers();
+ this.saveData();
+ }
+
+ private void cleanServers() {
+ ArrayList unneeded = new ArrayList();
+ for (LocalServerData server : serversMap.values()) {
+ if (
+ !server.inHistory &&
+ server.flag == ServerFlags.None &&
+ !server.pk.equals(this.currentServerPk) &&
+ (server.customName == null || server.customName.equals("")) &&
+ (server.personalNote == null || server.personalNote.equals(""))
+ ) {
+ unneeded.add(server.pk);
+ }
+ }
+
+ for (String pk : unneeded) {
+ this.serversMap.remove(pk);
+ }
+ }
+
+ private void saveData() {
+ Gson gson = new Gson();
+ String servers = gson.toJson(serversMap);
+
+ settings
+ .edit()
+ .putString(SERVER_LIST, servers)
+ .putString(CURRENT_SERVER_PK, currentServerPk)
+ .apply();
+
+ this.launchListEvents();
+ }
+
+ private void launchListEvents() {
+ ArrayList history = new ArrayList();
+ ArrayList favorites = new ArrayList();
+ ArrayList blocked = new ArrayList();
+
+ for (LocalServerData server : serversMap.values()) {
+ if (server.inHistory) {
+ history.add(server);
+ }
+ if (server.flag == ServerFlags.Favorite) {
+ favorites.add(server);
+ }
+ if (server.flag == ServerFlags.Blocked) {
+ blocked.add(server);
+ }
+ }
+
+ this.historySubject.onNext(history);
+ this.favoritesSubject.onNext(favorites);
+ this.blockedSubject.onNext(blocked);
+ this.currentServerSubject.onNext(currentServerSubject.getValue());
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/vpn/VPNStates.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/vpn/VPNStates.java
new file mode 100644
index 0000000000..dd50fbc1c3
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/vpn/VPNStates.java
@@ -0,0 +1,267 @@
+package com.skywire.skycoin.vpn.vpn;
+
+import com.skywire.skycoin.vpn.R;
+
+import java.util.HashMap;
+
+/**
+ * Helper class with the possible states of the VPN service.
+ *
+ * The states are numeric constants, similar to how http status codes work, to be able to identify
+ * state groups just by numeric ranges. The ranges are:
+ *
+ * State < 10: the service is not running.
+ *
+ * 10 =< State < 100: The VPN connection is being prepared.
+ *
+ * 100 =< State < 150: The VPN connection has been made and the internet connectivity should
+ * be protected and working.
+ *
+ * 150 =< State < 200: Temporal errors with the VPN connection.
+ *
+ * 200 =< State < 300: Closing the VPN connection/service.
+ *
+ * 300 =< State < 400: VPN connection/service closed.
+ *
+ * State >= 400 : An error occurred.
+ */
+public enum VPNStates {
+ /**
+ * The service is off.
+ */
+ OFF(1),
+ /**
+ * Starting the service.
+ */
+ STARTING(10),
+ /**
+ * Waiting for the visor to be completely stopped before starting it again.
+ */
+ WAITING_PREVIOUS_INSTANCE_STOP(12),
+ /**
+ * Checking for the first time if the device has internet connectivity.
+ */
+ CHECKING_CONNECTIVITY(15),
+ /**
+ * No internet connectivity was found and the service is checking again periodically.
+ */
+ WAITING_FOR_CONNECTIVITY(16),
+ /**
+ * Starting the Skywire visor.
+ */
+ PREPARING_VISOR(20),
+ /**
+ * Starting the VPN client, which is part of Skywiremob and running as part of the visor.
+ */
+ PREPARING_VPN_CLIENT(30),
+ /**
+ * Making final preparations for the VPN client, like performing the handshake and start serving.
+ */
+ FINAL_PREPARATIONS_FOR_VISOR(35),
+ /**
+ * The visor and VPN client are ready. Preparations may be needed in the app side.
+ */
+ VISOR_READY(40),
+ /**
+ * The VPN connection has been fully established and secure internet connectivity should
+ * be available.
+ */
+ CONNECTED(100),
+ /**
+ * There was an error with the VPN connection and it is being restored automatically.
+ */
+ RESTORING_VPN(150),
+ /**
+ * There was an error and the whole VPN service is being restored automatically.
+ */
+ RESTORING_SERVICE(155),
+ /**
+ * The VPN service is being stopped.
+ */
+ DISCONNECTING(200),
+ /**
+ * The VPN service has been stopped.
+ */
+ DISCONNECTED(300),
+ /**
+ * There has been an error, the VPN connection is not available and the service is
+ * being stopped.
+ */
+ ERROR(400),
+ /**
+ * There has been and error and the VPN connection is not available. The network will remain
+ * blocked until the user stops the service manually.
+ */
+ BLOCKING_ERROR(410);
+
+ /**
+ * Allows to easily get the value related to an specific number.
+ */
+ private static HashMap numericValues;
+
+ // Initializes the enum and saves the value.
+ private final int val;
+ VPNStates(int val) {
+ this.val = val;
+ }
+
+ /**
+ * Gets the associated numeric value.
+ */
+ public int val() {
+ return val;
+ }
+
+ /**
+ * Class with details about the state of the VPN service.
+ */
+ public static class StateInfo {
+ /**
+ * Current state of the service.
+ */
+ public final VPNStates state;
+ /**
+ * If the service was started by the OS, which means that the OS is responsible for
+ * stopping it.
+ */
+ public final boolean startedByTheSystem;
+ /**
+ * If the user already requested the service to be stopped.
+ */
+ public final boolean stopRequested;
+
+ public StateInfo(VPNStates state, boolean startedByTheSystem, boolean stopRequested) {
+ this.state = state;
+ this.startedByTheSystem = startedByTheSystem;
+ this.stopRequested = stopRequested;
+ }
+ }
+
+ /**
+ * Allows to get the resource ID of the string with the title for a state of the
+ * VPN service. If no resource is found for the state, -1 is returned.
+ */
+ public static int getTitleForState(VPNStates state) {
+ if (state == OFF) {
+ return R.string.vpn_state_disconnected;
+ } else if (state == STARTING) {
+ return R.string.vpn_state_connecting;
+ } else if (state == WAITING_PREVIOUS_INSTANCE_STOP) {
+ return R.string.vpn_state_connecting;
+ } else if (state == CHECKING_CONNECTIVITY) {
+ return R.string.vpn_state_connecting;
+ } else if (state == WAITING_FOR_CONNECTIVITY) {
+ return R.string.vpn_state_connecting;
+ } else if (state == PREPARING_VISOR) {
+ return R.string.vpn_state_connecting;
+ } else if (state == PREPARING_VPN_CLIENT) {
+ return R.string.vpn_state_connecting;
+ } else if (state == FINAL_PREPARATIONS_FOR_VISOR) {
+ return R.string.vpn_state_connecting;
+ } else if (state == VISOR_READY) {
+ return R.string.vpn_state_connecting;
+ } else if (state == CONNECTED) {
+ return R.string.vpn_state_connected;
+ } else if (state == RESTORING_VPN) {
+ return R.string.vpn_state_restarting;
+ } else if (state == RESTORING_SERVICE) {
+ return R.string.vpn_state_restarting;
+ } else if (state == DISCONNECTING) {
+ return R.string.vpn_state_disconnecting;
+ } else if (state == DISCONNECTED) {
+ return R.string.vpn_state_disconnected;
+ } else if (state == ERROR) {
+ return R.string.vpn_state_error;
+ } else if (state == BLOCKING_ERROR) {
+ return R.string.vpn_state_error;
+ }
+
+ return -1;
+ }
+
+ /**
+ * Allows to get the resource ID of the color for the title of a state of the
+ * VPN service. If no resource is found for the title, red is returned.
+ */
+ public static int getColorForStateTitle(int titleResource) {
+ if (titleResource == R.string.vpn_state_disconnected) {
+ return R.color.red;
+ } else if (titleResource == R.string.vpn_state_connecting) {
+ return R.color.yellow;
+ } else if (titleResource == R.string.vpn_state_connected) {
+ return R.color.green;
+ } else if (titleResource == R.string.vpn_state_restarting) {
+ return R.color.yellow;
+ } else if (titleResource == R.string.vpn_state_disconnecting) {
+ return R.color.yellow;
+ } else if (titleResource == R.string.vpn_state_error) {
+ return R.color.red;
+ }
+
+ return R.color.red;
+ }
+
+ /**
+ * Allows to get the resource ID of the string with the description of a state of the
+ * VPN service. If no resource is found for the state, -1 is returned.
+ */
+ public static int getDescriptionForState(VPNStates state) {
+ if (state == OFF) {
+ return R.string.vpn_state_details_off;
+ } else if (state == STARTING) {
+ return R.string.vpn_state_details_initializing;
+ } else if (state == WAITING_PREVIOUS_INSTANCE_STOP) {
+ return R.string.vpn_state_details_waiting_previous_instance_stop;
+ } else if (state == CHECKING_CONNECTIVITY) {
+ return R.string.vpn_state_details_checking_connectivity;
+ } else if (state == WAITING_FOR_CONNECTIVITY) {
+ return R.string.vpn_state_details_waiting_connectivity;
+ } else if (state == PREPARING_VISOR) {
+ return R.string.vpn_state_details_starting_visor;
+ } else if (state == PREPARING_VPN_CLIENT) {
+ return R.string.vpn_state_details_starting_vpn_app;
+ } else if (state == FINAL_PREPARATIONS_FOR_VISOR) {
+ return R.string.vpn_state_details_additional_visor_initializations;
+ } else if (state == VISOR_READY) {
+ return R.string.vpn_state_details_connecting;
+ } else if (state == CONNECTED) {
+ return R.string.vpn_state_details_connected;
+ } else if (state == RESTORING_VPN) {
+ return R.string.vpn_state_details_restoring;
+ } else if (state == RESTORING_SERVICE) {
+ return R.string.vpn_state_details_restoring_service;
+ } else if (state == DISCONNECTING) {
+ return R.string.vpn_state_details_disconnecting;
+ } else if (state == DISCONNECTED) {
+ return R.string.vpn_state_details_disconnected;
+ } else if (state == ERROR) {
+ return R.string.vpn_state_details_error;
+ } else if (state == BLOCKING_ERROR) {
+ return R.string.vpn_state_details_blocking_error;
+ }
+
+ return -1;
+ }
+
+ /**
+ * Allows to get the value associated with a numeric value. If there is no value for the
+ * provided number, the OFF state is returned.
+ * @param value Value to check.
+ */
+ public static VPNStates valueOf(int value) {
+ // Initialize the map for getting the values, if needed.
+ if (numericValues == null) {
+ numericValues = new HashMap<>();
+
+ for (VPNStates v : VPNStates.values()) {
+ numericValues.put(v.val(), v);
+ }
+ }
+
+ if (!numericValues.containsKey(value)) {
+ return OFF;
+ }
+
+ return numericValues.get(value);
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/vpn/VPNWorkInterface.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/vpn/VPNWorkInterface.java
new file mode 100644
index 0000000000..d611cb0dcb
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/vpn/VPNWorkInterface.java
@@ -0,0 +1,242 @@
+package com.skywire.skycoin.vpn.vpn;
+
+import android.net.VpnService;
+import android.os.ParcelFileDescriptor;
+
+import com.skywire.skycoin.vpn.App;
+import com.skywire.skycoin.vpn.helpers.Globals;
+import com.skywire.skycoin.vpn.helpers.HelperFunctions;
+import com.skywire.skycoin.vpn.R;
+
+import java.io.Closeable;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.util.HashSet;
+
+import skywiremob.Skywiremob;
+
+/**
+ * Object used for starting the VPN protection and sending/receiving data. After created, to start
+ * the VPN protection the object must be configured.
+ */
+public class VPNWorkInterface implements Closeable {
+ /**
+ * Modes in which the VPN interface can be configured.
+ */
+ public enum Modes {
+ /**
+ * Used just for blocking the network connectivity before configuring the visor, to avoid
+ * data leaks.
+ */
+ BLOCKING,
+ /**
+ * Normal mode for sending and receiving data using the VPN protection.
+ */
+ WORKING,
+ /**
+ * Mode used just for configuring a VPN interface and closing it immediately after that, to
+ * force the OS to disable the VPN protection, due to a bug in old Android versions.
+ */
+ DELETING,
+ }
+
+ /**
+ * Current VPN service instance.
+ */
+ private final VpnService service;
+ /**
+ * Current VPN communication object, created by the system.
+ */
+ private ParcelFileDescriptor vpnInterface = null;
+ /**
+ * Input stream to be used with the current communication object created by the system.
+ */
+ private FileInputStream inStream = null;
+ /**
+ * Output stream to be used with the current communication object created by the system.
+ */
+ private FileOutputStream outStream = null;
+
+ public VPNWorkInterface(VpnService service) {
+ this.service = service;
+ }
+
+ /**
+ * Terminates the VPN protections and cleans the used resources.
+ */
+ @Override
+ public void close() {
+ if (vpnInterface != null) {
+ try {
+ vpnInterface.close();
+ vpnInterface = null;
+ } catch (IOException e) {
+ HelperFunctions.logError("Unable to close interface", e);
+ }
+
+ cleanInputStream();
+ cleanOutputStream();
+ }
+ }
+
+ /**
+ * Checks if the interface has already been configured for the first time.
+ */
+ public boolean alreadyConfigured() {
+ return vpnInterface != null;
+ }
+
+ /**
+ * Configures and activates the VPN interface. After calling this function the OS starts
+ * routing the data using the interface, so all network connections will be blocked if the VPN
+ * is not working properly. This method can be called several times, which allows to restore
+ * the connection in case of errors or change the mode.
+ * @param mode Mode in which the VPN interface will be configured.
+ */
+ public void configure(Modes mode) throws Exception {
+ // Save a reference to the current interface, if any, to close it after creating the
+ // new one, to avoid leaking data while the new interface is created.
+ ParcelFileDescriptor oldVpnInterface = null;
+ if (vpnInterface != null) {
+ oldVpnInterface = vpnInterface;
+ }
+
+ // Create and configure a builder.
+ VpnService.Builder builder = service.new Builder();
+ builder.setMtu((short)Skywiremob.getMTU());
+ if (mode == Modes.WORKING) {
+ Skywiremob.printString("TUN IP: " + Skywiremob.tunip());
+ // Get the address from the visor.
+ builder.addAddress(Skywiremob.tunip(), (int) Skywiremob.getTUNIPPrefix());
+ } else {
+ // Use an address for blocking all connections.
+ builder.addAddress("8.8.8.8", 32);
+ }
+
+ // Use the custom DNS server, if any.
+ String dnsServer = VPNGeneralPersistentData.getCustomDns();
+ if (dnsServer != null && dnsServer.trim().length() > 0) {
+ builder.addDnsServer(dnsServer.trim());
+ }
+
+ builder.addRoute("0.0.0.0", 0);
+ // This makes the streams created with the interface synchronous, so that the data can be
+ // read blocking an independent thread in an efficient way.
+ builder.setBlocking(true);
+
+ // Allows to know if there was an error allowing or disallowing apps.
+ boolean errorIgnoringApps = false;
+
+ if (mode == Modes.WORKING || mode == Modes.BLOCKING) {
+ String upperCaseAppPackage = App.getContext().getPackageName().toUpperCase();
+ Globals.AppFilteringModes appsSelectionMode = VPNGeneralPersistentData.getAppsSelectionMode();
+
+ if (appsSelectionMode != Globals.AppFilteringModes.PROTECT_ALL) {
+ // Get the package name of all the apps selected by the user which are
+ // currently installed.
+ for (String packageName : HelperFunctions.filterAvailableApps(VPNGeneralPersistentData.getAppList(new HashSet<>()))) {
+ try {
+ if (appsSelectionMode == Globals.AppFilteringModes.PROTECT_SELECTED) {
+ // Protect all selected apps, but ignore this app.
+ if (!upperCaseAppPackage.equals(packageName.toUpperCase())) {
+ builder.addAllowedApplication(packageName);
+ }
+ } else {
+ // Avoid protecting the selected apps, but ignore this app.
+ if (!upperCaseAppPackage.equals(packageName.toUpperCase())) {
+ builder.addDisallowedApplication(packageName);
+ }
+ }
+ } catch (Exception e) {
+ errorIgnoringApps = true;
+ HelperFunctions.logError("Unable to add " + packageName + " to the VPN service", e);
+ break;
+ }
+ }
+ }
+
+ // Make the VPN protection ignore this app, as free access is needed for configuring
+ // the visor, specially in case of errors, when it is needed to restart components.
+ if (!errorIgnoringApps) {
+ try {
+ if (appsSelectionMode != Globals.AppFilteringModes.PROTECT_SELECTED) {
+ builder.addDisallowedApplication(App.getContext().getPackageName());
+ }
+ } catch (Exception e) {
+ errorIgnoringApps = true;
+ HelperFunctions.logError("Unable to add VPN app rule to the VPN service", e);
+ }
+ }
+ } else {
+ // Block this app only, to be able to avoid a bug in old Android versions.
+ builder.addAllowedApplication(App.getContext().getPackageName());
+ }
+
+ if (errorIgnoringApps) {
+ throw new Exception(App.getContext().getString(R.string.vpn_service_configuring_app_rules_error));
+ }
+
+ // Create the new interface using the builder.
+ builder.setConfigureIntent(HelperFunctions.getOpenAppPendingIntent());
+ synchronized (service) {
+ vpnInterface = builder.establish();
+ }
+ Skywiremob.printString("New interface: " + vpnInterface);
+
+ // Close the previous interface and streams, if any.
+ if (oldVpnInterface != null) {
+ oldVpnInterface.close();
+ }
+ cleanInputStream();
+ cleanOutputStream();
+ }
+
+ /**
+ * Gets the input stream for reading the packages from the system that must be sent using the
+ * VPN. NOTE: if the interface is closed or configured again, the stream is closed.
+ */
+ public FileInputStream getInputStream() {
+ if (inStream == null) {
+ inStream = new FileInputStream(vpnInterface.getFileDescriptor());
+ }
+ return inStream;
+ }
+
+ /**
+ * Gets the output stream that must be used for sending to the system the packages received via
+ * the VPN. NOTE: if the interface is closed or configured again, the stream is closed.
+ */
+ public FileOutputStream getOutputStream() {
+ if (outStream == null) {
+ outStream = new FileOutputStream(vpnInterface.getFileDescriptor());
+ }
+ return outStream;
+ }
+
+ /**
+ * Cleans and removes the current input stream, if any.
+ */
+ private void cleanInputStream() {
+ if (inStream != null) {
+ try {
+ inStream.close();
+ } catch (Exception e) { }
+
+ inStream = null;
+ }
+ }
+
+ /**
+ * Cleans and removes the current output stream, if any.
+ */
+ private void cleanOutputStream() {
+ if (outStream != null) {
+ try {
+ outStream.close();
+ } catch (Exception e) { }
+
+ outStream = null;
+ }
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/vpn/VisorRunnable.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/vpn/VisorRunnable.java
new file mode 100644
index 0000000000..a5614e5337
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/vpn/VisorRunnable.java
@@ -0,0 +1,218 @@
+package com.skywire.skycoin.vpn.vpn;
+
+import com.skywire.skycoin.vpn.App;
+import com.skywire.skycoin.vpn.R;
+import com.skywire.skycoin.vpn.helpers.HelperFunctions;
+import com.skywire.skycoin.vpn.objects.LocalServerData;
+
+import io.reactivex.rxjava3.core.Observable;
+import io.reactivex.rxjava3.core.ObservableEmitter;
+import io.reactivex.rxjava3.core.ObservableOnSubscribe;
+import skywiremob.Skywiremob;
+
+/**
+ * Allows to easily control the starting and stopping procedures of the the visor and VPN client
+ * included in Skywiremob.
+ */
+public class VisorRunnable {
+ /**
+ * If Skywiremob.prepareVPNClient has already been called without errors.
+ */
+ private boolean vpnClientStarted = false;
+ /**
+ * If Skywiremob.startListeningUDP() has already been called without errors.
+ */
+ private boolean listeningUdp = false;
+ /**
+ * If true, the initialization failed because the server refused the password.
+ */
+ private boolean passwordFailed = false;
+
+ /**
+ * Allows to know if the initialization failed because the server refused the password.
+ */
+ public boolean getIfPasswordFailed() {
+ return passwordFailed;
+ }
+
+ /**
+ * Starts stopping the visor. It returns before the visor has been completely stopped.
+ */
+ public void startStoppingVisor() {
+ skywiremob.Error err = Skywiremob.stopVisor();
+ if (err.getCode() != Skywiremob.ErrCodeNoError) {
+ Skywiremob.printString(gerErrorMsg(err));
+ HelperFunctions.showToast(gerErrorMsg(err), false);
+ }
+ Skywiremob.printString("Visor stopped");
+ }
+
+ /**
+ * Stops the VPN client without stopping the visor.
+ */
+ public void stopVpnConnection() {
+ if (vpnClientStarted) {
+ Skywiremob.stopVPNClient();
+ vpnClientStarted = false;
+ }
+ if (listeningUdp) {
+ Skywiremob.stopListeningUDP();
+ listeningUdp = false;
+ }
+ Skywiremob.printString("VPN connection stopped");
+ }
+
+ /**
+ * Starts the Skywire visor.
+ * @return Observable that will emit the current state of the process, as variables defined in
+ * VPNStates, and will complete after starting the visor.
+ */
+ public Observable runVisor() {
+ return Observable.create((ObservableOnSubscribe) emitter -> {
+ if (emitter.isDisposed()) { return; }
+ emitter.onNext(VPNStates.PREPARING_VISOR);
+
+ // Start the visor if the emitter is still valid.
+ if (emitter.isDisposed()) { return; }
+ skywiremob.Error err = Skywiremob.prepareVisor();
+ if (err.getCode() != Skywiremob.ErrCodeNoError) {
+ HelperFunctions.logError("Visor startup procedure, code " + err.getCode(), gerErrorMsg(err));
+ if (emitter.isDisposed()) { return; }
+ emitter.onError(new Exception(gerErrorMsg(err)));
+ return;
+ }
+
+ // Block the thread while the visor is starting.
+ err = Skywiremob.waitVisorReady();
+ if (err.getCode() != Skywiremob.ErrCodeNoError) {
+ HelperFunctions.logError("Visor startup procedure, code " + err.getCode(), gerErrorMsg(err));
+ if (emitter.isDisposed()) { return; }
+ emitter.onError(new Exception(gerErrorMsg(err)));
+ return;
+ }
+
+ // Finish.
+ Skywiremob.printString("Prepared visor");
+ if (emitter.isDisposed()) { return; }
+ emitter.onNext(VPNStates.VISOR_READY);
+ emitter.onComplete();
+ });
+ }
+
+ /**
+ * Starts the VPN client. This function was made to be used inside an observable which emits
+ * the state of the VPN service.
+ * @param parentEmitter Emitter of the observable from which this function was called, to be
+ * able to emit the state changes.
+ */
+ public void runVpnClient(ObservableEmitter parentEmitter) throws Exception {
+ passwordFailed = false;
+
+ // Update the state.
+ if (parentEmitter.isDisposed()) { return; }
+ parentEmitter.onNext(VPNStates.PREPARING_VPN_CLIENT);
+
+ // Prepare the VPN client with the last saved public key and password.
+ if (parentEmitter.isDisposed()) { return; }
+ LocalServerData currentServer = VPNServersPersistentData.getInstance().getCurrentServer();
+ String savedPk = currentServer != null ? currentServer.pk : "";
+ String savedPassword = currentServer != null && currentServer.password != null ? currentServer.password : "";
+ skywiremob.Error err = Skywiremob.prepareVPNClient(savedPk, savedPassword);
+ if (err.getCode() != Skywiremob.ErrCodeNoError) {
+ throw new Exception(gerErrorMsg(err));
+ }
+ vpnClientStarted = true;
+ Skywiremob.printString("Prepared VPN client");
+ if (parentEmitter.isDisposed()) { return; }
+ parentEmitter.onNext(VPNStates.FINAL_PREPARATIONS_FOR_VISOR);
+
+ // Perform the handshake.
+ if (parentEmitter.isDisposed()) { return; }
+ err = Skywiremob.shakeHands();
+ if (err.getCode() != Skywiremob.ErrCodeNoError) {
+ // Check if the server refused the password.
+ if (err.getCode() == Skywiremob.ErrCodeHandshakeFailed && err.getError().toUpperCase().contains("4 (Forbidden)".toUpperCase())) {
+ passwordFailed = true;
+ }
+ throw new Exception(gerErrorMsg(err));
+ }
+
+ // Start listening.
+ if (parentEmitter.isDisposed()) { return; }
+ err = Skywiremob.startListeningUDP();
+ listeningUdp = true;
+ if (err.getCode() != Skywiremob.ErrCodeNoError) {
+ throw new Exception(gerErrorMsg(err));
+ }
+
+ // Start serving.
+ if (parentEmitter.isDisposed()) { return; }
+ err = Skywiremob.serveVPN();
+ if (err.getCode() != Skywiremob.ErrCodeNoError) {
+ throw new Exception(gerErrorMsg(err));
+ }
+ }
+
+ /**
+ * Gets the error string for an specific error returned by Skywiremob.
+ */
+ private static String gerErrorMsg(skywiremob.Error error) {
+ int resource = -1;
+
+ if (error.getCode() == Skywiremob.ErrCodeInvalidPK) {
+ resource = R.string.skywiremob_error_invalid_pk;
+ } else if (error.getCode() == Skywiremob.ErrCodeInvalidVisorConfig) {
+ resource = R.string.skywiremob_error_invalid_visor_config;
+ } else if (error.getCode() == Skywiremob.ErrCodeInvalidAddrResolverURL) {
+ resource = R.string.skywiremob_error_invalid_addr_resolver_url;
+ } else if (error.getCode() == Skywiremob.ErrCodeSTCPInitFailed) {
+ resource = R.string.skywiremob_error_stcp_init_failed;
+ } else if (error.getCode() == Skywiremob.ErrCodeSTCPRInitFailed) {
+ resource = R.string.skywiremob_error_stcpr_init_failed;
+ } else if (error.getCode() == Skywiremob.ErrCodeSUDPHInitFailed) {
+ resource = R.string.skywiremob_error_sudph_init_failed;
+ } else if (error.getCode() == Skywiremob.ErrCodeDmsgListenFailed) {
+ resource = R.string.skywiremob_error_dmsg_listen_failed;
+ } else if (error.getCode() == Skywiremob.ErrCodeTpDiscUnavailable) {
+ resource = R.string.skywiremob_error_tp_disc_unavailable;
+ } else if (error.getCode() == Skywiremob.ErrCodeFailedToStartRouter) {
+ resource = R.string.skywiremob_error_failed_to_start_router;
+ } else if (error.getCode() == Skywiremob.ErrCodeFailedToSetupHVGateway) {
+ resource = R.string.skywiremob_error_failed_to_setup_hv_gateway;
+ } else if (error.getCode() == Skywiremob.ErrCodeVisorNotRunning) {
+ resource = R.string.skywiremob_error_visor_not_running;
+ } else if (error.getCode() == Skywiremob.ErrCodeInvalidRemotePK) {
+ resource = R.string.skywiremob_error_invalid_remote_pk;
+ } else if (error.getCode() == Skywiremob.ErrCodeFailedToSaveTransport) {
+ resource = R.string.skywiremob_error_failed_to_save_transport;
+ } else if (error.getCode() == Skywiremob.ErrCodeVPNServerUnavailable) {
+ resource = R.string.skywiremob_error_vpn_server_unavailable;
+ } else if (error.getCode() == Skywiremob.ErrCodeVPNClientNotRunning) {
+ resource = R.string.skywiremob_error_vpn_client_not_running;
+ } else if (error.getCode() == Skywiremob.ErrCodeHandshakeFailed) {
+ if (error.getError().toUpperCase().contains("4 (Forbidden)".toUpperCase())) {
+ resource = R.string.skywiremob_error_wrong_password;
+ } else {
+ resource = R.string.skywiremob_error_handshake_failed;
+ }
+ } else if (error.getCode() == Skywiremob.ErrCodeInvalidAddr) {
+ resource = R.string.skywiremob_error_invalid_addr;
+ } else if (error.getCode() == Skywiremob.ErrCodeAlreadyListeningUDP) {
+ resource = R.string.skywiremob_error_already_listening_udp;
+ } else if (error.getCode() == Skywiremob.ErrCodeUDPListenFailed) {
+ resource = R.string.skywiremob_error_udp_listen_failed;
+ }
+
+ String response;
+ if (resource != -1) {
+ response = App.getContext().getString(resource);
+ } else {
+ response = error.getError();
+ if (response == null || response.trim().equals("")) {
+ response = App.getContext().getString(R.string.skywiremob_error_unknown);
+ }
+ }
+
+ return response;
+ }
+}
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/animator/anim_start_button.xml b/cmd/skywirevisormobile/android/app/src/main/res/animator/anim_start_button.xml
new file mode 100644
index 0000000000..582fa05dd9
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/animator/anim_start_button.xml
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/animator/anim_state.xml b/cmd/skywirevisormobile/android/app/src/main/res/animator/anim_state.xml
new file mode 100644
index 0000000000..103fd50373
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/animator/anim_state.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-hdpi/bronze_rating.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-hdpi/bronze_rating.png
new file mode 100644
index 0000000000..69db4edda4
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-hdpi/bronze_rating.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-hdpi/gold_rating.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-hdpi/gold_rating.png
new file mode 100644
index 0000000000..59c43cd713
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-hdpi/gold_rating.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-hdpi/modal_background_pattern.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-hdpi/modal_background_pattern.png
new file mode 100644
index 0000000000..33f259744b
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-hdpi/modal_background_pattern.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-hdpi/silver_rating.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-hdpi/silver_rating.png
new file mode 100644
index 0000000000..89a0a368f4
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-hdpi/silver_rating.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xhdpi/background_box1.9.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xhdpi/background_box1.9.png
new file mode 100644
index 0000000000..6acb77df29
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xhdpi/background_box1.9.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xhdpi/background_box2.9.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xhdpi/background_box2.9.png
new file mode 100644
index 0000000000..d8d214fe73
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xhdpi/background_box2.9.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xhdpi/background_box3.9.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xhdpi/background_box3.9.png
new file mode 100644
index 0000000000..8324a21529
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xhdpi/background_box3.9.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xhdpi/background_box4.9.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xhdpi/background_box4.9.png
new file mode 100644
index 0000000000..ced82888af
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xhdpi/background_box4.9.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xhdpi/background_box5.9.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xhdpi/background_box5.9.png
new file mode 100644
index 0000000000..6d1f223273
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xhdpi/background_box5.9.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xhdpi/box_pattern.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xhdpi/box_pattern.png
new file mode 100644
index 0000000000..1a838ed167
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xhdpi/box_pattern.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xhdpi/logo_vpn.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xhdpi/logo_vpn.png
new file mode 100644
index 0000000000..22000faeb1
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xhdpi/logo_vpn.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xhdpi/map_phones.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xhdpi/map_phones.png
new file mode 100644
index 0000000000..1218ddbc94
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xhdpi/map_phones.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xhdpi/red_btn.9.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xhdpi/red_btn.9.png
new file mode 100644
index 0000000000..ffb342a495
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xhdpi/red_btn.9.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xhdpi/select_arrow.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xhdpi/select_arrow.png
new file mode 100644
index 0000000000..7a15f38a0c
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xhdpi/select_arrow.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xhdpi/start_btn.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xhdpi/start_btn.png
new file mode 100644
index 0000000000..0c92d539d6
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xhdpi/start_btn.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ab.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ab.png
new file mode 100644
index 0000000000..82ffb169a6
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ab.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ad.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ad.png
new file mode 100644
index 0000000000..7b12b7214d
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ad.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ae.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ae.png
new file mode 100644
index 0000000000..9e316d9058
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ae.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/af.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/af.png
new file mode 100644
index 0000000000..7879ab7afc
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/af.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ag.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ag.png
new file mode 100644
index 0000000000..48e08fb9c1
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ag.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ai.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ai.png
new file mode 100644
index 0000000000..611293b976
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ai.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/al.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/al.png
new file mode 100644
index 0000000000..59b2b8d3fa
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/al.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/am.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/am.png
new file mode 100644
index 0000000000..750da0651e
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/am.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ao.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ao.png
new file mode 100644
index 0000000000..5161bbedde
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ao.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/aq.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/aq.png
new file mode 100644
index 0000000000..efa0c26927
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/aq.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ar.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ar.png
new file mode 100644
index 0000000000..a03bd05609
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ar.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/as.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/as.png
new file mode 100644
index 0000000000..5eb0dc7efa
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/as.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/at.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/at.png
new file mode 100644
index 0000000000..541907e239
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/at.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/au.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/au.png
new file mode 100644
index 0000000000..3a938889f2
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/au.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/aw.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/aw.png
new file mode 100644
index 0000000000..5f9149efe1
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/aw.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ax.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ax.png
new file mode 100644
index 0000000000..9f06c81964
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ax.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/az.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/az.png
new file mode 100644
index 0000000000..9673ab5a7c
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/az.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ba.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ba.png
new file mode 100644
index 0000000000..6741cf0215
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ba.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/bb.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/bb.png
new file mode 100644
index 0000000000..bb20cc9fbe
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/bb.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/bd.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/bd.png
new file mode 100644
index 0000000000..705ef7e35e
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/bd.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/be.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/be.png
new file mode 100644
index 0000000000..35c2ba83ee
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/be.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/bf.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/bf.png
new file mode 100644
index 0000000000..fb2f8fff15
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/bf.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/bg.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/bg.png
new file mode 100644
index 0000000000..afa5aeb258
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/bg.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/bh.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/bh.png
new file mode 100644
index 0000000000..aba79a4b22
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/bh.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/bi.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/bi.png
new file mode 100644
index 0000000000..7260b4c39c
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/bi.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/bj.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/bj.png
new file mode 100644
index 0000000000..211a163b69
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/bj.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/bl.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/bl.png
new file mode 100644
index 0000000000..34c9a87870
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/bl.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/bm.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/bm.png
new file mode 100644
index 0000000000..1c1c86da3c
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/bm.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/bn.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/bn.png
new file mode 100644
index 0000000000..2e6f20ebb5
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/bn.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/bo.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/bo.png
new file mode 100644
index 0000000000..0143a672c1
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/bo.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/bq.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/bq.png
new file mode 100644
index 0000000000..2be4c18cf9
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/bq.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/br.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/br.png
new file mode 100644
index 0000000000..4e338c1322
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/br.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/bs.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/bs.png
new file mode 100644
index 0000000000..731bfc6fbb
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/bs.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/bt.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/bt.png
new file mode 100644
index 0000000000..07f9f34d00
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/bt.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/bv.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/bv.png
new file mode 100644
index 0000000000..c9ff046d89
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/bv.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/bw.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/bw.png
new file mode 100644
index 0000000000..cde0a5f545
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/bw.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/by.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/by.png
new file mode 100644
index 0000000000..4f3ffd8859
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/by.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/bz.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/bz.png
new file mode 100644
index 0000000000..161cdf1edd
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/bz.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ca.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ca.png
new file mode 100644
index 0000000000..3790ae5175
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ca.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/cc.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/cc.png
new file mode 100644
index 0000000000..2dc34b57eb
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/cc.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/cd.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/cd.png
new file mode 100644
index 0000000000..0433ff7e6e
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/cd.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/cf.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/cf.png
new file mode 100644
index 0000000000..3913150b92
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/cf.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/cg.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/cg.png
new file mode 100644
index 0000000000..8e30e9d7e0
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/cg.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ch.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ch.png
new file mode 100644
index 0000000000..7fa87e53dc
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ch.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ci.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ci.png
new file mode 100644
index 0000000000..6a404186f6
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ci.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ck.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ck.png
new file mode 100644
index 0000000000..5cb42801cb
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ck.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/cl.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/cl.png
new file mode 100644
index 0000000000..11eeb917a2
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/cl.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/cm.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/cm.png
new file mode 100644
index 0000000000..6a2b9b38ff
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/cm.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/cn.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/cn.png
new file mode 100644
index 0000000000..1e39d2ca7c
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/cn.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/co.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/co.png
new file mode 100644
index 0000000000..447f8714e9
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/co.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/cr.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/cr.png
new file mode 100644
index 0000000000..df96cfaaa8
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/cr.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/cu.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/cu.png
new file mode 100644
index 0000000000..160bb489ba
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/cu.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/cv.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/cv.png
new file mode 100644
index 0000000000..b71fb9ea29
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/cv.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/cw.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/cw.png
new file mode 100644
index 0000000000..2365ec8454
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/cw.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/cx.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/cx.png
new file mode 100644
index 0000000000..44c79e270a
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/cx.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/cy.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/cy.png
new file mode 100644
index 0000000000..1c0c60eca6
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/cy.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/cz.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/cz.png
new file mode 100644
index 0000000000..6280f4d7e6
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/cz.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/de.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/de.png
new file mode 100644
index 0000000000..db3f7db47d
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/de.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/dj.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/dj.png
new file mode 100644
index 0000000000..3ff60b0827
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/dj.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/dk.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/dk.png
new file mode 100644
index 0000000000..6eea46fbca
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/dk.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/dm.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/dm.png
new file mode 100644
index 0000000000..7fb94f9584
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/dm.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/do_flag.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/do_flag.png
new file mode 100644
index 0000000000..dfba39205c
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/do_flag.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/dz.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/dz.png
new file mode 100644
index 0000000000..85e5bf86bc
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/dz.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ec.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ec.png
new file mode 100644
index 0000000000..0ae8eecacd
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ec.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ee.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ee.png
new file mode 100644
index 0000000000..2a4b9ae044
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ee.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/eg.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/eg.png
new file mode 100644
index 0000000000..c3a6e9802c
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/eg.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/eh.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/eh.png
new file mode 100644
index 0000000000..22ba313635
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/eh.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/er.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/er.png
new file mode 100644
index 0000000000..d1bba3ae7c
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/er.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/es.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/es.png
new file mode 100644
index 0000000000..338a347000
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/es.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/et.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/et.png
new file mode 100644
index 0000000000..e5861dac0f
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/et.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/fi.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/fi.png
new file mode 100644
index 0000000000..a8dacd0ab3
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/fi.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/fj.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/fj.png
new file mode 100644
index 0000000000..c144450d41
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/fj.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/fk.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/fk.png
new file mode 100644
index 0000000000..9c75d7abe2
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/fk.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/fm.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/fm.png
new file mode 100644
index 0000000000..7731a55da4
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/fm.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/fo.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/fo.png
new file mode 100644
index 0000000000..9c05b1e266
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/fo.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/fr.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/fr.png
new file mode 100644
index 0000000000..0e3d16c3fd
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/fr.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ga.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ga.png
new file mode 100644
index 0000000000..999588ad3e
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ga.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/gb.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/gb.png
new file mode 100644
index 0000000000..ffebad3149
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/gb.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/gd.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/gd.png
new file mode 100644
index 0000000000..7f8934b3ac
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/gd.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ge.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ge.png
new file mode 100644
index 0000000000..d2dd15ec17
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ge.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/gf.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/gf.png
new file mode 100644
index 0000000000..c5d0b2504c
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/gf.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/gg.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/gg.png
new file mode 100644
index 0000000000..8f10041e37
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/gg.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/gi.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/gi.png
new file mode 100644
index 0000000000..0f5985b9bc
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/gi.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/gl.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/gl.png
new file mode 100644
index 0000000000..acfe5fd517
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/gl.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/gm.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/gm.png
new file mode 100644
index 0000000000..154552a594
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/gm.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/gn.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/gn.png
new file mode 100644
index 0000000000..ecc7aed83c
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/gn.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/gp.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/gp.png
new file mode 100644
index 0000000000..75d7f46e86
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/gp.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/gq.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/gq.png
new file mode 100644
index 0000000000..f7bf7ac3c5
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/gq.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/gr.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/gr.png
new file mode 100644
index 0000000000..be17af8219
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/gr.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/gs.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/gs.png
new file mode 100644
index 0000000000..73ebc9f07c
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/gs.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/gt.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/gt.png
new file mode 100644
index 0000000000..37c4006089
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/gt.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/gu.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/gu.png
new file mode 100644
index 0000000000..cdb2617c51
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/gu.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/gw.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/gw.png
new file mode 100644
index 0000000000..387962a6b8
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/gw.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/gy.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/gy.png
new file mode 100644
index 0000000000..46c56f1d25
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/gy.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/hk.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/hk.png
new file mode 100644
index 0000000000..f3e1bdfec3
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/hk.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/hm.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/hm.png
new file mode 100644
index 0000000000..6bdd23868a
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/hm.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/hn.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/hn.png
new file mode 100644
index 0000000000..5778bc45fa
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/hn.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/hr.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/hr.png
new file mode 100644
index 0000000000..8026133719
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/hr.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ht.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ht.png
new file mode 100644
index 0000000000..7a0601c50f
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ht.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/hu.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/hu.png
new file mode 100644
index 0000000000..d87ba073a8
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/hu.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/id.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/id.png
new file mode 100644
index 0000000000..4e22bccc3b
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/id.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ie.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ie.png
new file mode 100644
index 0000000000..49aef004df
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ie.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/il.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/il.png
new file mode 100644
index 0000000000..5b033849cd
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/il.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/im.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/im.png
new file mode 100644
index 0000000000..02162a1175
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/im.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/in.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/in.png
new file mode 100644
index 0000000000..03300d8bef
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/in.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/io.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/io.png
new file mode 100644
index 0000000000..dc0ac4f873
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/io.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/iq.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/iq.png
new file mode 100644
index 0000000000..fbac7587fc
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/iq.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ir.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ir.png
new file mode 100644
index 0000000000..c172ad9d80
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ir.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/is.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/is.png
new file mode 100644
index 0000000000..aa611d7970
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/is.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/it.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/it.png
new file mode 100644
index 0000000000..353b20e749
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/it.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/je.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/je.png
new file mode 100644
index 0000000000..eb01f5a0ba
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/je.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/jm.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/jm.png
new file mode 100644
index 0000000000..e807027660
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/jm.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/jo.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/jo.png
new file mode 100644
index 0000000000..30e353f47f
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/jo.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/jp.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/jp.png
new file mode 100644
index 0000000000..b432601bec
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/jp.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ke.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ke.png
new file mode 100644
index 0000000000..c2f2a4a7a4
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ke.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/kg.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/kg.png
new file mode 100644
index 0000000000..34fb6df580
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/kg.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/kh.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/kh.png
new file mode 100644
index 0000000000..8ca2eaa1dd
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/kh.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ki.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ki.png
new file mode 100644
index 0000000000..b7a89ba593
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ki.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/km.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/km.png
new file mode 100644
index 0000000000..0b64f43080
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/km.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/kn.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/kn.png
new file mode 100644
index 0000000000..338d6de3f8
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/kn.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/kp.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/kp.png
new file mode 100644
index 0000000000..31683b6547
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/kp.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/kr.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/kr.png
new file mode 100644
index 0000000000..86deaad104
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/kr.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/kw.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/kw.png
new file mode 100644
index 0000000000..0a31fe9375
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/kw.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ky.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ky.png
new file mode 100644
index 0000000000..c4238b3b41
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ky.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/kz.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/kz.png
new file mode 100644
index 0000000000..0d1377e48f
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/kz.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/la.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/la.png
new file mode 100644
index 0000000000..d4d078417f
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/la.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/lb.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/lb.png
new file mode 100644
index 0000000000..4d81bdef6c
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/lb.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/lc.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/lc.png
new file mode 100644
index 0000000000..971bc37738
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/lc.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/li.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/li.png
new file mode 100644
index 0000000000..26047468c8
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/li.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/lk.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/lk.png
new file mode 100644
index 0000000000..08f7d6a7e3
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/lk.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/lr.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/lr.png
new file mode 100644
index 0000000000..ef44629881
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/lr.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ls.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ls.png
new file mode 100644
index 0000000000..4d7721d3c3
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ls.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/lt.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/lt.png
new file mode 100644
index 0000000000..8d861b7ed6
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/lt.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/lu.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/lu.png
new file mode 100644
index 0000000000..2b28a35d37
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/lu.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/lv.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/lv.png
new file mode 100644
index 0000000000..a6ef25feb7
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/lv.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ly.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ly.png
new file mode 100644
index 0000000000..2fedcc1dd4
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ly.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ma.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ma.png
new file mode 100644
index 0000000000..f89c2e0011
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ma.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/mc.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/mc.png
new file mode 100644
index 0000000000..be057f99bb
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/mc.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/md.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/md.png
new file mode 100644
index 0000000000..65fbb0b1af
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/md.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/me.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/me.png
new file mode 100644
index 0000000000..6a80a75250
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/me.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/mf.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/mf.png
new file mode 100644
index 0000000000..332a8e3fcd
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/mf.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/mg.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/mg.png
new file mode 100644
index 0000000000..9eb2cf69c3
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/mg.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/mh.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/mh.png
new file mode 100644
index 0000000000..1d17d4d1b9
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/mh.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/mk.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/mk.png
new file mode 100644
index 0000000000..da1b9abcbb
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/mk.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ml.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ml.png
new file mode 100644
index 0000000000..a87c0abdc1
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ml.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/mm.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/mm.png
new file mode 100644
index 0000000000..d9f9032c8a
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/mm.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/mn.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/mn.png
new file mode 100644
index 0000000000..e8f9d9d1a3
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/mn.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/mo.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/mo.png
new file mode 100644
index 0000000000..f7ba1c3c1d
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/mo.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/mp.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/mp.png
new file mode 100644
index 0000000000..58c74fc67b
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/mp.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/mq.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/mq.png
new file mode 100644
index 0000000000..b723c009ea
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/mq.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/mr.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/mr.png
new file mode 100644
index 0000000000..285f737402
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/mr.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ms.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ms.png
new file mode 100644
index 0000000000..94d50aa75c
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ms.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/mt.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/mt.png
new file mode 100644
index 0000000000..f2b0ce0b6f
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/mt.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/mu.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/mu.png
new file mode 100644
index 0000000000..755dce2085
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/mu.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/mv.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/mv.png
new file mode 100644
index 0000000000..31eac72d22
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/mv.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/mw.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/mw.png
new file mode 100644
index 0000000000..d649d1144d
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/mw.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/mx.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/mx.png
new file mode 100644
index 0000000000..fdb43b6dea
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/mx.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/my.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/my.png
new file mode 100644
index 0000000000..db64b56c50
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/my.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/mz.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/mz.png
new file mode 100644
index 0000000000..1d65219788
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/mz.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/na.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/na.png
new file mode 100644
index 0000000000..4c696bca1f
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/na.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/nc.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/nc.png
new file mode 100644
index 0000000000..3b188515d4
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/nc.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ne.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ne.png
new file mode 100644
index 0000000000..959afa7fdb
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ne.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/nf.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/nf.png
new file mode 100644
index 0000000000..88c7fcc4ec
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/nf.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ng.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ng.png
new file mode 100644
index 0000000000..4d59d5440d
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ng.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ni.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ni.png
new file mode 100644
index 0000000000..6a938c94e8
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ni.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/nl.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/nl.png
new file mode 100644
index 0000000000..b3d928a85b
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/nl.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/no.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/no.png
new file mode 100644
index 0000000000..326de62d12
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/no.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/np.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/np.png
new file mode 100644
index 0000000000..c9916676d9
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/np.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/nr.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/nr.png
new file mode 100644
index 0000000000..95223d3ba6
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/nr.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/nu.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/nu.png
new file mode 100644
index 0000000000..082d194101
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/nu.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/nz.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/nz.png
new file mode 100644
index 0000000000..2ef9bab7d3
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/nz.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/om.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/om.png
new file mode 100644
index 0000000000..79939f3c68
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/om.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/pa.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/pa.png
new file mode 100644
index 0000000000..fbafa66e2c
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/pa.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/pe.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/pe.png
new file mode 100644
index 0000000000..7440086213
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/pe.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/pf.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/pf.png
new file mode 100644
index 0000000000..000c41d143
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/pf.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/pg.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/pg.png
new file mode 100644
index 0000000000..998c227ad1
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/pg.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ph.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ph.png
new file mode 100644
index 0000000000..a604cdcabf
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ph.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/pk.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/pk.png
new file mode 100644
index 0000000000..6637d24d61
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/pk.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/pl.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/pl.png
new file mode 100644
index 0000000000..ec7a4954b4
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/pl.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/pm.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/pm.png
new file mode 100644
index 0000000000..7af0691825
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/pm.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/pn.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/pn.png
new file mode 100644
index 0000000000..7c344ea734
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/pn.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/pr.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/pr.png
new file mode 100644
index 0000000000..7da5cd418e
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/pr.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ps.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ps.png
new file mode 100644
index 0000000000..7a26daea88
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ps.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/pt.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/pt.png
new file mode 100644
index 0000000000..38d60e9c17
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/pt.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/pw.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/pw.png
new file mode 100644
index 0000000000..cec7edebe0
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/pw.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/py.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/py.png
new file mode 100644
index 0000000000..84a78cf4ba
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/py.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/qa.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/qa.png
new file mode 100644
index 0000000000..3dd855689e
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/qa.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/re.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/re.png
new file mode 100644
index 0000000000..ff4cd7cb3b
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/re.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ro.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ro.png
new file mode 100644
index 0000000000..70b8505fec
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ro.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/rs.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/rs.png
new file mode 100644
index 0000000000..73c0531399
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/rs.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ru.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ru.png
new file mode 100644
index 0000000000..9e86166a3c
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ru.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/rw.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/rw.png
new file mode 100644
index 0000000000..ff0476fa84
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/rw.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/sa.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/sa.png
new file mode 100644
index 0000000000..190963fd58
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/sa.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/sb.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/sb.png
new file mode 100644
index 0000000000..e92ecffe82
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/sb.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/sc.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/sc.png
new file mode 100644
index 0000000000..519821fd89
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/sc.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/sd.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/sd.png
new file mode 100644
index 0000000000..6fea21812a
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/sd.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/se.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/se.png
new file mode 100644
index 0000000000..0bc7c93ced
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/se.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/sg.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/sg.png
new file mode 100644
index 0000000000..1e3e8a0d6b
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/sg.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/sh.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/sh.png
new file mode 100644
index 0000000000..670eb8eab1
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/sh.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/si.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/si.png
new file mode 100644
index 0000000000..0f2cd6f020
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/si.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/sj.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/sj.png
new file mode 100644
index 0000000000..51905cc110
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/sj.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/sk.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/sk.png
new file mode 100644
index 0000000000..8d860bced3
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/sk.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/sl.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/sl.png
new file mode 100644
index 0000000000..7d898bdbd0
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/sl.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/sm.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/sm.png
new file mode 100644
index 0000000000..06a6bda12c
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/sm.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/sn.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/sn.png
new file mode 100644
index 0000000000..8b813b3a0a
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/sn.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/so.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/so.png
new file mode 100644
index 0000000000..ba9ce3eeb4
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/so.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/sr.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/sr.png
new file mode 100644
index 0000000000..9e2d1caed2
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/sr.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ss.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ss.png
new file mode 100644
index 0000000000..582710c293
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ss.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/st.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/st.png
new file mode 100644
index 0000000000..0fa430c934
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/st.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/sv.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/sv.png
new file mode 100644
index 0000000000..d6f48971d8
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/sv.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/sx.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/sx.png
new file mode 100644
index 0000000000..f6b855871a
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/sx.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/sy.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/sy.png
new file mode 100644
index 0000000000..25ce12f15b
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/sy.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/sz.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/sz.png
new file mode 100644
index 0000000000..7f27ab7308
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/sz.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/tc.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/tc.png
new file mode 100644
index 0000000000..e334188fa9
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/tc.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/td.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/td.png
new file mode 100644
index 0000000000..991f9b1e04
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/td.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/tf.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/tf.png
new file mode 100644
index 0000000000..88df1bf7b6
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/tf.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/tg.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/tg.png
new file mode 100644
index 0000000000..fc7a3a9206
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/tg.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/th.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/th.png
new file mode 100644
index 0000000000..03fd23b0ef
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/th.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/tj.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/tj.png
new file mode 100644
index 0000000000..5c3b062de1
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/tj.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/tk.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/tk.png
new file mode 100644
index 0000000000..d6f69805e3
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/tk.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/tl.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/tl.png
new file mode 100644
index 0000000000..8e39f38105
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/tl.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/tm.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/tm.png
new file mode 100644
index 0000000000..bf7186fd1a
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/tm.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/tn.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/tn.png
new file mode 100644
index 0000000000..5b83512b68
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/tn.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/to.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/to.png
new file mode 100644
index 0000000000..3b861fc9cd
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/to.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/tr.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/tr.png
new file mode 100644
index 0000000000..31f5edff17
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/tr.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/tt.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/tt.png
new file mode 100644
index 0000000000..fdff2b2cef
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/tt.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/tv.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/tv.png
new file mode 100644
index 0000000000..131956f6d4
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/tv.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/tw.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/tw.png
new file mode 100644
index 0000000000..9dc8fec720
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/tw.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/tz.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/tz.png
new file mode 100644
index 0000000000..73180f0004
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/tz.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ua.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ua.png
new file mode 100644
index 0000000000..558ec3fbf5
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ua.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ug.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ug.png
new file mode 100644
index 0000000000..6003d6f268
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ug.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/um.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/um.png
new file mode 100644
index 0000000000..e092541afd
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/um.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/us.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/us.png
new file mode 100644
index 0000000000..e092541afd
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/us.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/uy.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/uy.png
new file mode 100644
index 0000000000..302f6d9ad4
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/uy.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/uz.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/uz.png
new file mode 100644
index 0000000000..7eb9d05415
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/uz.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/va.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/va.png
new file mode 100644
index 0000000000..48eae45833
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/va.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/vc.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/vc.png
new file mode 100644
index 0000000000..c98be731d3
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/vc.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ve.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ve.png
new file mode 100644
index 0000000000..77e9416dea
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ve.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/vg.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/vg.png
new file mode 100644
index 0000000000..7f87c68ea7
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/vg.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/vi.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/vi.png
new file mode 100644
index 0000000000..e7eb8f7fb8
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/vi.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/vn.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/vn.png
new file mode 100644
index 0000000000..a290df621f
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/vn.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/vu.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/vu.png
new file mode 100644
index 0000000000..a0d648ffab
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/vu.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/wf.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/wf.png
new file mode 100644
index 0000000000..06e55d2c17
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/wf.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ws.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ws.png
new file mode 100644
index 0000000000..04adf06736
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ws.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/xk.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/xk.png
new file mode 100644
index 0000000000..84834502bb
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/xk.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ye.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ye.png
new file mode 100644
index 0000000000..5325964fe7
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/ye.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/yt.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/yt.png
new file mode 100644
index 0000000000..19a0e4a5b4
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/yt.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/za.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/za.png
new file mode 100644
index 0000000000..e329e2e186
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/za.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/zm.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/zm.png
new file mode 100644
index 0000000000..422ba64361
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/zm.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/zw.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/zw.png
new file mode 100644
index 0000000000..9cf6b5eaa8
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/zw.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/zz.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/zz.png
new file mode 100644
index 0000000000..fdedbb42ef
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/zz.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxxhdpi/ic_alert.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxxhdpi/ic_alert.png
new file mode 100644
index 0000000000..5ab36c4729
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxxhdpi/ic_alert.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxxhdpi/ic_error.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxxhdpi/ic_error.png
new file mode 100644
index 0000000000..fd994a23ee
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxxhdpi/ic_error.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxxhdpi/ic_filled.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxxhdpi/ic_filled.png
new file mode 100644
index 0000000000..b7bcc6b595
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxxhdpi/ic_filled.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxxhdpi/ic_lines.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxxhdpi/ic_lines.png
new file mode 100644
index 0000000000..2b8a9282a1
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxxhdpi/ic_lines.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxxhdpi/modal_background.9.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxxhdpi/modal_background.9.png
new file mode 100644
index 0000000000..f7a2eb9ef3
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxxhdpi/modal_background.9.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable/background.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable/background.png
new file mode 100644
index 0000000000..b4144fdf7a
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable/background.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable/box_background_pattern_tiling.xml b/cmd/skywirevisormobile/android/app/src/main/res/drawable/box_background_pattern_tiling.xml
new file mode 100644
index 0000000000..4708111a52
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/drawable/box_background_pattern_tiling.xml
@@ -0,0 +1,4 @@
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable/box_clip_area_left.xml b/cmd/skywirevisormobile/android/app/src/main/res/drawable/box_clip_area_left.xml
new file mode 100644
index 0000000000..e9d2a8c109
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/drawable/box_clip_area_left.xml
@@ -0,0 +1,9 @@
+
+
+ -
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable/box_clip_area_right.xml b/cmd/skywirevisormobile/android/app/src/main/res/drawable/box_clip_area_right.xml
new file mode 100644
index 0000000000..ff8e69ee69
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/drawable/box_clip_area_right.xml
@@ -0,0 +1,9 @@
+
+
+ -
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable/box_ripple.xml b/cmd/skywirevisormobile/android/app/src/main/res/drawable/box_ripple.xml
new file mode 100644
index 0000000000..a5a4603ff4
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/drawable/box_ripple.xml
@@ -0,0 +1,11 @@
+
+
+ -
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable/box_row_rounded_box_1.xml b/cmd/skywirevisormobile/android/app/src/main/res/drawable/box_row_rounded_box_1.xml
new file mode 100644
index 0000000000..cd88329f33
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/drawable/box_row_rounded_box_1.xml
@@ -0,0 +1,9 @@
+
+
+ -
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable/box_row_rounded_box_2.xml b/cmd/skywirevisormobile/android/app/src/main/res/drawable/box_row_rounded_box_2.xml
new file mode 100644
index 0000000000..31d96f34f2
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/drawable/box_row_rounded_box_2.xml
@@ -0,0 +1,8 @@
+
+
+ -
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable/box_row_rounded_box_3.xml b/cmd/skywirevisormobile/android/app/src/main/res/drawable/box_row_rounded_box_3.xml
new file mode 100644
index 0000000000..1f2be538e7
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/drawable/box_row_rounded_box_3.xml
@@ -0,0 +1,9 @@
+
+
+ -
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable/box_row_rounded_box_4.xml b/cmd/skywirevisormobile/android/app/src/main/res/drawable/box_row_rounded_box_4.xml
new file mode 100644
index 0000000000..3df744babb
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/drawable/box_row_rounded_box_4.xml
@@ -0,0 +1,9 @@
+
+
+ -
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable/box_row_rounded_box_5.xml b/cmd/skywirevisormobile/android/app/src/main/res/drawable/box_row_rounded_box_5.xml
new file mode 100644
index 0000000000..5ef7f10567
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/drawable/box_row_rounded_box_5.xml
@@ -0,0 +1,9 @@
+
+
+ -
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable/clear_box_ripple.xml b/cmd/skywirevisormobile/android/app/src/main/res/drawable/clear_box_ripple.xml
new file mode 100644
index 0000000000..cdac0f21d2
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/drawable/clear_box_ripple.xml
@@ -0,0 +1,11 @@
+
+
+ -
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable/current_server_ripple.xml b/cmd/skywirevisormobile/android/app/src/main/res/drawable/current_server_ripple.xml
new file mode 100644
index 0000000000..85b80d9bff
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/drawable/current_server_ripple.xml
@@ -0,0 +1,11 @@
+
+
+ -
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable/current_server_rounded_box.xml b/cmd/skywirevisormobile/android/app/src/main/res/drawable/current_server_rounded_box.xml
new file mode 100644
index 0000000000..2ba99e7d1a
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/drawable/current_server_rounded_box.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable/flag_rounded_box.xml b/cmd/skywirevisormobile/android/app/src/main/res/drawable/flag_rounded_box.xml
new file mode 100644
index 0000000000..4c6bbe75e2
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/drawable/flag_rounded_box.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable/internal_box_row_rounded_box_1.xml b/cmd/skywirevisormobile/android/app/src/main/res/drawable/internal_box_row_rounded_box_1.xml
new file mode 100644
index 0000000000..71083003f0
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/drawable/internal_box_row_rounded_box_1.xml
@@ -0,0 +1,9 @@
+
+
+ -
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable/internal_box_row_rounded_box_2.xml b/cmd/skywirevisormobile/android/app/src/main/res/drawable/internal_box_row_rounded_box_2.xml
new file mode 100644
index 0000000000..26018e3aac
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/drawable/internal_box_row_rounded_box_2.xml
@@ -0,0 +1,8 @@
+
+
+ -
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable/internal_box_row_rounded_box_3.xml b/cmd/skywirevisormobile/android/app/src/main/res/drawable/internal_box_row_rounded_box_3.xml
new file mode 100644
index 0000000000..e86d1fe085
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/drawable/internal_box_row_rounded_box_3.xml
@@ -0,0 +1,9 @@
+
+
+ -
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable/internal_box_row_rounded_box_4.xml b/cmd/skywirevisormobile/android/app/src/main/res/drawable/internal_box_row_rounded_box_4.xml
new file mode 100644
index 0000000000..5ef7f10567
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/drawable/internal_box_row_rounded_box_4.xml
@@ -0,0 +1,9 @@
+
+
+ -
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable/map.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable/map.png
new file mode 100644
index 0000000000..1218ddbc94
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable/map.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable/modal_background_pattern_tiling.xml b/cmd/skywirevisormobile/android/app/src/main/res/drawable/modal_background_pattern_tiling.xml
new file mode 100644
index 0000000000..e31574bdfb
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/drawable/modal_background_pattern_tiling.xml
@@ -0,0 +1,4 @@
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable/modal_button_primary_background.xml b/cmd/skywirevisormobile/android/app/src/main/res/drawable/modal_button_primary_background.xml
new file mode 100644
index 0000000000..14d5e6874c
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/drawable/modal_button_primary_background.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable/modal_button_primary_ripple.xml b/cmd/skywirevisormobile/android/app/src/main/res/drawable/modal_button_primary_ripple.xml
new file mode 100644
index 0000000000..142ef10978
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/drawable/modal_button_primary_ripple.xml
@@ -0,0 +1,11 @@
+
+
+ -
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable/modal_button_secondary_background.xml b/cmd/skywirevisormobile/android/app/src/main/res/drawable/modal_button_secondary_background.xml
new file mode 100644
index 0000000000..2973e01b92
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/drawable/modal_button_secondary_background.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable/modal_button_secondary_ripple.xml b/cmd/skywirevisormobile/android/app/src/main/res/drawable/modal_button_secondary_ripple.xml
new file mode 100644
index 0000000000..0eb4edfc30
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/drawable/modal_button_secondary_ripple.xml
@@ -0,0 +1,11 @@
+
+
+ -
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable/modal_internal_area.xml b/cmd/skywirevisormobile/android/app/src/main/res/drawable/modal_internal_area.xml
new file mode 100644
index 0000000000..4f9212b84b
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/drawable/modal_internal_area.xml
@@ -0,0 +1,9 @@
+
+
+ -
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable/red_button_pattern_tiling.xml b/cmd/skywirevisormobile/android/app/src/main/res/drawable/red_button_pattern_tiling.xml
new file mode 100644
index 0000000000..4708111a52
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/drawable/red_button_pattern_tiling.xml
@@ -0,0 +1,4 @@
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable/stop_btn_internal_area.xml b/cmd/skywirevisormobile/android/app/src/main/res/drawable/stop_btn_internal_area.xml
new file mode 100644
index 0000000000..571fe729c2
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/drawable/stop_btn_internal_area.xml
@@ -0,0 +1,9 @@
+
+
+ -
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable/tablet_tab_border.xml b/cmd/skywirevisormobile/android/app/src/main/res/drawable/tablet_tab_border.xml
new file mode 100644
index 0000000000..2574f6f64b
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/drawable/tablet_tab_border.xml
@@ -0,0 +1,9 @@
+
+
+ -
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable/time_rounded_box.xml b/cmd/skywirevisormobile/android/app/src/main/res/drawable/time_rounded_box.xml
new file mode 100644
index 0000000000..3f4b675875
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/drawable/time_rounded_box.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/drawable/top_bar_shadow.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable/top_bar_shadow.png
new file mode 100644
index 0000000000..5706222b35
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable/top_bar_shadow.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/font/material_font.ttf b/cmd/skywirevisormobile/android/app/src/main/res/font/material_font.ttf
new file mode 100644
index 0000000000..e50801b3b6
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/font/material_font.ttf differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/font/skycoin_font.otf b/cmd/skywirevisormobile/android/app/src/main/res/font/skycoin_font.otf
new file mode 100644
index 0000000000..e3e80f0e48
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/font/skycoin_font.otf differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/font/skycoin_font_bold.otf b/cmd/skywirevisormobile/android/app/src/main/res/font/skycoin_font_bold.otf
new file mode 100644
index 0000000000..9ef7020744
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/font/skycoin_font_bold.otf differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/layout/activity_app_list.xml b/cmd/skywirevisormobile/android/app/src/main/res/layout/activity_app_list.xml
new file mode 100644
index 0000000000..7c02a596ca
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/layout/activity_app_list.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/layout/activity_index.xml b/cmd/skywirevisormobile/android/app/src/main/res/layout/activity_index.xml
new file mode 100644
index 0000000000..a909194a88
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/layout/activity_index.xml
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/layout/activity_main.xml b/cmd/skywirevisormobile/android/app/src/main/res/layout/activity_main.xml
new file mode 100644
index 0000000000..e69786e05c
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/layout/activity_main.xml
@@ -0,0 +1,125 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/layout/activity_server_list.xml b/cmd/skywirevisormobile/android/app/src/main/res/layout/activity_server_list.xml
new file mode 100644
index 0000000000..cc6bd2059b
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/layout/activity_server_list.xml
@@ -0,0 +1,114 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/layout/activity_settings.xml b/cmd/skywirevisormobile/android/app/src/main/res/layout/activity_settings.xml
new file mode 100644
index 0000000000..88ac6fda5e
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/layout/activity_settings.xml
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/layout/activity_start.xml b/cmd/skywirevisormobile/android/app/src/main/res/layout/activity_start.xml
new file mode 100644
index 0000000000..da6dd70342
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/layout/activity_start.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/layout/view_app_list_item.xml b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_app_list_item.xml
new file mode 100644
index 0000000000..98388bcf68
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_app_list_item.xml
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/layout/view_app_list_row.xml b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_app_list_row.xml
new file mode 100644
index 0000000000..517524ee2d
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_app_list_row.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/layout/view_app_list_selection_option.xml b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_app_list_selection_option.xml
new file mode 100644
index 0000000000..f638fb50ba
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_app_list_selection_option.xml
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/layout/view_app_list_separator.xml b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_app_list_separator.xml
new file mode 100644
index 0000000000..b79be52d70
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_app_list_separator.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/layout/view_confirmation_dialog.xml b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_confirmation_dialog.xml
new file mode 100644
index 0000000000..0177755fc0
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_confirmation_dialog.xml
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/layout/view_current_server_button.xml b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_current_server_button.xml
new file mode 100644
index 0000000000..a666468c9b
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_current_server_button.xml
@@ -0,0 +1,88 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/layout/view_edit_server_value_modal.xml b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_edit_server_value_modal.xml
new file mode 100644
index 0000000000..150d78b349
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_edit_server_value_modal.xml
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/layout/view_manual_server_modal.xml b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_manual_server_modal.xml
new file mode 100644
index 0000000000..86549c7e14
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_manual_server_modal.xml
@@ -0,0 +1,124 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/layout/view_modal_base.xml b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_modal_base.xml
new file mode 100644
index 0000000000..edfacb192e
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_modal_base.xml
@@ -0,0 +1,80 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/layout/view_modal_window_button.xml b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_modal_window_button.xml
new file mode 100644
index 0000000000..87c3360146
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_modal_window_button.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/layout/view_options.xml b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_options.xml
new file mode 100644
index 0000000000..aaded44e4d
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_options.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/layout/view_options_item.xml b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_options_item.xml
new file mode 100644
index 0000000000..8f86789d73
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_options_item.xml
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/layout/view_select.xml b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_select.xml
new file mode 100644
index 0000000000..20ffabed25
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_select.xml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/layout/view_server_filters_modal.xml b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_server_filters_modal.xml
new file mode 100644
index 0000000000..e517d2609b
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_server_filters_modal.xml
@@ -0,0 +1,130 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/layout/view_server_info_modal.xml b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_server_info_modal.xml
new file mode 100644
index 0000000000..42c95cbf4a
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_server_info_modal.xml
@@ -0,0 +1,269 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/layout/view_server_list_condition_list.xml b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_server_list_condition_list.xml
new file mode 100644
index 0000000000..9e08031104
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_server_list_condition_list.xml
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/layout/view_server_list_item.xml b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_server_list_item.xml
new file mode 100644
index 0000000000..d54a24ef4d
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_server_list_item.xml
@@ -0,0 +1,302 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/layout/view_server_list_option_button.xml b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_server_list_option_button.xml
new file mode 100644
index 0000000000..9e476cecd7
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_server_list_option_button.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/layout/view_server_list_options.xml b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_server_list_options.xml
new file mode 100644
index 0000000000..a824756cc7
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_server_list_options.xml
@@ -0,0 +1,79 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/layout/view_server_list_table_header.xml b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_server_list_table_header.xml
new file mode 100644
index 0000000000..b63f5e6b5c
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_server_list_table_header.xml
@@ -0,0 +1,194 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/layout/view_server_list_table_row.xml b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_server_list_table_row.xml
new file mode 100644
index 0000000000..e66c1cc1f7
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_server_list_table_row.xml
@@ -0,0 +1,186 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/layout/view_server_list_top_tab.xml b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_server_list_top_tab.xml
new file mode 100644
index 0000000000..2abe148aac
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_server_list_top_tab.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/layout/view_server_name.xml b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_server_name.xml
new file mode 100644
index 0000000000..2249d037a4
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_server_name.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/layout/view_server_notes_modal.xml b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_server_notes_modal.xml
new file mode 100644
index 0000000000..db0587c79b
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_server_notes_modal.xml
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/layout/view_server_password_modal.xml b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_server_password_modal.xml
new file mode 100644
index 0000000000..5cd30c2c8e
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_server_password_modal.xml
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/layout/view_settings_button.xml b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_settings_button.xml
new file mode 100644
index 0000000000..afd95f937e
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_settings_button.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/layout/view_settings_dns_modal.xml b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_settings_dns_modal.xml
new file mode 100644
index 0000000000..d19f6b3af2
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_settings_dns_modal.xml
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/layout/view_settings_list_item.xml b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_settings_list_item.xml
new file mode 100644
index 0000000000..0c16845d25
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_settings_list_item.xml
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/layout/view_start_button.xml b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_start_button.xml
new file mode 100644
index 0000000000..9f061f69c0
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_start_button.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/layout/view_start_chart.xml b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_start_chart.xml
new file mode 100644
index 0000000000..db1ca7c9ea
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_start_chart.xml
@@ -0,0 +1,95 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/layout/view_start_connected.xml b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_start_connected.xml
new file mode 100644
index 0000000000..d475cbab6c
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_start_connected.xml
@@ -0,0 +1,508 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/layout/view_start_disconnected.xml b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_start_disconnected.xml
new file mode 100644
index 0000000000..3570f9ab8b
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_start_disconnected.xml
@@ -0,0 +1,92 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/layout/view_start_right_panel.xml b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_start_right_panel.xml
new file mode 100644
index 0000000000..86f5ee3cf7
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_start_right_panel.xml
@@ -0,0 +1,247 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/layout/view_stop_button.xml b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_stop_button.xml
new file mode 100644
index 0000000000..899432ec6f
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_stop_button.xml
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/layout/view_tab.xml b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_tab.xml
new file mode 100644
index 0000000000..7919cfad6e
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_tab.xml
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/layout/view_tablet_top_bar.xml b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_tablet_top_bar.xml
new file mode 100644
index 0000000000..cc6615aaf2
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_tablet_top_bar.xml
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/layout/view_tablet_top_bar_stats.xml b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_tablet_top_bar_stats.xml
new file mode 100644
index 0000000000..da6c293688
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_tablet_top_bar_stats.xml
@@ -0,0 +1,149 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/layout/view_tablet_top_bar_tab.xml b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_tablet_top_bar_tab.xml
new file mode 100644
index 0000000000..aeb99c5434
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_tablet_top_bar_tab.xml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/layout/view_top_bar.xml b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_top_bar.xml
new file mode 100644
index 0000000000..198c4ca6c6
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_top_bar.xml
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/layout/view_top_bar_button.xml b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_top_bar_button.xml
new file mode 100644
index 0000000000..adf632bc0b
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_top_bar_button.xml
@@ -0,0 +1,14 @@
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/layout/view_top_tab.xml b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_top_tab.xml
new file mode 100644
index 0000000000..600a801d75
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_top_tab.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/cmd/skywirevisormobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000000..036d09bc5f
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/cmd/skywirevisormobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000000..bbd773eac5
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/cmd/skywirevisormobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000000..9d64e921b6
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/values-v26/theme.xml b/cmd/skywirevisormobile/android/app/src/main/res/values-v26/theme.xml
new file mode 100644
index 0000000000..4d75ce1a9e
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/values-v26/theme.xml
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/values/attrs.xml b/cmd/skywirevisormobile/android/app/src/main/res/values/attrs.xml
new file mode 100644
index 0000000000..2b31a504fe
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/values/attrs.xml
@@ -0,0 +1,82 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/values/colors.xml b/cmd/skywirevisormobile/android/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000000..2504077b2d
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/values/colors.xml
@@ -0,0 +1,40 @@
+
+
+ #215f9e
+ #a7a7a7
+ #5a98f4
+
+ #ffffff
+ #80ffffff
+ #555555
+ #999999
+ #bbbbbb
+ #5790ca
+
+ #88000000
+ #314560
+
+ #1a2739
+ #28384e
+ #162334
+
+ #45a7cbff
+ #394f6d
+ #bcc4d0
+ #113050
+ #727272
+
+ #26ffffff
+ #33215f9e
+
+ #77000000
+
+ #ffffff
+ #72c012
+ #ffa500
+ #ff393f
+
+ #ffa500
+ #b8bcc2
+ #995d10
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/values/dimens.xml b/cmd/skywirevisormobile/android/app/src/main/res/values/dimens.xml
new file mode 100644
index 0000000000..fb49c391de
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/values/dimens.xml
@@ -0,0 +1,34 @@
+
+
+ 14sp
+ 12sp
+ 9sp
+ 7sp
+ 16sp
+ 18sp
+
+ 54dp
+ 52dp
+ 115dp
+ 17dp
+ 36dp
+ 90dp
+ 54dp
+
+ 10dp
+ 14dp
+ 1dp
+ -5dp
+ 9dp
+
+ 10dp
+
+ 20dp
+ 20dp
+
+ 380dp
+
+
+ 16dp
+ 16dp
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/values/ic_launcher_background.xml b/cmd/skywirevisormobile/android/app/src/main/res/values/ic_launcher_background.xml
new file mode 100644
index 0000000000..a0889def3c
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/values/ic_launcher_background.xml
@@ -0,0 +1,4 @@
+
+
+ #1E2227
+
\ No newline at end of file
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/values/strings.xml b/cmd/skywirevisormobile/android/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000000..2f10aadca9
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/values/strings.xml
@@ -0,0 +1,295 @@
+
+
+ SkywireVPN
+ SkywireVPN status notifications.
+ SkywireVPN Alerts
+ SkywireVPN important alert notifications.
+ SkywireVPN networking service.
+
+ s.
+ ms.
+ Options
+
+ Cancel
+ Close
+ Yes
+ No
+ Unknown
+
+ Copied to clipboard.
+
+ Gold
+ Silver
+ Bronze
+
+ Unable to start the VPN protection.
+ SkywireVPN does not have the necessary permissions for activating the VPN service. Please use the app to start the VPN protection.
+ There has been an error with the SkywireVPN service. The VPN protection is disabled.
+ Unable to connect to the selected server because it has been added to the blocked servers list.
+ Please stop the VPN before using this option.
+
+ The VPN service continues running. See the notification for more information.
+
+ The VPN protection cannot be started because no server has been selected.
+ The VPN protection cannot be started because the selected server is in the blocked list.
+ Invalid public key. Are you connecting to a valid VPN server?
+ Unexpected error. The local visor configuration is not valid.
+ It was not possible to access the address resolver. This may happen if there is no network connectivity or if there are temporal problems accessing any of the service components.
+ Error initializing the STCP connection. This may happen if there is no network connectivity or if there are temporal problems accessing any of the service components.
+ Error initializing the STCPR connection. This may happen if there is no network connectivity or if there are temporal problems accessing any of the service components.
+ Error initializing the SUDPH connection. This may happen if there is no network connectivity or if there are temporal problems accessing any of the service components.
+ The DMSG connection failed. This may happen if there is no network connectivity or if there are temporal problems accessing any of the service components.
+ It was not possible to access the transport discovery service. This may happen if there is no network connectivity or if there are temporal problems accessing any of the service components.
+ It was not possible to start the router. This may happen if there is no network connectivity or if there are temporal problems accessing any of the service components.
+ Failed to start the RPC server for the hypervisor. This may happen if there is no network connectivity or if there are temporal problems accessing any of the service components.
+ There was an unexpected error.
+
+ The local Skywire visor is not running. This error should be automatically solved by restarting the VPN.
+ The remote public key is not valid. Are you are connecting to a valid VPN server?
+ It was not possible to create a transport to access the VPN server. Do you have internet connection and are connecting to a valid VPN server?
+ Unable to connect with the VPN server. It may be temporally unavailable or there may be no network connectivity to access it.
+ The local VPN client is not running. This error should be automatically solved by restarting the VPN.
+ The server has reported that the password is invalid.
+ The handshake procedure with the VPN server failed. This may happen if the server is not compatible with the current version of the app or if the server refused the connection.
+ There was an error connecting the app with the local visor. This error should be automatically solved by restarting the VPN.
+ Already listening UDP. This error should be automatically solved by restarting the VPN.
+ UPD failed. This error should be automatically solved by restarting the VPN.
+
+ Preparing
+ Restoring
+ Finishing
+
+ Disconnected
+ Connecting
+ Connected
+ Restoring
+ Disconnecting
+ Error
+
+ The SkywireVPN protection is disconnected.
+ Making initial preparations.
+ Waiting for the visor to be ready.
+ Checking network connectivity.
+ Waiting for network connectivity.
+ Starting the SkywireVPN visor.
+ Starting the SkywireVPN network connector.
+ Finishing the SkywireVPN visor initialization.
+ Establishing remote connection.
+ VPN protection active.
+ Restarting the VPN connection.
+ Restarting the VPN service.
+ Closing the VPN connection.
+ The VPN connection has been closed.
+ There has been an error with the SkywireVPN service.
+ There has been an error with the SkywireVPN service. The network will be blocked until stopping the service.
+
+ Invalid public key:
+
+ The network will be unavailable while the VPN is starting.
+ It was not possible to protect the network.
+ Unable to connect to the VPN server.
+ The VPN connection has been closed unexpectedly.
+ The VPN is already running.
+ You have not selected specific applications to protect. All applications will be protected.
+ You have not selected specific applications to ignore. All applications will be protected.
+ It was not possible to configure the rules for the VPN app.
+
+ Start
+ Stop
+ Select server
+ Select apps
+ Settings
+ Stopping the service...
+ As the VPN connection was started using the system options, it is not possible to stop it using this page.
+ The VPN service failed with the following error:
+
+ Status
+ Last error:
+
+ START VPN
+ No server selected!
+
+ Your connection is currently:
+ Current IP:
+ Current Country:
+ Waiting for VPN...
+ Option disabled
+ Please wait %1$s second(s) before refreshing the data.
+ %1$s total
+ Server:
+ Remote visor public key:
+ Local visor public key:
+ Disconnect
+ Are you sure you want to stop the VPN protection?
+ As the VPN connection was started using the system options, it cannot be stopped using this screen. For stopping the VPN, please use the same system options used for stating it.
+ App protection:
+ Protecting only selected apps
+ Ignoring selected apps
+
+ Servers
+ Currently there are no VPN servers to show. Please try again later.
+ There is no history to show.
+ There are no favorite servers to show.
+ There are no blocked servers to show.
+ No VPN server matches the selected filtering criteria.
+ Sorted by
+ (Press to remove)
+ Remove filters
+ Remove custom sorting
+ Remove both
+ Sort by
+ Automatic
+ Last usage date
+ Date
+ Country
+ Name
+ Location
+ Public key
+ PK
+ Congestion
+ Congestion rating
+ Latency
+ Latency rating
+ Hops
+ Note
+ (reversed)
+ Public
+ History
+ Favorites
+ Blocked
+ Unnamed
+ Unknown
+ Please stop the VPN before changing the server.
+
+ Filter List
+ Any
+ The country must be
+ The name must contain
+ The location must contain
+ The public key must contain
+ The note must contain
+ Filter
+
+ Enter Manually
+ Server public key
+ Server password (if any)
+ Server name (optional)
+ Personal note (optional)
+ The public key must be 66 characters long.
+ The public key is not valid.
+ Use server
+
+ View info
+ Copy public key
+ Custom name
+ Custom note
+ Remove password
+ Are you sure you want remove the password used for connecting to this server?
+ Password removed.
+ Change password
+ Set password
+ Make favorite
+ Are you sure you want to mark this server as favorite? It will be removed from the blocked list.
+ Added to the favorites list.
+ Remove from favorites
+ Removed from the favorites list.
+ Block server
+ ERROR: you cannot block this server because it is currently selected.
+ Are you sure you want to block this server? It will be removed from the favorites list.
+ Added to the blocked list.
+ Unblock server
+ Removed from the blocked list.
+ Remove from history
+ Are you sure you want to remove this server from the history?
+ Removed from history.
+
+ Server Info
+ Server Note
+ Without value
+ Basic Information
+ Server name:
+ Custom server name:
+ Skywire visor public key:
+ Note:
+ Original note:
+ Personal note:
+ Last time used:
+ Location
+ Country:
+ Country code:
+ Location:
+ Connectivity
+ Current congestion:
+ Regular congestion rating:
+ Current latency:
+ Regular latency rating:
+ Hops needed for connecting with the server:
+ Special Conditions
+ Selected as the current server:
+ Is favorite:
+ Is blocked:
+ Is in history:
+ Entered manually:
+ Has password:
+
+ Enter Password
+ Server password
+ Apply
+ Cancel
+ The change has been made.
+
+ Custom Name
+ Custom Note
+ Custom name
+ Custom note
+ Apply
+ Cancel
+ The change has been made.
+
+ Select apps
+ Protect all apps
+ All applications on the device will be protected. Selections made on the list will be ignored.
+ Protect selected apps only
+ Only the applications that were selected will be protected. All other applications will use the regular network connection.
+ Do not protect the selected apps
+ Applications that were not selected will be protected. The selected applications will use the regular network connection.
+ Protection mode
+ Apps
+ Installed apps
+ Uninstalled apps
+
+ Settings
+ Protect specific apps
+ Currently the VPN protection covers all your apps. Use this option if you want to protect only some applications or if you want to ignore them.
+ Only %1$s app(s) you have selected will be protected. Press here if you want to change the configuiration.
+ %1$s app(s) you have selected will NOT be protected. Press here if you want to change the configuiration.
+ Show IP info
+ When active, the application will show the public IP address of the device. For this, external services will be used.
+ Kill switch
+ If active, the app will try to block all network connections if the VPN protection is stopped unexpectedly, to ensure privacy. Note: the operating system may prevent this option from working.
+ Reset after errors.
+ If active, the service will be restarted automatically in case of error. You may not have internet connection while the service is being restarted.
+ Protect before finishing connecting.
+ If active, no data will be sent unprotected after you press the connection button, but the internet connection will be unavailable for a moment.
+ Start on boot.
+ The VPN protection will be automatically started shortly after turning on the device.
+ Please select a server before using this option.
+ Data units
+ Bits for all stats
+ Bytes for all stats
+ Bits for speed and bytes for volume (default)
+ Custom DNS server
+ None (use the default configuration).
+ %1$s (the OS and some apps could ignore this configuration in some circumstances)
+
+ Custom DNS Server
+ IP (empty to remove)
+ Apply
+ Cancel
+ Please enter a valid IPv4 address.
+ The change has been made.
+
+ Confirmation
+ Yes
+ No
+
diff --git a/cmd/skywirevisormobile/android/app/src/main/res/values/theme.xml b/cmd/skywirevisormobile/android/app/src/main/res/values/theme.xml
new file mode 100644
index 0000000000..f229da9b34
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/values/theme.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/build.gradle b/cmd/skywirevisormobile/android/build.gradle
new file mode 100644
index 0000000000..973cb31eac
--- /dev/null
+++ b/cmd/skywirevisormobile/android/build.gradle
@@ -0,0 +1,21 @@
+/* Copyright 2015 The Go Authors. All rights reserved.
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file.
+ */
+
+buildscript {
+ repositories {
+ google()
+ jcenter()
+ }
+ dependencies {
+ classpath 'com.android.tools.build:gradle:3.1.0'
+ }
+}
+
+allprojects {
+ repositories {
+ google()
+ jcenter()
+ }
+}
diff --git a/cmd/skywirevisormobile/android/gradle/wrapper/gradle-wrapper.jar b/cmd/skywirevisormobile/android/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000000..f6b961fd5a
Binary files /dev/null and b/cmd/skywirevisormobile/android/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/cmd/skywirevisormobile/android/gradle/wrapper/gradle-wrapper.properties b/cmd/skywirevisormobile/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000000..468e03f990
--- /dev/null
+++ b/cmd/skywirevisormobile/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Wed Jul 22 13:34:46 MSK 2020
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-6.1.1-all.zip
diff --git a/cmd/skywirevisormobile/android/gradlew b/cmd/skywirevisormobile/android/gradlew
new file mode 100644
index 0000000000..cccdd3d517
--- /dev/null
+++ b/cmd/skywirevisormobile/android/gradlew
@@ -0,0 +1,172 @@
+#!/usr/bin/env sh
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS=""
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn () {
+ echo "$*"
+}
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+ NONSTOP* )
+ nonstop=true
+ ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin, switch paths to Windows format before running java
+if $cygwin ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=$((i+1))
+ done
+ case $i in
+ (0) set -- ;;
+ (1) set -- "$args0" ;;
+ (2) set -- "$args0" "$args1" ;;
+ (3) set -- "$args0" "$args1" "$args2" ;;
+ (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Escape application args
+save () {
+ for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+ echo " "
+}
+APP_ARGS=$(save "$@")
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+
+# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
+if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
+ cd "$(dirname "$0")"
+fi
+
+exec "$JAVACMD" "$@"
diff --git a/cmd/skywirevisormobile/android/gradlew.bat b/cmd/skywirevisormobile/android/gradlew.bat
new file mode 100644
index 0000000000..f9553162f1
--- /dev/null
+++ b/cmd/skywirevisormobile/android/gradlew.bat
@@ -0,0 +1,84 @@
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS=
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto init
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto init
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:init
+@rem Get command-line arguments, handling Windows variants
+
+if not "%OS%" == "Windows_NT" goto win9xME_args
+
+:win9xME_args
+@rem Slurp the command line arguments.
+set CMD_LINE_ARGS=
+set _SKIP=2
+
+:win9xME_args_slurp
+if "x%~1" == "x" goto execute
+
+set CMD_LINE_ARGS=%*
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/cmd/skywirevisormobile/android/settings.gradle b/cmd/skywirevisormobile/android/settings.gradle
new file mode 100644
index 0000000000..718aac6c02
--- /dev/null
+++ b/cmd/skywirevisormobile/android/settings.gradle
@@ -0,0 +1,5 @@
+/* Copyright 2015 The Go Authors. All rights reserved.
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file.
+ */
+include ':app'