diff --git a/README.md b/README.md index 159ab61..e80275c 100644 --- a/README.md +++ b/README.md @@ -194,7 +194,34 @@ FreeProxy project. See `THIRD_PARTY_NOTICES.md` and - ARM64 Android DragonTCP core build: PASS. - APK resource/DEX/native packaging: PASS. - APK signature verification (v3): PASS. -- Supplied APK uses the same signing certificate as the previous v7 APK (versionCode 8), so it can be installed as an in-place update over v7. +- Supplied APK uses the same signing certificate as the previous Lite/v7 APK and has versionCode 9, so it can be installed as an in-place update. A physical Android device is still required to validate the final VpnService path against a real mobile network. + +## v9 dark UI update + +This release changes only the Android UI/service state handling. The embedded +DragonTCP Go transport core is byte-for-byte identical to the previous Lite +release. + +UI changes: + +- permanent dark theme independent of the Android system theme +- fixed header with CONNECTED / CONNECTING / STOPPING / DISCONNECTED state +- connection and transport settings are grouped into compact dark cards +- the settings area and Live Log have independent scrolling regions +- Live Log appends new lines in-place and no longer calls + `fullScroll(FOCUS_DOWN)`, so log updates do not move the settings screen +- automatic log following happens only while the user is already near the + bottom; scrolling upward pauses auto-follow +- CONNECT is immediately disabled/greyed while connecting or connected +- STOP is enabled only while DragonTCP is active +- the service independently rejects duplicate CONNECT commands, so a double + tap cannot restart a working tunnel +- connection state is broadcast to the Activity, so buttons and the status + chip update without rebuilding the screen + +The supplied APK is versionCode 9 / versionName `9.0-lite-ui` and is signed +with the same DragonTCP VPN development certificate used by the preceding +Lite APK, allowing an in-place update. diff --git a/android/AndroidManifest.xml b/android/AndroidManifest.xml index 3e7feac..0f5a458 100644 --- a/android/AndroidManifest.xml +++ b/android/AndroidManifest.xml @@ -1,8 +1,8 @@ + android:versionCode="9" + android:versionName="9.0-lite-ui"> @@ -13,7 +13,8 @@ android:extractNativeLibs="true" android:label="DragonTCP Lite" android:icon="@drawable/ic_dragontcp" - android:usesCleartextTraffic="true"> + android:usesCleartextTraffic="true" + android:theme="@style/DragonTCPTheme"> + + + diff --git a/android/src/com/dragontcp/client/DragonService.java b/android/src/com/dragontcp/client/DragonService.java index 9cb79e4..c53e3de 100644 --- a/android/src/com/dragontcp/client/DragonService.java +++ b/android/src/com/dragontcp/client/DragonService.java @@ -31,6 +31,9 @@ import tech.xvanturing.freeproxy.vpn.proxy.SocketProtector; public class DragonService extends VpnService { public static final String ACTION_CONNECT = "com.dragontcp.client.CONNECT"; public static final String ACTION_STOP = "com.dragontcp.client.STOP"; + public static final String ACTION_STATE = "com.dragontcp.client.STATE"; + public static final String EXTRA_ACTIVE = "active"; + public static final String EXTRA_STATUS = "status"; public static final String EXTRA_SERVER = "server"; public static final String EXTRA_PORT = "port"; @@ -45,12 +48,19 @@ public class DragonService extends VpnService { private static final int LOCAL_PROXY_PORT = 8080; private static final int TUN_MTU = 1400; + private static volatile boolean appActive; + private static volatile String appStatus = "DISCONNECTED"; + + public static boolean isActive() { return appActive; } + public static String currentStatus() { return appStatus; } + private final Object stateLock = new Object(); private volatile Process coreProcess; private volatile TunnelEngine tunnelEngine; private volatile ParcelFileDescriptor tunFd; private volatile boolean connected; private volatile boolean stopping; + private volatile boolean cancelRequested; @Override public void onCreate() { @@ -62,10 +72,22 @@ public class DragonService extends VpnService { public int onStartCommand(Intent intent, int flags, int startId) { String action = intent != null ? intent.getAction() : null; if (ACTION_STOP.equals(action)) { + cancelRequested = true; + publishState("STOPPING", true); new Thread(() -> stopEverything("Stopped"), "dragontcp-stop").start(); return START_NOT_STICKY; } if (ACTION_CONNECT.equals(action)) { + synchronized (stateLock) { + // UI disabling is backed by a service-side guard too. A duplicate + // CONNECT can no longer tear down and restart a healthy tunnel. + if (appActive || connected || coreProcess != null || tunnelEngine != null) { + publishState(connected ? "CONNECTED" : "CONNECTING", true); + return START_STICKY; + } + cancelRequested = false; + publishState("CONNECTING", true); + } startForeground(NOTIFICATION_ID, buildNotification("Starting...")); Intent copy = new Intent(intent); new Thread(() -> startEverything(copy), "dragontcp-start").start(); @@ -76,12 +98,6 @@ public class DragonService extends VpnService { private void startEverything(Intent intent) { synchronized (stateLock) { - if (connected || coreProcess != null || tunnelEngine != null) { - // Restart in-place without stopSelf(); this avoids a race where - // Android destroys the service just after a new CONNECT begins. - stopping = true; - cleanupComponentsLocked(); - } stopping = false; } @@ -111,6 +127,7 @@ public class DragonService extends VpnService { startCoreLogReader(process); waitForLocalProxy(process); + if (cancelRequested) throw new InterruptedException("Stop requested"); AppLog.append("Local DragonTCP proxy ready on 127.0.0.1:8080"); ParcelFileDescriptor pfd = establishVpn(); @@ -141,15 +158,23 @@ public class DragonService extends VpnService { ); synchronized (stateLock) { + if (cancelRequested) { + try { pfd.close(); } catch (Throwable ignored) {} + throw new InterruptedException("Stop requested"); + } tunFd = pfd; tunnelEngine = engine; connected = true; } engine.start(); + publishState("CONNECTED", true); updateNotification("Connected"); AppLog.append("VPN connected"); AppLog.append("DNS: forced through 1.1.1.1 over DragonTCP"); AppLog.append("IPv6: captured and blocked to prevent bypass"); + } catch (InterruptedException stopped) { + Thread.currentThread().interrupt(); + stopEverything(null); } catch (Throwable t) { failStart(t.getMessage() != null ? t.getMessage() : t.toString()); } @@ -311,6 +336,7 @@ public class DragonService extends VpnService { stopping = true; cleanupComponentsLocked(); if (logMessage != null) AppLog.append(logMessage); + publishState("DISCONNECTED", false); try { stopForeground(true); } catch (Throwable ignored) {} stopSelf(); stopping = false; @@ -333,6 +359,16 @@ public class DragonService extends VpnService { return super.onBind(intent); } + private void publishState(String status, boolean active) { + appStatus = status == null ? (active ? "CONNECTED" : "DISCONNECTED") : status; + appActive = active; + Intent state = new Intent(ACTION_STATE); + state.setPackage(getPackageName()); + state.putExtra(EXTRA_ACTIVE, active); + state.putExtra(EXTRA_STATUS, appStatus); + try { sendBroadcast(state); } catch (Throwable ignored) {} + } + private void createNotificationChannel() { if (Build.VERSION.SDK_INT >= 26) { NotificationManager nm = getSystemService(NotificationManager.class); diff --git a/android/src/com/dragontcp/client/MainActivity.java b/android/src/com/dragontcp/client/MainActivity.java index 047cf2d..737f32d 100644 --- a/android/src/com/dragontcp/client/MainActivity.java +++ b/android/src/com/dragontcp/client/MainActivity.java @@ -1,10 +1,14 @@ package com.dragontcp.client; import android.app.Activity; +import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; +import android.content.IntentFilter; import android.content.SharedPreferences; +import android.graphics.Color; import android.graphics.Typeface; +import android.graphics.drawable.GradientDrawable; import android.net.VpnService; import android.os.Build; import android.os.Bundle; @@ -12,18 +16,32 @@ import android.text.InputType; import android.view.Gravity; import android.view.View; import android.view.ViewGroup; +import android.view.Window; import android.widget.Button; import android.widget.EditText; import android.widget.LinearLayout; import android.widget.ScrollView; -import android.widget.TableLayout; -import android.widget.TableRow; import android.widget.TextView; public class MainActivity extends Activity { private static final int VPN_REQUEST = 100; private static final String PREFS = "dragontcp"; + private static final int BG = Color.rgb(11, 15, 20); + private static final int CARD = Color.rgb(22, 28, 36); + private static final int FIELD = Color.rgb(14, 19, 26); + private static final int BORDER = Color.rgb(43, 53, 66); + private static final int TEXT = Color.rgb(244, 247, 251); + private static final int MUTED = Color.rgb(139, 151, 168); + private static final int ACCENT = Color.rgb(92, 149, 255); + private static final int ACCENT_DARK = Color.rgb(42, 67, 104); + private static final int STOP = Color.rgb(218, 83, 83); + private static final int DISABLED = Color.rgb(48, 56, 68); + private static final int LOG_BG = Color.rgb(7, 10, 14); + private static final int LOG_TEXT = Color.rgb(179, 194, 214); + private static final int OK = Color.rgb(82, 201, 143); + private static final int WARN = Color.rgb(240, 177, 83); + private EditText server; private EditText port; private EditText token; @@ -31,14 +49,33 @@ public class MainActivity extends Activity { private EditText chunkMin; private EditText reconnect; private EditText timeout; + + private Button connectButton; + private Button stopButton; + private TextView statusChip; private TextView logText; private ScrollView logScroll; + private boolean receiverRegistered; + private boolean connectPending; + private final AppLog.Listener logListener = line -> runOnUiThread(() -> appendLogLine(line)); + private final BroadcastReceiver stateReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (!DragonService.ACTION_STATE.equals(intent.getAction())) return; + boolean active = intent.getBooleanExtra(DragonService.EXTRA_ACTIVE, false); + String status = intent.getStringExtra(DragonService.EXTRA_STATUS); + connectPending = false; + updateConnectionUi(active, status == null ? (active ? "CONNECTED" : "DISCONNECTED") : status); + } + }; + @Override public void onCreate(Bundle state) { super.onCreate(state); + forceDarkSystemUi(); buildUi(); loadSettings(); } @@ -46,145 +83,303 @@ public class MainActivity extends Activity { @Override protected void onStart() { super.onStart(); + + // Load log history only when the Activity itself enters the foreground. + // Live updates below append only to the log TextView and never rebuild the UI. logText.setText(AppLog.history()); AppLog.addListener(logListener); - logScroll.post(() -> logScroll.fullScroll(View.FOCUS_DOWN)); + logScroll.post(this::scrollLogToBottom); + + if (!receiverRegistered) { + registerReceiver(stateReceiver, new IntentFilter(DragonService.ACTION_STATE)); + receiverRegistered = true; + } + updateConnectionUi(DragonService.isActive(), DragonService.currentStatus()); } @Override protected void onStop() { AppLog.removeListener(logListener); + if (receiverRegistered) { + try { unregisterReceiver(stateReceiver); } catch (Throwable ignored) {} + receiverRegistered = false; + } super.onStop(); } + private void forceDarkSystemUi() { + Window w = getWindow(); + w.setStatusBarColor(BG); + w.setNavigationBarColor(Color.rgb(7, 10, 14)); + if (Build.VERSION.SDK_INT >= 26) { + w.getDecorView().setSystemUiVisibility(0); + } + } + private void buildUi() { - int pad = dp(14); - ScrollView outer = new ScrollView(this); LinearLayout root = new LinearLayout(this); root.setOrientation(LinearLayout.VERTICAL); - root.setPadding(pad, pad, pad, pad); - outer.addView(root, new ScrollView.LayoutParams( + root.setBackgroundColor(BG); + root.setPadding(dp(16), dp(14), dp(16), dp(12)); + + // Fixed header: never moves when logs are appended. + LinearLayout header = new LinearLayout(this); + header.setOrientation(LinearLayout.HORIZONTAL); + header.setGravity(Gravity.CENTER_VERTICAL); + header.setPadding(dp(2), dp(2), dp(2), dp(12)); + + LinearLayout heading = new LinearLayout(this); + heading.setOrientation(LinearLayout.VERTICAL); + TextView title = text("DragonTCP Lite", 25, TEXT, true); + TextView subtitle = text("Adaptive XOR tunnel over TCP/53", 12, MUTED, false); + subtitle.setPadding(0, dp(2), 0, 0); + heading.addView(title); + heading.addView(subtitle); + header.addView(heading, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)); + + statusChip = text("DISCONNECTED", 11, MUTED, true); + statusChip.setGravity(Gravity.CENTER); + statusChip.setPadding(dp(12), dp(7), dp(12), dp(7)); + statusChip.setBackground(roundRect(CARD, 999, BORDER, 1)); + header.addView(statusChip); + root.addView(header); + + // Only the settings area scrolls. The log panel below has its own independent scroll. + ScrollView settingsScroll = new ScrollView(this); + settingsScroll.setFillViewport(false); + settingsScroll.setClipToPadding(false); + settingsScroll.setOverScrollMode(View.OVER_SCROLL_IF_CONTENT_SCROLLS); + + LinearLayout settings = new LinearLayout(this); + settings.setOrientation(LinearLayout.VERTICAL); + settings.setPadding(0, 0, 0, dp(8)); + settingsScroll.addView(settings, new ScrollView.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT )); - TextView title = new TextView(this); - title.setText("DragonTCP Lite VPN"); - title.setTextSize(24); - title.setTypeface(Typeface.DEFAULT, Typeface.BOLD); - root.addView(title); + LinearLayout connectionCard = card("CONNECTION"); + server = addField(connectionCard, "Server", "Server IP or hostname", "", false, false); + LinearLayout connectionRow = row(); + port = addFieldToRow(connectionRow, "Port", "53", "53", true, false, 0.34f); + token = addFieldToRow(connectionRow, "Token", "Optional", "", false, true, 0.66f); + connectionCard.addView(connectionRow); + settings.addView(connectionCard, cardParams()); - TextView subtitle = new TextView(this); - subtitle.setText("Android VPN → local HTTP CONNECT proxy → adaptive XOR over TCP/53"); - subtitle.setTextSize(13); - subtitle.setPadding(0, dp(4), 0, dp(12)); - root.addView(subtitle); + LinearLayout transportCard = card("TRANSPORT"); + LinearLayout chunks = row(); + chunkMax = addFieldToRow(chunks, "Max chunk", "1048576", "1048576", true, false, 0.58f); + chunkMin = addFieldToRow(chunks, "Min chunk", "32", "32", true, false, 0.42f); + transportCard.addView(chunks); - TableLayout table = new TableLayout(this); - table.setStretchAllColumns(false); - table.setColumnStretchable(1, true); - root.addView(table, new LinearLayout.LayoutParams( + LinearLayout timing = row(); + reconnect = addFieldToRow(timing, "Reconnect every", "1", "1", true, false, 0.58f); + timeout = addFieldToRow(timing, "Timeout (s)", "2", "2", true, false, 0.42f); + transportCard.addView(timing); + + TextView fixed = text("1 poller • Start = Max chunk • XOR 0xAD always on", 11, MUTED, false); + fixed.setPadding(dp(2), dp(6), dp(2), dp(2)); + transportCard.addView(fixed); + settings.addView(transportCard, cardParams()); + + LinearLayout buttons = row(); + buttons.setPadding(0, dp(4), 0, dp(8)); + connectButton = actionButton("CONNECT"); + stopButton = actionButton("STOP"); + LinearLayout.LayoutParams left = new LinearLayout.LayoutParams(0, dp(52), 1f); + left.setMarginEnd(dp(6)); + LinearLayout.LayoutParams right = new LinearLayout.LayoutParams(0, dp(52), 1f); + right.setMarginStart(dp(6)); + buttons.addView(connectButton, left); + buttons.addView(stopButton, right); + settings.addView(buttons); + + TextView note = text( + "IPv4 uses the Android VPN adapter. IPv6 is captured and blocked to prevent bypass. DNS is tunneled to 1.1.1.1 over DragonTCP.", + 11, MUTED, false + ); + note.setLineSpacing(0, 1.08f); + note.setPadding(dp(4), dp(2), dp(4), dp(6)); + settings.addView(note); + + root.addView(settingsScroll, new LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT + 0, + 1f )); - server = addField(table, "Server", "", false, false); - port = addField(table, "Port", "53", true, false); - token = addField(table, "Token", "", false, true); - chunkMax = addField(table, "Max chunk", "1048576", true, false); - chunkMin = addField(table, "Min chunk", "32", true, false); - reconnect = addField(table, "Reconnect every", "1", true, false); - timeout = addField(table, "Timeout (s)", "2", true, false); + // Fixed log panel. New lines can never change the position/size of the settings area. + LinearLayout logPanel = new LinearLayout(this); + logPanel.setOrientation(LinearLayout.VERTICAL); + logPanel.setBackground(roundRect(CARD, 16, BORDER, 1)); + logPanel.setPadding(dp(10), dp(8), dp(10), dp(10)); - TextView fixed = new TextView(this); - fixed.setText("Pollers: 1 (fixed) • Start chunk = Max chunk • XOR 0xAD always on"); - fixed.setTextSize(12); - fixed.setPadding(0, dp(8), 0, dp(8)); - root.addView(fixed); - - LinearLayout buttons = new LinearLayout(this); - buttons.setOrientation(LinearLayout.HORIZONTAL); - buttons.setGravity(Gravity.CENTER); - Button connect = new Button(this); - connect.setText("CONNECT"); - Button stop = new Button(this); - stop.setText("STOP"); - buttons.addView(connect, new LinearLayout.LayoutParams(0, dp(52), 1f)); - buttons.addView(stop, new LinearLayout.LayoutParams(0, dp(52), 1f)); - root.addView(buttons); - - connect.setOnClickListener(v -> requestConnect()); - stop.setOnClickListener(v -> { - Intent i = new Intent(this, DragonService.class).setAction(DragonService.ACTION_STOP); - startService(i); - }); - - LinearLayout logHeader = new LinearLayout(this); - logHeader.setOrientation(LinearLayout.HORIZONTAL); + LinearLayout logHeader = row(); logHeader.setGravity(Gravity.CENTER_VERTICAL); - TextView logLabel = new TextView(this); - logLabel.setText("Live log"); - logLabel.setTextSize(16); - logLabel.setTypeface(Typeface.DEFAULT, Typeface.BOLD); - Button clear = new Button(this); - clear.setText("CLEAR"); - logHeader.addView(logLabel, new LinearLayout.LayoutParams(0, dp(48), 1f)); - logHeader.addView(clear, new LinearLayout.LayoutParams(dp(100), dp(48))); - root.addView(logHeader); + TextView logLabel = text("Live log", 15, TEXT, true); + Button clear = smallButton("CLEAR"); + logHeader.addView(logLabel, new LinearLayout.LayoutParams(0, dp(40), 1f)); + logHeader.addView(clear, new LinearLayout.LayoutParams(dp(82), dp(38))); + logPanel.addView(logHeader); logScroll = new ScrollView(this); + logScroll.setFillViewport(true); + logScroll.setFocusable(false); + logScroll.setOverScrollMode(View.OVER_SCROLL_IF_CONTENT_SCROLLS); + logScroll.setBackground(roundRect(LOG_BG, 11, Color.rgb(30, 38, 49), 1)); + logText = new TextView(this); - logText.setTextSize(12); + logText.setTextSize(11.5f); + logText.setTextColor(LOG_TEXT); logText.setTypeface(Typeface.MONOSPACE); logText.setTextIsSelectable(true); - logText.setPadding(dp(8), dp(8), dp(8), dp(8)); + logText.setPadding(dp(10), dp(9), dp(10), dp(9)); + logText.setLineSpacing(0, 1.08f); logScroll.addView(logText, new ScrollView.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT )); - root.addView(logScroll, new LinearLayout.LayoutParams( + logPanel.addView(logScroll, new LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, - dp(280) + 0, + 1f )); + root.addView(logPanel, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + dp(238) + )); + clear.setOnClickListener(v -> { AppLog.clear(); logText.setText(""); + logScroll.scrollTo(0, 0); }); - TextView note = new TextView(this); - note.setText("IPv4 is tunneled. IPv6 is captured and blocked so it cannot bypass the proxy. DNS is sent to 1.1.1.1 through DragonTCP using DNS-over-TCP."); - note.setTextSize(11); - note.setPadding(0, dp(8), 0, dp(12)); - root.addView(note); + connectButton.setOnClickListener(v -> requestConnect()); + stopButton.setOnClickListener(v -> requestStop()); - setContentView(outer); + setContentView(root); + updateConnectionUi(false, "DISCONNECTED"); } - private EditText addField(TableLayout table, String label, String defaultValue, boolean numeric, boolean password) { - TableRow row = new TableRow(this); - row.setPadding(0, dp(2), 0, dp(2)); - TextView name = new TextView(this); - name.setText(label); - name.setGravity(Gravity.CENTER_VERTICAL); - name.setPadding(0, 0, dp(10), 0); - EditText value = new EditText(this); - value.setSingleLine(true); - value.setText(defaultValue); - if (numeric) value.setInputType(InputType.TYPE_CLASS_NUMBER); - if (password) value.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD); - row.addView(name, new TableRow.LayoutParams(dp(125), dp(50))); - row.addView(value, new TableRow.LayoutParams(0, dp(50), 1f)); - table.addView(row); - return value; + private LinearLayout card(String title) { + LinearLayout card = new LinearLayout(this); + card.setOrientation(LinearLayout.VERTICAL); + card.setPadding(dp(13), dp(11), dp(13), dp(12)); + card.setBackground(roundRect(CARD, 16, BORDER, 1)); + TextView label = text(title, 11, MUTED, true); + label.setLetterSpacing(0.08f); + label.setPadding(dp(2), 0, 0, dp(7)); + card.addView(label); + return card; + } + + private LinearLayout.LayoutParams cardParams() { + LinearLayout.LayoutParams p = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + p.setMargins(0, 0, 0, dp(10)); + return p; + } + + private EditText addField(LinearLayout parent, String label, String hint, String value, boolean numeric, boolean password) { + LinearLayout block = fieldBlock(label); + EditText edit = createEdit(hint, value, numeric, password); + block.addView(edit, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(46))); + parent.addView(block, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + return edit; + } + + private EditText addFieldToRow(LinearLayout parent, String label, String hint, String value, boolean numeric, boolean password, float weight) { + LinearLayout block = fieldBlock(label); + EditText edit = createEdit(hint, value, numeric, password); + block.addView(edit, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(46))); + LinearLayout.LayoutParams p = new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, weight); + if (parent.getChildCount() > 0) p.setMarginStart(dp(8)); + parent.addView(block, p); + return edit; + } + + private LinearLayout fieldBlock(String label) { + LinearLayout block = new LinearLayout(this); + block.setOrientation(LinearLayout.VERTICAL); + block.setPadding(0, 0, 0, dp(8)); + TextView name = text(label, 11, MUTED, false); + name.setPadding(dp(2), 0, 0, dp(4)); + block.addView(name); + return block; + } + + private EditText createEdit(String hint, String value, boolean numeric, boolean password) { + EditText edit = new EditText(this); + edit.setSingleLine(true); + edit.setText(value); + edit.setHint(hint); + edit.setTextColor(TEXT); + edit.setHintTextColor(Color.rgb(91, 103, 120)); + edit.setTextSize(15); + edit.setPadding(dp(12), 0, dp(12), 0); + edit.setBackground(roundRect(FIELD, 11, BORDER, 1)); + edit.setSelectAllOnFocus(false); + if (numeric) edit.setInputType(InputType.TYPE_CLASS_NUMBER); + if (password) edit.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD); + return edit; + } + + private LinearLayout row() { + LinearLayout row = new LinearLayout(this); + row.setOrientation(LinearLayout.HORIZONTAL); + return row; + } + + private TextView text(String value, float size, int color, boolean bold) { + TextView t = new TextView(this); + t.setText(value); + t.setTextSize(size); + t.setTextColor(color); + if (bold) t.setTypeface(Typeface.DEFAULT, Typeface.BOLD); + return t; + } + + private Button actionButton(String value) { + Button b = new Button(this); + b.setText(value); + b.setTextSize(14); + b.setTypeface(Typeface.DEFAULT, Typeface.BOLD); + b.setTextColor(Color.WHITE); + b.setAllCaps(false); + return b; + } + + private Button smallButton(String value) { + Button b = actionButton(value); + b.setTextSize(11); + b.setBackground(roundRect(Color.rgb(32, 40, 51), 10, BORDER, 1)); + return b; + } + + private GradientDrawable roundRect(int color, int radiusDp, int strokeColor, int strokeDp) { + GradientDrawable d = new GradientDrawable(); + d.setColor(color); + d.setCornerRadius(dp(radiusDp)); + if (strokeDp > 0) d.setStroke(dp(strokeDp), strokeColor); + return d; } private void requestConnect() { + if (connectPending || DragonService.isActive()) return; try { validateAndSave(); } catch (Exception e) { AppLog.append("CONFIG: " + e.getMessage()); return; } + + // Grey it immediately, before Android's VPN permission dialog can be double-tapped. + connectPending = true; + updateConnectionUi(true, "CONNECTING"); + Intent prepare = VpnService.prepare(this); if (prepare != null) { startActivityForResult(prepare, VPN_REQUEST); @@ -193,12 +388,27 @@ public class MainActivity extends Activity { } } + private void requestStop() { + if (!DragonService.isActive() && !connectPending) return; + connectPending = false; + updateConnectionUi(true, "STOPPING"); + connectButton.setEnabled(false); + stopButton.setEnabled(false); + Intent i = new Intent(this, DragonService.class).setAction(DragonService.ACTION_STOP); + startService(i); + } + @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); if (requestCode == VPN_REQUEST) { - if (resultCode == RESULT_OK) startDragonService(); - else AppLog.append("VPN permission was not granted"); + if (resultCode == RESULT_OK) { + startDragonService(); + } else { + connectPending = false; + updateConnectionUi(false, "DISCONNECTED"); + AppLog.append("VPN permission was not granted"); + } } } @@ -254,13 +464,50 @@ public class MainActivity extends Activity { timeout.setText(Integer.toString(p.getInt("timeout", 2))); } + private void updateConnectionUi(boolean active, String rawStatus) { + String status = rawStatus == null ? (active ? "CONNECTED" : "DISCONNECTED") : rawStatus.toUpperCase(); + boolean stopping = status.contains("STOPPING"); + boolean connected = status.contains("CONNECTED") && !status.contains("DISCONNECTED"); + boolean starting = active && !connected && !stopping; + + statusChip.setText(connected ? "CONNECTED" : starting ? "CONNECTING" : stopping ? "STOPPING" : "DISCONNECTED"); + statusChip.setTextColor(connected ? OK : starting || stopping ? WARN : MUTED); + statusChip.setBackground(roundRect( + connected ? Color.rgb(20, 54, 42) : starting || stopping ? Color.rgb(58, 43, 22) : CARD, + 999, + connected ? Color.rgb(46, 105, 80) : starting || stopping ? Color.rgb(112, 78, 35) : BORDER, + 1 + )); + + boolean canConnect = !active && !connectPending && !stopping; + boolean canStop = active && !stopping; + connectButton.setEnabled(canConnect); + stopButton.setEnabled(canStop); + + connectButton.setTextColor(canConnect ? Color.WHITE : Color.rgb(132, 142, 155)); + connectButton.setBackground(roundRect(canConnect ? ACCENT : DISABLED, 14, canConnect ? ACCENT : BORDER, 1)); + stopButton.setTextColor(canStop ? Color.WHITE : Color.rgb(112, 121, 133)); + stopButton.setBackground(roundRect(canStop ? STOP : Color.rgb(32, 38, 47), 14, canStop ? STOP : BORDER, 1)); + } + private void appendLogLine(String line) { - // Only auto-scroll when the user was already near the bottom. View child = logScroll.getChildAt(0); - int gap = child == null ? 0 : child.getBottom() - (logScroll.getScrollY() + logScroll.getHeight()); - boolean follow = gap < dp(48); + int contentHeight = child == null ? 0 : child.getHeight(); + int maxScroll = Math.max(0, contentHeight - logScroll.getHeight()); + boolean follow = maxScroll - logScroll.getScrollY() < dp(36); + logText.append(line + "\n"); - if (follow) logScroll.post(() -> logScroll.fullScroll(View.FOCUS_DOWN)); + + // Do not use fullScroll(FOCUS_DOWN): it requests focus and can make an outer + // ScrollView jump. scrollTo only moves this dedicated fixed-size log panel. + if (follow) logScroll.post(this::scrollLogToBottom); + } + + private void scrollLogToBottom() { + View child = logScroll.getChildAt(0); + if (child == null) return; + int y = Math.max(0, child.getHeight() - logScroll.getHeight()); + logScroll.scrollTo(0, y); } private int dp(int value) { diff --git a/bin/dragontcp-lite-client-linux-amd64 b/bin/dragontcp-lite-client-linux-amd64 new file mode 100644 index 0000000..a7b3b7e Binary files /dev/null and b/bin/dragontcp-lite-client-linux-amd64 differ diff --git a/bin/dragontcp-lite-server-linux-amd64 b/bin/dragontcp-lite-server-linux-amd64 new file mode 100644 index 0000000..fe828f4 Binary files /dev/null and b/bin/dragontcp-lite-server-linux-amd64 differ diff --git a/bin/dragontcp-lite-server-linux-arm64 b/bin/dragontcp-lite-server-linux-arm64 new file mode 100644 index 0000000..1595756 Binary files /dev/null and b/bin/dragontcp-lite-server-linux-arm64 differ