This commit is contained in:
2026-08-16 03:49:29 -03:00
parent c02b36c83d
commit 8486e54329
9 changed files with 431 additions and 105 deletions
@@ -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);
@@ -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) {