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 @@ - - - - - - - - -