diff --git a/.gitignore b/.gitignore
index 8883a034b..4cc3f35d3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -49,3 +49,4 @@ cmd/skywirevisormobile/android/app/skywire-sources.jar
cmd/skywirevisormobile/android/app/.idea
cmd/skywirevisormobile/android/app/build
cmd/skywirevisormobile/android/.gradle
+cmd/skywirevisormobile/android/local.properties
diff --git a/cmd/skywirevisormobile/android/app/build.gradle b/cmd/skywirevisormobile/android/app/build.gradle
index ec8487b2a..debee173b 100644
--- a/cmd/skywirevisormobile/android/app/build.gradle
+++ b/cmd/skywirevisormobile/android/app/build.gradle
@@ -9,15 +9,16 @@ repositories {
flatDir {
dirs '.'
}
+ maven { url 'https://jitpack.io' }
}
android {
- compileSdkVersion 27
+ compileSdkVersion 29
defaultConfig {
applicationId "com.skywire.skycoin.vpn"
- minSdkVersion 26
- targetSdkVersion 27
+ minSdkVersion 21
+ targetSdkVersion 29
versionCode 1
versionName "1.0"
}
@@ -34,6 +35,27 @@ android {
dependencies {
- implementation 'com.android.support:appcompat-v7:27.1.1'
+ // 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
index 157c63ad1..f0a71f93f 100644
--- a/cmd/skywirevisormobile/android/app/src/main/AndroidManifest.xml
+++ b/cmd/skywirevisormobile/android/app/src/main/AndroidManifest.xml
@@ -1,17 +1,30 @@
-
-
+
+
-
+
+
+
+
+
+
+
+
@@ -19,14 +32,23 @@
+ android:name=".activities.index.IndexActivity"
+ android:configChanges="keyboardHidden"
+ android:launchMode="singleTask"
+ android:label="@string/general_app_name" >
+
+
+
+
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 000000000..e3627d5d3
--- /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/FromVPNClientRunnable.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/FromVPNClientRunnable.java
deleted file mode 100644
index 41f592fec..000000000
--- a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/FromVPNClientRunnable.java
+++ /dev/null
@@ -1,98 +0,0 @@
-package com.skywire.skycoin.vpn;
-
-import java.io.File;
-import java.io.FileOutputStream;
-import java.nio.ByteBuffer;
-import java.nio.channels.DatagramChannel;
-import java.util.concurrent.TimeUnit;
-
-import skywiremob.Skywiremob;
-
-public class FromVPNClientRunnable implements Runnable {
- private FileOutputStream out;
- private DatagramChannel tunnel;
-
- /** Time to wait in between losing the connection and retrying. */
- private static final long RECONNECT_WAIT_MS = TimeUnit.SECONDS.toMillis(3);
- /** Time between keepalives if there is no traffic at the moment.
- *
- * TODO: don't do this; it's much better to let the connection die and then reconnect when
- * necessary instead of keeping the network hardware up for hours on end in between.
- **/
- private static final long KEEPALIVE_INTERVAL_MS = TimeUnit.SECONDS.toMillis(15);
- /** Time to wait without receiving any response before assuming the server is gone. */
- private static final long RECEIVE_TIMEOUT_MS = TimeUnit.SECONDS.toMillis(20);
- /**
- * Time between polling the VPN interface for new traffic, since it's non-blocking.
- *
- * TODO: really don't do this; a blocking read on another thread is much cleaner.
- */
- private static final long IDLE_INTERVAL_MS = TimeUnit.MILLISECONDS.toMillis(100);
-
- private final Object StopMx = new Object();
- private boolean shouldStop = false;
-
- public FromVPNClientRunnable(FileOutputStream out, DatagramChannel tunnel) {
- this.out = out;
- this.tunnel = tunnel;
- }
-
- public void Stop() {
- synchronized (StopMx) {
- shouldStop = true;
- }
- }
-
- @Override
- public void run() {
- android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_BACKGROUND);
- ByteBuffer packet = ByteBuffer.allocate(Short.MAX_VALUE);
-
- boolean idle = true;
- long lastSendTime = System.currentTimeMillis();
- long lastReceiveTime = System.currentTimeMillis();
- while (true) {
- synchronized (StopMx) {
- if (shouldStop) {
- break;
- }
- }
-
- try {
- 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();
- // There might be more incoming packets.
- idle = false;
- lastSendTime = System.currentTimeMillis();
- }
-
- if (idle) {
- Thread.sleep(IDLE_INTERVAL_MS);
- final long timeNow = System.currentTimeMillis();
- if (lastSendTime + KEEPALIVE_INTERVAL_MS <= timeNow) {
- // We are receiving for a long time but not sending.
- // Send empty control messages.
- packet.put((byte) 0).limit(1);
- for (int i = 0; i < 3; ++i) {
- packet.position(0);
- tunnel.write(packet);
- }
- packet.clear();
- lastSendTime = timeNow;
- } else if (lastReceiveTime + RECEIVE_TIMEOUT_MS <= timeNow) {
- // We are sending for a long time but not receiving.
- throw new IllegalStateException("Timed out");
- }
- }
- } catch (Exception e) {
- Skywiremob.printString("EXCEPTION IN FromVPNClientRunnable: " + e.getMessage());
- }
- }
- }
-}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/MainActivity.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/MainActivity.java
deleted file mode 100644
index 6a6eced0b..000000000
--- a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/MainActivity.java
+++ /dev/null
@@ -1,120 +0,0 @@
-/*
- * 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.
- */
-
-package com.skywire.skycoin.vpn;
-
-import android.app.Activity;
-import android.content.Intent;
-import android.net.VpnService;
-import android.os.Bundle;
-import android.os.Handler;
-import android.os.Looper;
-import android.os.Message;
-import android.util.Log;
-import android.view.View;
-import android.widget.Button;
-import android.widget.EditText;
-import android.widget.TextView;
-import android.widget.Toast;
-
-import skywiremob.Skywiremob;
-
-public class MainActivity extends Activity implements Handler.Callback {
-
- private EditText mRemotePK;
- private EditText mPasscode;
- private Button mStart;
- private Button mStop;
-
- private final Object visorMx = new Object();
- private VisorRunnable visor = null;
-
- @Override
- public boolean handleMessage(Message msg) {
- String err = msg.getData().getString("text");
- showToast(err);
- return false;
- }
-
- public void showToast(String text) {
- Toast toast = Toast.makeText(getApplicationContext(), text, Toast.LENGTH_SHORT);
- toast.show();
- }
-
- public void startVPNService() {
- Intent intent = VpnService.prepare(MainActivity.this);
- if (intent != null) {
- startActivityForResult(intent, 0);
- } else {
- onActivityResult(0, RESULT_OK, null);
- }
- }
-
- @Override
- protected void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- setContentView(R.layout.activity_main);
- mRemotePK = (EditText) findViewById(R.id.editTextRemotePK);
- mPasscode = (EditText) findViewById(R.id.editTextPasscode);
- mStart = (Button) findViewById(R.id.buttonStart);
- mStop = (Button)findViewById(R.id.buttonStop);
-
- mStart.setOnClickListener(new View.OnClickListener() {
- @Override
- public void onClick(View v) {
- String remotePK = mRemotePK.getText().toString();
- String passcode = mPasscode.getText().toString();
-
- skywiremob.Error err = Skywiremob.isPKValid(remotePK);
- if (err.getCode() != Skywiremob.ErrCodeNoError) {
- Toast toast = Toast.makeText(getApplicationContext(),
- "Invalid credentials" , Toast.LENGTH_SHORT);
- toast.show();
- return;
- } else {
- Skywiremob.printString("PK is correct");
- }
-
- synchronized (visorMx) {
- if (visor != null) {
- visor.stopVisor();
- visor = null;
- stopService(getServiceIntent().setAction(SkywireVPNService.ACTION_DISCONNECT));
- }
-
- visor = new VisorRunnable(getApplicationContext(), MainActivity.this,
- remotePK, passcode);
-
- new Thread(visor).start();
- }
- }
- });
-
- mStop.setOnClickListener(new View.OnClickListener() {
- @Override
- public void onClick(View v) {
- startService(getServiceIntent().setAction(SkywireVPNService.ACTION_DISCONNECT));
-
- synchronized (visorMx) {
- if (visor != null) {
- visor.stopVisor();
- }
- }
- }
- });
- }
-
- @Override
- protected void onActivityResult(int request, int result, Intent data) {
- if (result == RESULT_OK) {
- startService(getServiceIntent().setAction(SkywireVPNService.ACTION_CONNECT));
- }
- }
-
- private Intent getServiceIntent() {
- return new Intent(this, SkywireVPNService.class);
- }
-}
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 000000000..99aae2322
--- /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/SkywireVPNConnection.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/SkywireVPNConnection.java
deleted file mode 100644
index d1170e23e..000000000
--- a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/SkywireVPNConnection.java
+++ /dev/null
@@ -1,226 +0,0 @@
-package com.skywire.skycoin.vpn;
-
-import android.app.PendingIntent;
-import android.content.pm.PackageManager;
-import android.net.Network;
-import android.net.ProxyInfo;
-import android.net.VpnService;
-import android.os.ParcelFileDescriptor;
-import android.system.OsConstants;
-import android.text.TextUtils;
-
-import java.io.FileInputStream;
-import java.io.FileOutputStream;
-import java.io.IOException;
-import java.net.InetSocketAddress;
-import java.net.Socket;
-import java.net.SocketAddress;
-import java.net.SocketException;
-import java.nio.ByteBuffer;
-import java.nio.channels.DatagramChannel;
-import java.util.Set;
-import java.util.concurrent.TimeUnit;
-
-import skywiremob.Skywiremob;
-
-public class SkywireVPNConnection implements Runnable {
- /**
- * Callback interface to let the {@link SkywireVPNService} know about new connections
- * and update the foreground notification with connection status.
- */
- public interface OnEstablishListener {
- void onEstablish(ParcelFileDescriptor tunInterface);
- }
- /** Maximum packet size is constrained by the MTU, which is given as a signed short. */
- private static final int MAX_PACKET_SIZE = Short.MAX_VALUE;
- /** Time to wait in between losing the connection and retrying. */
- private static final long RECONNECT_WAIT_MS = TimeUnit.SECONDS.toMillis(3);
- /** Time between keepalives if there is no traffic at the moment.
- *
- * TODO: don't do this; it's much better to let the connection die and then reconnect when
- * necessary instead of keeping the network hardware up for hours on end in between.
- **/
- private static final long KEEPALIVE_INTERVAL_MS = TimeUnit.SECONDS.toMillis(15);
- /** Time to wait without receiving any response before assuming the server is gone. */
- private static final long RECEIVE_TIMEOUT_MS = TimeUnit.SECONDS.toMillis(20);
- /**
- * Time between polling the VPN interface for new traffic, since it's non-blocking.
- *
- * TODO: really don't do this; a blocking read on another thread is much cleaner.
- */
- private static final long IDLE_INTERVAL_MS = TimeUnit.MILLISECONDS.toMillis(100);
- /**
- * Number of periods of length {@IDLE_INTERVAL_MS} to wait before declaring the handshake a
- * complete and abject failure.
- *
- * TODO: use a higher-level protocol; hand-rolling is a fun but pointless exercise.
- */
- private static final int MAX_HANDSHAKE_ATTEMPTS = 50;
- private final VpnService mService;
- private final int mConnectionId;
- private final String mServerName;
- private final int mServerPort;
- private PendingIntent mConfigureIntent;
- private OnEstablishListener mOnEstablishListener;
- private FromVPNClientRunnable fromVPNClientRunnable;
-
- private final Object StopMx = new Object();
- private boolean shouldStop = false;
-
- public void Stop() {
- synchronized (StopMx) {
- shouldStop = true;
- }
-
- fromVPNClientRunnable.Stop();
- }
- // Allowed/Disallowed packages for VPN usage
- //private final boolean mAllow;
- //private final Set mPackages;
- public SkywireVPNConnection(final VpnService service, final int connectionId,
- final String serverName, final int serverPort/*, final byte[] sharedSecret,
- boolean allow,
- final Set packages*/) {
- mService = service;
- mConnectionId = connectionId;
- mServerName = serverName;
- mServerPort= serverPort;
- //mAllow = allow;
- //mPackages = packages;
- }
- /**
- * Optionally, set an intent to configure the VPN. This is {@code null} by default.
- */
- public void setConfigureIntent(PendingIntent intent) {
- mConfigureIntent = intent;
- }
- public void setOnEstablishListener(OnEstablishListener listener) {
- mOnEstablishListener = listener;
- }
- @Override
- public void run() {
- try {
- Skywiremob.printString(getTag() + " Starting");
- // If anything needs to be obtained using the network, get it now.
- // This greatly reduces the complexity of seamless handover, which
- // tries to recreate the tunnel without shutting down everything.
- // In this demo, all we need to know is the server address.
- final SocketAddress serverAddress = new InetSocketAddress(mServerName, mServerPort);
- // We try to create the tunnel several times.
- // TODO: The better way is to work with ConnectivityManager, trying only when the
- // network is available.
- // Here we just use a counter to keep things simple.
- for (int attempt = 0; attempt < 10; ++attempt) {
- // Reset the counter if we were connected.
- if (run(serverAddress)) {
- attempt = 0;
- }
- // Sleep for a while. This also checks if we got interrupted.
- Thread.sleep(3000);
- }
- Skywiremob.printString(getTag() + " Giving");
- } catch (IOException | InterruptedException | IllegalArgumentException e) {
- Skywiremob.printString(getTag() + " Connection failed, exiting " + e.getMessage());
- }
- }
- private boolean run(SocketAddress server)
- throws IOException, InterruptedException, IllegalArgumentException {
- ParcelFileDescriptor iface = null;
- boolean connected = false;
-
- // Create a DatagramChannel as the VPN tunnel.
- try (DatagramChannel tunnel = DatagramChannel.open()) {
- // Protect the tunnel before connecting to avoid loopback.
- if (!mService.protect(tunnel.socket())) {
- throw new IllegalStateException("Cannot protect the tunnel");
- }
- for (int fd = (int)Skywiremob.nextDmsgSocket(); fd != 0; fd = (int)Skywiremob.nextDmsgSocket()) {
- Skywiremob.printString("PRINTING FD " + fd);
- if (!mService.protect(fd)) {
- throw new IllegalStateException("Cannot protect the tunnel");
- }
- }
- // Connect to the server.
- tunnel.connect(server);
-
- Skywiremob.setMobileAppAddr(tunnel.getLocalAddress().toString());
-
- // For simplicity, we use the same thread for both reading and
- // writing. Here we put the tunnel into non-blocking mode.
- tunnel.configureBlocking(false);
- // Configure the virtual network interface.
- iface = configure();
- // Now we are connected. Set the flag.
- connected = true;
- // Packets to be sent are queued in this input stream.
- FileInputStream in = new FileInputStream(iface.getFileDescriptor());
- // Packets received need to be written to this output stream.
- FileOutputStream out = new FileOutputStream(iface.getFileDescriptor());
-
- this.fromVPNClientRunnable = new FromVPNClientRunnable(out, tunnel);
- new Thread(this.fromVPNClientRunnable).start();
- // Allocate the buffer for a single packet.
- ByteBuffer packet = ByteBuffer.allocate(MAX_PACKET_SIZE);
- // We keep forwarding packets till something goes wrong.
- Skywiremob.printString("Start forwarding packets on Android");
- while (true) {
- synchronized (StopMx) {
- if (shouldStop) {
- break;
- }
- }
-
- // Assume that we did not make any progress in this iteration.
- // Read the outgoing packet from the input stream.
- int length = in.read(packet.array());
- if (length > 0) {
- // Write the outgoing packet to the tunnel.
- packet.limit(length);
- tunnel.write(packet);
- packet.clear();
- }
- }
- } catch (SocketException e) {
- Skywiremob.printString(getTag() + " Cannot use socket " + e.getMessage());
- } finally {
- if (iface != null) {
- try {
- iface.close();
- } catch (IOException e) {
- Skywiremob.printString(getTag() + " Unable to close interface " + e.getMessage());
- }
- }
- }
- return connected;
- }
-
- private ParcelFileDescriptor configure() throws IllegalArgumentException {
- // Configure a builder while parsing the parameters.
- VpnService.Builder builder = mService.new Builder();
-
- builder.setMtu((short)Skywiremob.getMTU());
- Skywiremob.printString("TUN IP: " + Skywiremob.tunip());
- builder.addAddress(Skywiremob.tunip(), (int)Skywiremob.getTUNIPPrefix());
- builder.allowFamily(OsConstants.AF_INET);
- builder.addDnsServer("8.8.8.8");
- //builder.addDnsServer("192.168.1.1");
- builder.addRoute("0.0.0.0", 1);
- builder.addRoute("128.0.0.0", 1);
-
- // Create a new interface using the builder and save the parameters.
- final ParcelFileDescriptor vpnInterface;
-
- builder.setSession(mServerName).setConfigureIntent(mConfigureIntent);
- synchronized (mService) {
- vpnInterface = builder.establish();
- if (mOnEstablishListener != null) {
- mOnEstablishListener.onEstablish(vpnInterface);
- }
- }
- Skywiremob.printString(getTag() + " New interface: " + vpnInterface);
- return vpnInterface;
- }
- private final String getTag() {
- return SkywireVPNConnection.class.getSimpleName() + "[" + mConnectionId + "]";
- }
-}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/SkywireVPNService.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/SkywireVPNService.java
deleted file mode 100644
index 9aa55bfa3..000000000
--- a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/SkywireVPNService.java
+++ /dev/null
@@ -1,154 +0,0 @@
-package com.skywire.skycoin.vpn;;
-
-import android.app.Notification;
-import android.app.NotificationChannel;
-import android.app.NotificationManager;
-import android.app.PendingIntent;
-import android.content.Intent;
-import android.content.SharedPreferences;
-import android.net.VpnService;
-import android.os.Handler;
-import android.os.Message;
-import android.os.ParcelFileDescriptor;
-import android.util.Pair;
-import android.widget.Toast;
-
-import java.io.IOException;
-import java.util.Collections;
-import java.util.Set;
-import java.util.concurrent.atomic.AtomicInteger;
-import java.util.concurrent.atomic.AtomicReference;
-
-import skywiremob.Skywiremob;
-
-public class SkywireVPNService extends VpnService implements Handler.Callback {
- public static final String ACTION_CONNECT = "com.skywire.android.vpn.START";
- public static final String ACTION_DISCONNECT = "com.skywire.android.vpn.STOP";
- private SkywireVPNConnection connectionRunnable;
-
- private static final String TAG = SkywireVPNService.class.getSimpleName();
- private Handler mHandler;
- private static class Connection extends Pair {
- public Connection(Thread thread, ParcelFileDescriptor pfd) {
- super(thread, pfd);
- }
- }
- private final AtomicReference mConnectingThread = new AtomicReference<>();
- private final AtomicReference mConnection = new AtomicReference<>();
- private AtomicInteger mNextConnectionId = new AtomicInteger(1);
- private PendingIntent mConfigureIntent;
-
- @Override
- public void onCreate() {
- // The handler is only used to show messages.
- if (mHandler == null) {
- mHandler = new Handler(this);
- }
- // Create the intent to "configure" the connection (just start SkywireVPNClient).
- mConfigureIntent = PendingIntent.getActivity(this, 0, new Intent(this, MainActivity.class),
- PendingIntent.FLAG_UPDATE_CURRENT);
- }
-
- @Override
- public int onStartCommand(Intent intent, int flags, int startId) {
- if (intent != null && ACTION_DISCONNECT.equals(intent.getAction())) {
- Skywiremob.printString("STOPPING ANDROID VPN SERVICE");
- disconnect();
- return START_NOT_STICKY;
- } else {
- Skywiremob.printString("STARTING ANDROID VPN SERVICE");
- connect();
- return START_STICKY;
- }
- }
-
- @Override
- public void onDestroy() {
- disconnect();
- }
-
- @Override
- public boolean handleMessage(Message message) {
- Toast.makeText(this, message.what, Toast.LENGTH_SHORT).show();
- if (message.what != R.string.disconnected) {
- updateForegroundNotification(message.what);
- }
- return true;
- }
-
- private void connect() {
- // 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().
- updateForegroundNotification(R.string.connecting);
- mHandler.sendEmptyMessage(R.string.connecting);
-
- try {
- while (!Skywiremob.isVPNReady()) {
- Skywiremob.printString("VPN STILL NOT READY, WAITING...");
- Thread.sleep(1000);
- }
- } catch (Exception e) {
- Skywiremob.printString(e.getMessage());
- }
-
- Skywiremob.printString("VPN IS READY, LET'S TRY IT OUT");
-
- startConnection(new SkywireVPNConnection(
- this, mNextConnectionId.getAndIncrement(), "localhost", 7890));
- }
-
- private void startConnection(final SkywireVPNConnection connection) {
- this.connectionRunnable = connection;
- // Replace any existing connecting thread with the new one.
- final Thread thread = new Thread(connection, "SkywireVPNThread");
- setConnectingThread(thread);
- // Handler to mark as connected once onEstablish is called.
- connection.setConfigureIntent(mConfigureIntent);
- connection.setOnEstablishListener(tunInterface -> {
- mHandler.sendEmptyMessage(R.string.connected);
- mConnectingThread.compareAndSet(thread, null);
- setConnection(new Connection(thread, tunInterface));
- });
- thread.start();
- }
-
- private void setConnectingThread(final Thread thread) {
- final Thread oldThread = mConnectingThread.getAndSet(thread);
- if (oldThread != null) {
- oldThread.interrupt();
- }
- }
- private void setConnection(final Connection connection) {
- final Connection oldConnection = mConnection.getAndSet(connection);
- if (oldConnection != null) {
- try {
- oldConnection.first.interrupt();
- oldConnection.second.close();
- } catch (IOException e) {
- Skywiremob.printString(TAG + " Closing VPN interface " + e.getMessage());
- }
- }
- }
- private void disconnect() {
- mHandler.sendEmptyMessage(R.string.disconnected);
- if (connectionRunnable != null) {
- connectionRunnable.Stop();
- }
- setConnectingThread(null);
- setConnection(null);
- stopForeground(true);
- }
- private void updateForegroundNotification(final int message) {
- final String NOTIFICATION_CHANNEL_ID = "SkywireVPN";
- NotificationManager mNotificationManager = (NotificationManager) getSystemService(
- NOTIFICATION_SERVICE);
- mNotificationManager.createNotificationChannel(new NotificationChannel(
- NOTIFICATION_CHANNEL_ID, NOTIFICATION_CHANNEL_ID,
- NotificationManager.IMPORTANCE_DEFAULT));
- startForeground(1, new Notification.Builder(this, NOTIFICATION_CHANNEL_ID)
- .setSmallIcon(R.drawable.ic_vpn)
- .setContentText(getString(message))
- .setContentIntent(mConfigureIntent)
- .build());
- }
-}
diff --git a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/VisorRunnable.java b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/VisorRunnable.java
deleted file mode 100644
index bf1567502..000000000
--- a/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/VisorRunnable.java
+++ /dev/null
@@ -1,116 +0,0 @@
-package com.skywire.skycoin.vpn;
-
-import android.content.Context;
-import android.os.Bundle;
-import android.os.Handler;
-import android.os.Looper;
-import android.os.Message;
-import android.widget.Toast;
-
-import java.io.IOException;
-import java.io.OutputStream;
-import java.io.PrintWriter;
-import java.net.Socket;
-
-import skywiremob.Skywiremob;
-
-public class VisorRunnable implements Runnable {
- private Context context;
- private MainActivity activity;
- private String RemotePK;
- private String Passcode;
-
- public VisorRunnable(Context context, MainActivity activity, String remotePK, String passcode) {
- this.context = context;
- this.activity = activity;
- this.RemotePK = remotePK;
- this.Passcode = passcode;
- }
-
- public void stopVisor() {
- skywiremob.Error err = Skywiremob.stopVisor();
- if (err.getCode() != Skywiremob.ErrCodeNoError) {
- Skywiremob.printString(err.getError());
- showToast(err.getError());
- }
- }
-
- private void showToast(String text) {
- activity.runOnUiThread(new Runnable() {
- public void run() {
- Toast.makeText(activity, text, Toast.LENGTH_SHORT).show();
- }
- });
- }
-
- @Override
- public void run() {
- android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_BACKGROUND);
-
- skywiremob.Error err = Skywiremob.prepareVisor();
- if (err.getCode() != Skywiremob.ErrCodeNoError) {
- Skywiremob.printString(err.getError());
- showToast(err.getError());
- return;
- }
- Skywiremob.printString("Prepared visor");
-
- err = Skywiremob.waitVisorReady();
- if (err.getCode() != Skywiremob.ErrCodeNoError) {
- Skywiremob.printString(err.getError());
- showToast(err.getError());
- return;
- }
-
- err = Skywiremob.prepareVPNClient(this.RemotePK, this.Passcode);
- if (err.getCode() != Skywiremob.ErrCodeNoError) {
- Skywiremob.printString(err.getError());
- showToast(err.getError());
- return;
- }
- Skywiremob.printString("Prepared VPN client");
-
- err = Skywiremob.shakeHands();
- if (err.getCode() != Skywiremob.ErrCodeNoError) {
- Skywiremob.printString(err.getError());
- showToast(err.getError());
- return;
- }
-
- err = Skywiremob.startListeningUDP();
- if (err.getCode() != Skywiremob.ErrCodeNoError) {
- Skywiremob.printString(err.getError());
- showToast(err.getError());
- return;
- }
-
- err = Skywiremob.serveVPN();
- if (err.getCode() != Skywiremob.ErrCodeNoError) {
- String errMsg = "Failed to serve VPN: " + err.getError();
- Skywiremob.printString(errMsg);
- showToast(errMsg);
- return;
- }
-
- try {
- Skywiremob.printString("VPN IS READY, SLEEPING...");
- Thread.sleep(1 * 1000 * 10);
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
-
-
- activity.runOnUiThread(new Runnable() {
- public void run() {
- activity.startVPNService();
- }
- });
-
- /*err = Skywiremob.waitForVisorToStop();
- if (!err.isEmpty()) {
- Skywiremob.printString(err);
- showToast(err);
- return;
- }*/
- }
-}
\ No newline at end of file
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 000000000..02f8ae5bf
--- /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 000000000..52431579f
--- /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 000000000..1e6888c08
--- /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 000000000..6c51f7183
--- /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 000000000..c89673f17
--- /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 000000000..72defac1c
--- /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 000000000..dfa267d28
--- /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 000000000..08905acac
--- /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 000000000..222be391b
--- /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 000000000..987dbc6a1
--- /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 000000000..cd0811891
--- /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 000000000..b67c30fa0
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/servers/ServerListButton.java
@@ -0,0 +1,163 @@
+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 com.skywire.skycoin.vpn.objects.ServerRatings;
+
+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 textLatency;
+ private TextView textCongestion;
+ private TextView textHops;
+ private TextView textLatencyRating;
+ private TextView textCongestionRating;
+ private TextView textNote;
+ private TextView textPersonalNote;
+ private LinearLayout statsArea1;
+ private LinearLayout statsArea2;
+ private LinearLayout noteArea;
+ private LinearLayout personalNoteArea;
+ private SettingsButton buttonSettings;
+
+ 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);
+ 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);
+ textNote = this.findViewById (R.id.textNote);
+ textPersonalNote = this.findViewById (R.id.textPersonalNote);
+ statsArea1 = this.findViewById (R.id.statsArea1);
+ statsArea2 = this.findViewById (R.id.statsArea2);
+ noteArea = this.findViewById (R.id.noteArea);
+ personalNoteArea = this.findViewById (R.id.personalNoteArea);
+ buttonSettings = this.findViewById (R.id.buttonSettings);
+
+ 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);
+ }
+
+ 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);
+ }
+
+ 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 000000000..be47576a0
--- /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 000000000..89dbfa432
--- /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 000000000..4ec472654
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/servers/ServerListTableHeader.java
@@ -0,0 +1,38 @@
+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;
+ 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);
+ statsArea = this.findViewById (R.id.statsArea);
+ }
+
+ public void setListType(ServerLists listType) {
+ 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 000000000..aac710893
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/servers/ServerListTableRow.java
@@ -0,0 +1,148 @@
+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 com.skywire.skycoin.vpn.objects.ServerRatings;
+
+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 ImageView imageCongestionRating;
+ private ImageView imageLatencyRating;
+ private ServerName serverName;
+ private TextView textDate;
+ private TextView textLocation;
+ private TextView textPk;
+ private TextView textCongestion;
+ private TextView textLatency;
+ private TextView textHops;
+ private LinearLayout statsArea;
+ private SettingsButton buttonNote;
+ private SettingsButton buttonSettings;
+
+ 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);
+ imageCongestionRating = this.findViewById (R.id.imageCongestionRating);
+ imageLatencyRating = this.findViewById (R.id.imageLatencyRating);
+ serverName = this.findViewById (R.id.serverName);
+ textDate = this.findViewById (R.id.textDate);
+ textLocation = this.findViewById (R.id.textLocation);
+ textPk = this.findViewById (R.id.textPk);
+ textCongestion = this.findViewById (R.id.textCongestion);
+ textLatency = this.findViewById (R.id.textLatency);
+ textHops = this.findViewById (R.id.textHops);
+ statsArea = this.findViewById (R.id.statsArea);
+ buttonNote = this.findViewById (R.id.buttonNote);
+ buttonSettings = this.findViewById (R.id.buttonSettings);
+
+ 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);
+ }
+
+ 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);
+ }
+
+ 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 000000000..8be2b43c7
--- /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 000000000..48c6b2b80
--- /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 000000000..c693a0d1d
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/servers/ServersActivity.java
@@ -0,0 +1,428 @@
+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.objects.LocalServerData;
+import com.skywire.skycoin.vpn.objects.ServerFlags;
+import com.skywire.skycoin.vpn.objects.ServerRatings;
+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()
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(response -> {
+ 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 000000000..60340da71
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/servers/VpnServerForList.java
@@ -0,0 +1,26 @@
+package com.skywire.skycoin.vpn.activities.servers;
+
+import com.skywire.skycoin.vpn.objects.ServerFlags;
+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 double congestion;
+ public ServerRatings congestionRating;
+ public double latency;
+ public ServerRatings latencyRating;
+ public int hops;
+ public String note;
+ public String personalNote;
+ public Date lastUsed;
+ public boolean inHistory;
+ public ServerFlags flag;
+ public boolean hasPassword;
+ public boolean enteredManually;
+}
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 000000000..8297b01ad
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/activities/servers/VpnServersAdapter.java
@@ -0,0 +1,545 @@
+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.objects.ServerRatings;
+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,
+ CONGESTION,
+ CONGESTION_RATING,
+ LATENCY,
+ LATENCY_RATING,
+ HOPS,
+ NOTE;
+
+ public static int getColumnNameId(SortableColumns column) {
+ 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;
+ } 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 : ""));
+ } 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.COUNTRY);
+ optionValues.add(SortableColumns.LOCATION);
+ optionValues.add(SortableColumns.PK);
+ 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 000000000..9a5639faa
--- /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 000000000..e079385c5
--- /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 000000000..8aaf37e96
--- /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 000000000..142647ecd
--- /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 000000000..36299e3b6
--- /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 000000000..f20ea4fa7
--- /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 000000000..1c4489493
--- /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 000000000..d4129b09b
--- /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 000000000..b956b2fd7
--- /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 000000000..af847828d
--- /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 000000000..7ba05604e
--- /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 000000000..67d59299e
--- /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 000000000..9df7c76f0
--- /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 000000000..868c713d2
--- /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 000000000..42ea08585
--- /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 000000000..78939294e
--- /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 000000000..cea138f40
--- /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 000000000..e11fa85e9
--- /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 000000000..cab1ee98c
--- /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 000000000..cfb280343
--- /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 000000000..762fe9550
--- /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 000000000..9f3f8b949
--- /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 000000000..a72175233
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/controls/ServerInfoModalWindow.java
@@ -0,0 +1,265 @@
+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.objects.ServerRatings;
+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;
+
+ 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);
+
+ 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);
+
+ 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();
+ }
+
+ 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 000000000..eabbcf599
--- /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 000000000..92007f909
--- /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 000000000..b2407652b
--- /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 000000000..b3a873540
--- /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 000000000..28dba6efa
--- /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 000000000..cc8d16bef
--- /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 000000000..1b4503e73
--- /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 000000000..19ef475e1
--- /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 000000000..4938b3e21
--- /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 000000000..2b1af828b
--- /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 000000000..03369d5ce
--- /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 000000000..ff8903cec
--- /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 000000000..0f7075fba
--- /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 000000000..03c82af9d
--- /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 000000000..2de332ff9
--- /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 000000000..bbac77675
--- /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 000000000..2bcfac359
--- /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 000000000..c3fe237e0
--- /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 000000000..9839df1fa
--- /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 000000000..b4b9339fa
--- /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 000000000..a8ffd434e
--- /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 000000000..285b53ad4
--- /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 000000000..d1942445a
--- /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 000000000..a32c4989c
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/helpers/HelperFunctions.java
@@ -0,0 +1,660 @@
+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;
+ }
+ }
+
+ 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 000000000..f4d5f6ce1
--- /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 000000000..a0e7501dc
--- /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 000000000..e1883ed10
--- /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 000000000..9b385f3b0
--- /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 000000000..d8c067163
--- /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 000000000..e797c1fdf
--- /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 000000000..b54869a88
--- /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 000000000..518e1fc39
--- /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 000000000..2255fbd22
--- /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 000000000..3a2ea5fc6
--- /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 000000000..8daf4639f
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/java/com/skywire/skycoin/vpn/objects/ServerRatings.java
@@ -0,0 +1,37 @@
+package com.skywire.skycoin.vpn.objects;
+
+import com.skywire.skycoin.vpn.R;
+
+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 000000000..fca375bd8
--- /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 000000000..ad5ebe9b9
--- /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 000000000..5689830d4
--- /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 000000000..9ebee7bff
--- /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 000000000..e15b58c19
--- /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 000000000..8eab908b0
--- /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 000000000..3aaf0d4fc
--- /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 000000000..dd50fbc1c
--- /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 000000000..d611cb0dc
--- /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 000000000..a5614e533
--- /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 000000000..582fa05dd
--- /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 000000000..103fd5037
--- /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 000000000..69db4edda
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 000000000..59c43cd71
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 000000000..33f259744
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 000000000..89a0a368f
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 000000000..6acb77df2
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 000000000..d8d214fe7
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 000000000..8324a2152
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 000000000..ced82888a
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 000000000..6d1f22327
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 000000000..1a838ed16
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 000000000..22000faeb
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 000000000..1218ddbc9
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 000000000..ffb342a49
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 000000000..7a15f38a0
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 000000000..0c92d539d
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 000000000..82ffb169a
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 000000000..7b12b7214
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 000000000..9e316d905
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 000000000..7879ab7af
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 000000000..48e08fb9c
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 000000000..611293b97
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 000000000..59b2b8d3f
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 000000000..750da0651
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 000000000..5161bbedd
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 000000000..efa0c2692
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 000000000..a03bd0560
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 000000000..5eb0dc7ef
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 000000000..541907e23
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 000000000..3a938889f
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 000000000..5f9149efe
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 000000000..9f06c8196
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 000000000..9673ab5a7
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 000000000..6741cf021
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 000000000..bb20cc9fb
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 000000000..705ef7e35
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 000000000..35c2ba83e
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 000000000..fb2f8fff1
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 000000000..afa5aeb25
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 000000000..aba79a4b2
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 000000000..7260b4c39
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 000000000..211a163b6
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 000000000..34c9a8787
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 000000000..1c1c86da3
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 000000000..2e6f20ebb
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 000000000..0143a672c
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 000000000..2be4c18cf
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 000000000..4e338c132
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 000000000..731bfc6fb
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 000000000..07f9f34d0
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 000000000..c9ff046d8
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 000000000..cde0a5f54
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 000000000..4f3ffd885
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 000000000..161cdf1ed
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 000000000..3790ae517
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 000000000..2dc34b57e
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 000000000..0433ff7e6
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 000000000..3913150b9
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 000000000..8e30e9d7e
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 000000000..7fa87e53d
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 000000000..6a404186f
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 000000000..5cb42801c
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 000000000..11eeb917a
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 000000000..6a2b9b38f
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 000000000..1e39d2ca7
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 000000000..447f8714e
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 000000000..df96cfaaa
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 000000000..160bb489b
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 000000000..b71fb9ea2
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 000000000..2365ec845
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 000000000..44c79e270
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 000000000..1c0c60eca
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 000000000..6280f4d7e
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 000000000..db3f7db47
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 000000000..3ff60b082
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 000000000..6eea46fbc
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 000000000..7fb94f958
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 000000000..dfba39205
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 000000000..85e5bf86b
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 000000000..0ae8eecac
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 000000000..2a4b9ae04
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 000000000..c3a6e9802
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 000000000..22ba31363
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 000000000..d1bba3ae7
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 000000000..338a34700
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 000000000..e5861dac0
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 000000000..a8dacd0ab
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 000000000..c144450d4
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 000000000..9c75d7abe
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 000000000..7731a55da
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 000000000..9c05b1e26
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 000000000..0e3d16c3f
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 000000000..999588ad3
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 000000000..ffebad314
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 000000000..7f8934b3a
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 000000000..d2dd15ec1
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 000000000..c5d0b2504
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 000000000..8f10041e3
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/gh.png b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/gh.png
new file mode 100644
index 000000000..fcfb29b0c
Binary files /dev/null and b/cmd/skywirevisormobile/android/app/src/main/res/drawable-xxhdpi/gh.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 000000000..0f5985b9b
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 000000000..acfe5fd51
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 000000000..154552a59
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 000000000..ecc7aed83
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 000000000..75d7f46e8
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 000000000..f7bf7ac3c
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 000000000..be17af821
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 000000000..73ebc9f07
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 000000000..37c400608
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 000000000..cdb2617c5
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 000000000..387962a6b
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 000000000..46c56f1d2
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 000000000..f3e1bdfec
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 000000000..6bdd23868
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 000000000..5778bc45f
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 000000000..802613371
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 000000000..7a0601c50
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 000000000..d87ba073a
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 000000000..4e22bccc3
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 000000000..49aef004d
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 000000000..5b033849c
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 000000000..02162a117
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 000000000..03300d8be
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 000000000..dc0ac4f87
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 000000000..fbac7587f
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 000000000..c172ad9d8
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 000000000..aa611d797
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 000000000..353b20e74
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 000000000..eb01f5a0b
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 000000000..e80702766
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 000000000..30e353f47
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 000000000..b432601be
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 000000000..c2f2a4a7a
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 000000000..34fb6df58
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 000000000..8ca2eaa1d
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 000000000..b7a89ba59
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 000000000..0b64f4308
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 000000000..338d6de3f
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 000000000..31683b654
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 000000000..86deaad10
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 000000000..0a31fe937
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 000000000..c4238b3b4
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 000000000..0d1377e48
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 000000000..d4d078417
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 000000000..4d81bdef6
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 000000000..971bc3773
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 000000000..26047468c
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 000000000..08f7d6a7e
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 000000000..ef4462988
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 000000000..4d7721d3c
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 000000000..8d861b7ed
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 000000000..2b28a35d3
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 000000000..a6ef25feb
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 000000000..2fedcc1dd
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 000000000..f89c2e001
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 000000000..be057f99b
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 000000000..65fbb0b1a
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 000000000..6a80a7525
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 000000000..332a8e3fc
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 000000000..9eb2cf69c
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 000000000..1d17d4d1b
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 000000000..da1b9abcb
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 000000000..a87c0abdc
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 000000000..d9f9032c8
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 000000000..e8f9d9d1a
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 000000000..f7ba1c3c1
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 000000000..58c74fc67
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 000000000..b723c009e
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 000000000..285f73740
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 000000000..94d50aa75
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 000000000..f2b0ce0b6
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 000000000..755dce208
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 000000000..31eac72d2
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 000000000..d649d1144
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 000000000..fdb43b6de
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 000000000..db64b56c5
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 000000000..1d6521978
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 000000000..4c696bca1
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 000000000..3b188515d
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 000000000..959afa7fd
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 000000000..88c7fcc4e
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 000000000..4d59d5440
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 000000000..6a938c94e
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 000000000..b3d928a85
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 000000000..326de62d1
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 000000000..c9916676d
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 000000000..95223d3ba
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 000000000..082d19410
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 000000000..2ef9bab7d
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 000000000..79939f3c6
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 000000000..fbafa66e2
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 000000000..744008621
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 000000000..000c41d14
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 000000000..998c227ad
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 000000000..a604cdcab
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 000000000..6637d24d6
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 000000000..ec7a4954b
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 000000000..7af069182
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 000000000..7c344ea73
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 000000000..7da5cd418
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 000000000..7a26daea8
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 000000000..38d60e9c1
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 000000000..cec7edebe
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 000000000..84a78cf4b
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 000000000..3dd855689
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 000000000..ff4cd7cb3
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 000000000..70b8505fe
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 000000000..73c053139
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 000000000..9e86166a3
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 000000000..ff0476fa8
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 000000000..190963fd5
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 000000000..e92ecffe8
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 000000000..519821fd8
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 000000000..6fea21812
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 000000000..0bc7c93ce
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 000000000..1e3e8a0d6
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 000000000..670eb8eab
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 000000000..0f2cd6f02
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 000000000..51905cc11
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 000000000..8d860bced
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 000000000..7d898bdbd
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 000000000..06a6bda12
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 000000000..8b813b3a0
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 000000000..ba9ce3eeb
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 000000000..9e2d1caed
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 000000000..582710c29
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 000000000..0fa430c93
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 000000000..d6f48971d
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 000000000..f6b855871
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 000000000..25ce12f15
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 000000000..7f27ab730
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 000000000..e334188fa
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 000000000..991f9b1e0
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 000000000..88df1bf7b
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 000000000..fc7a3a920
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 000000000..03fd23b0e
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 000000000..5c3b062de
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 000000000..d6f69805e
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 000000000..8e39f3810
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 000000000..bf7186fd1
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 000000000..5b83512b6
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 000000000..3b861fc9c
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 000000000..31f5edff1
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 000000000..fdff2b2ce
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 000000000..131956f6d
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 000000000..9dc8fec72
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 000000000..73180f000
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 000000000..558ec3fbf
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 000000000..6003d6f26
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 000000000..e092541af
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 000000000..e092541af
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 000000000..302f6d9ad
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 000000000..7eb9d0541
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 000000000..48eae4583
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 000000000..c98be731d
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 000000000..77e9416de
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 000000000..7f87c68ea
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 000000000..e7eb8f7fb
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 000000000..a290df621
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 000000000..a0d648ffa
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 000000000..06e55d2c1
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 000000000..04adf0673
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 000000000..84834502b
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 000000000..5325964fe
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 000000000..19a0e4a5b
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 000000000..e329e2e18
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 000000000..422ba6436
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 000000000..9cf6b5eaa
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 000000000..fdedbb42e
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 000000000..5ab36c472
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 000000000..fd994a23e
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 000000000..b7bcc6b59
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 000000000..2b8a9282a
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 000000000..f7a2eb9ef
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 000000000..b4144fdf7
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 000000000..4708111a5
--- /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 000000000..e9d2a8c10
--- /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 000000000..ff8e69ee6
--- /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 000000000..a5a4603ff
--- /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 000000000..cd88329f3
--- /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 000000000..31d96f34f
--- /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 000000000..1f2be538e
--- /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 000000000..3df744bab
--- /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 000000000..5ef7f1056
--- /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 000000000..cdac0f21d
--- /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 000000000..85b80d9bf
--- /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 000000000..2ba99e7d1
--- /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 000000000..4c6bbe75e
--- /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/ic_vpn.xml b/cmd/skywirevisormobile/android/app/src/main/res/drawable/ic_vpn.xml
deleted file mode 100644
index a9aa1ec04..000000000
--- a/cmd/skywirevisormobile/android/app/src/main/res/drawable/ic_vpn.xml
+++ /dev/null
@@ -1,30 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
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 000000000..71083003f
--- /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 000000000..26018e3aa
--- /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 000000000..e86d1fe08
--- /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 000000000..5ef7f1056
--- /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 000000000..1218ddbc9
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 000000000..e31574bdf
--- /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 000000000..14d5e6874
--- /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 000000000..142ef1097
--- /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 000000000..2973e01b9
--- /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 000000000..0eb4edfc3
--- /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 000000000..4f9212b84
--- /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 000000000..4708111a5
--- /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 000000000..571fe729c
--- /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 000000000..2574f6f64
--- /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 000000000..3f4b67587
--- /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 000000000..5706222b3
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 000000000..e50801b3b
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 000000000..e3e80f0e4
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 000000000..9ef702074
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 000000000..7c02a596c
--- /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 000000000..a909194a8
--- /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
index 77abf866f..e69786e05 100644
--- a/cmd/skywirevisormobile/android/app/src/main/res/layout/activity_main.xml
+++ b/cmd/skywirevisormobile/android/app/src/main/res/layout/activity_main.xml
@@ -1,44 +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 000000000..cc6bd2059
--- /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 000000000..88ac6fda5
--- /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 000000000..da6dd7034
--- /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 000000000..98388bcf6
--- /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 000000000..517524ee2
--- /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 000000000..f638fb50b
--- /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 000000000..b79be52d7
--- /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 000000000..0177755fc
--- /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 000000000..a666468c9
--- /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 000000000..150d78b34
--- /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 000000000..86549c7e1
--- /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 000000000..edfacb192
--- /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 000000000..87c336014
--- /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 000000000..aaded44e4
--- /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 000000000..8f86789d7
--- /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 000000000..20ffabed2
--- /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 000000000..e517d2609
--- /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 000000000..817cd9072
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_server_info_modal.xml
@@ -0,0 +1,267 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 000000000..9e0803110
--- /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 000000000..e7622df17
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_server_list_item.xml
@@ -0,0 +1,300 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 000000000..9e476cecd
--- /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 000000000..a824756cc
--- /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 000000000..87678a5f3
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_server_list_table_header.xml
@@ -0,0 +1,192 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 000000000..04964ab5e
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/layout/view_server_list_table_row.xml
@@ -0,0 +1,184 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 000000000..2abe148aa
--- /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 000000000..2249d037a
--- /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 000000000..db0587c79
--- /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 000000000..5cd30c2c8
--- /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 000000000..afd95f937
--- /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 000000000..d19f6b3af
--- /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 000000000..0c16845d2
--- /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 000000000..9f061f69c
--- /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 000000000..db1ca7c9e
--- /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 000000000..d475cbab6
--- /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 000000000..3570f9ab8
--- /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 000000000..86f5ee3cf
--- /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 000000000..899432ec6
--- /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 000000000..7919cfad6
--- /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 000000000..cc6615aaf
--- /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 000000000..da6c29368
--- /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 000000000..aeb99c543
--- /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 000000000..198c4ca6c
--- /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 000000000..adf632bc0
--- /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 000000000..600a801d7
--- /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 000000000..036d09bc5
--- /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 000000000..bbd773eac
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 000000000..9d64e921b
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 000000000..4d75ce1a9
--- /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 000000000..2b31a504f
--- /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 000000000..2504077b2
--- /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
index cc0636b6c..fb49c391d 100644
--- a/cmd/skywirevisormobile/android/app/src/main/res/values/dimens.xml
+++ b/cmd/skywirevisormobile/android/app/src/main/res/values/dimens.xml
@@ -1,7 +1,33 @@
-
+
+ 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 000000000..a0889def3
--- /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
index 42566f294..2f10aadca 100644
--- a/cmd/skywirevisormobile/android/app/src/main/res/values/strings.xml
+++ b/cmd/skywirevisormobile/android/app/src/main/res/values/strings.xml
@@ -1,6 +1,295 @@
- SkywireVPN is connecting…
- SkywireVPN is connected!
- SkywireVPN is disconnected!
-
\ No newline at end of file
+ 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 000000000..f229da9b3
--- /dev/null
+++ b/cmd/skywirevisormobile/android/app/src/main/res/values/theme.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
diff --git a/cmd/skywirevisormobile/android/local.properties b/cmd/skywirevisormobile/android/local.properties
index ab5704469..650440306 100644
--- a/cmd/skywirevisormobile/android/local.properties
+++ b/cmd/skywirevisormobile/android/local.properties
@@ -4,5 +4,5 @@
# Location of the SDK. This is only used by Gradle.
# For customization when using a Version Control System, please read the
# header note.
-#Wed Jul 22 13:34:21 MSK 2020
-sdk.dir=/Users/darkrengarius/Library/Android/sdk
+#Thu Nov 05 17:04:24 VET 2020
+sdk.dir=F\:\\Respaldo\\AppData\\Local\\Android\\android-sdk