v9
This commit is contained in:
@@ -194,7 +194,34 @@ FreeProxy project. See `THIRD_PARTY_NOTICES.md` and
|
|||||||
- ARM64 Android DragonTCP core build: PASS.
|
- ARM64 Android DragonTCP core build: PASS.
|
||||||
- APK resource/DEX/native packaging: PASS.
|
- APK resource/DEX/native packaging: PASS.
|
||||||
- APK signature verification (v3): 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
|
A physical Android device is still required to validate the final VpnService
|
||||||
path against a real mobile network.
|
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.
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
package="com.dragontcp.client"
|
package="com.dragontcp.client"
|
||||||
android:versionCode="8"
|
android:versionCode="9"
|
||||||
android:versionName="8.0-lite">
|
android:versionName="9.0-lite-ui">
|
||||||
|
|
||||||
<uses-sdk android:minSdkVersion="29" android:targetSdkVersion="29" />
|
<uses-sdk android:minSdkVersion="29" android:targetSdkVersion="29" />
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
@@ -13,7 +13,8 @@
|
|||||||
android:extractNativeLibs="true"
|
android:extractNativeLibs="true"
|
||||||
android:label="DragonTCP Lite"
|
android:label="DragonTCP Lite"
|
||||||
android:icon="@drawable/ic_dragontcp"
|
android:icon="@drawable/ic_dragontcp"
|
||||||
android:usesCleartextTraffic="true">
|
android:usesCleartextTraffic="true"
|
||||||
|
android:theme="@style/DragonTCPTheme">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="DragonTCPTheme" parent="android:style/Theme.Material.NoActionBar">
|
||||||
|
<item name="android:fontFamily">sans</item>
|
||||||
|
<item name="android:windowActionModeOverlay">true</item>
|
||||||
|
<item name="android:windowBackground">#0B0F14</item>
|
||||||
|
<item name="android:colorAccent">#6EA8FE</item>
|
||||||
|
<item name="android:navigationBarColor">#080B0F</item>
|
||||||
|
<item name="android:statusBarColor">#0B0F14</item>
|
||||||
|
<item name="android:windowLightStatusBar">false</item>
|
||||||
|
<item name="android:windowLightNavigationBar">false</item>
|
||||||
|
<item name="android:windowNoTitle">true</item>
|
||||||
|
<item name="android:forceDarkAllowed">false</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -31,6 +31,9 @@ import tech.xvanturing.freeproxy.vpn.proxy.SocketProtector;
|
|||||||
public class DragonService extends VpnService {
|
public class DragonService extends VpnService {
|
||||||
public static final String ACTION_CONNECT = "com.dragontcp.client.CONNECT";
|
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_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_SERVER = "server";
|
||||||
public static final String EXTRA_PORT = "port";
|
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 LOCAL_PROXY_PORT = 8080;
|
||||||
private static final int TUN_MTU = 1400;
|
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 final Object stateLock = new Object();
|
||||||
private volatile Process coreProcess;
|
private volatile Process coreProcess;
|
||||||
private volatile TunnelEngine tunnelEngine;
|
private volatile TunnelEngine tunnelEngine;
|
||||||
private volatile ParcelFileDescriptor tunFd;
|
private volatile ParcelFileDescriptor tunFd;
|
||||||
private volatile boolean connected;
|
private volatile boolean connected;
|
||||||
private volatile boolean stopping;
|
private volatile boolean stopping;
|
||||||
|
private volatile boolean cancelRequested;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onCreate() {
|
public void onCreate() {
|
||||||
@@ -62,10 +72,22 @@ public class DragonService extends VpnService {
|
|||||||
public int onStartCommand(Intent intent, int flags, int startId) {
|
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||||
String action = intent != null ? intent.getAction() : null;
|
String action = intent != null ? intent.getAction() : null;
|
||||||
if (ACTION_STOP.equals(action)) {
|
if (ACTION_STOP.equals(action)) {
|
||||||
|
cancelRequested = true;
|
||||||
|
publishState("STOPPING", true);
|
||||||
new Thread(() -> stopEverything("Stopped"), "dragontcp-stop").start();
|
new Thread(() -> stopEverything("Stopped"), "dragontcp-stop").start();
|
||||||
return START_NOT_STICKY;
|
return START_NOT_STICKY;
|
||||||
}
|
}
|
||||||
if (ACTION_CONNECT.equals(action)) {
|
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..."));
|
startForeground(NOTIFICATION_ID, buildNotification("Starting..."));
|
||||||
Intent copy = new Intent(intent);
|
Intent copy = new Intent(intent);
|
||||||
new Thread(() -> startEverything(copy), "dragontcp-start").start();
|
new Thread(() -> startEverything(copy), "dragontcp-start").start();
|
||||||
@@ -76,12 +98,6 @@ public class DragonService extends VpnService {
|
|||||||
|
|
||||||
private void startEverything(Intent intent) {
|
private void startEverything(Intent intent) {
|
||||||
synchronized (stateLock) {
|
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;
|
stopping = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +127,7 @@ public class DragonService extends VpnService {
|
|||||||
|
|
||||||
startCoreLogReader(process);
|
startCoreLogReader(process);
|
||||||
waitForLocalProxy(process);
|
waitForLocalProxy(process);
|
||||||
|
if (cancelRequested) throw new InterruptedException("Stop requested");
|
||||||
AppLog.append("Local DragonTCP proxy ready on 127.0.0.1:8080");
|
AppLog.append("Local DragonTCP proxy ready on 127.0.0.1:8080");
|
||||||
|
|
||||||
ParcelFileDescriptor pfd = establishVpn();
|
ParcelFileDescriptor pfd = establishVpn();
|
||||||
@@ -141,15 +158,23 @@ public class DragonService extends VpnService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
synchronized (stateLock) {
|
synchronized (stateLock) {
|
||||||
|
if (cancelRequested) {
|
||||||
|
try { pfd.close(); } catch (Throwable ignored) {}
|
||||||
|
throw new InterruptedException("Stop requested");
|
||||||
|
}
|
||||||
tunFd = pfd;
|
tunFd = pfd;
|
||||||
tunnelEngine = engine;
|
tunnelEngine = engine;
|
||||||
connected = true;
|
connected = true;
|
||||||
}
|
}
|
||||||
engine.start();
|
engine.start();
|
||||||
|
publishState("CONNECTED", true);
|
||||||
updateNotification("Connected");
|
updateNotification("Connected");
|
||||||
AppLog.append("VPN connected");
|
AppLog.append("VPN connected");
|
||||||
AppLog.append("DNS: forced through 1.1.1.1 over DragonTCP");
|
AppLog.append("DNS: forced through 1.1.1.1 over DragonTCP");
|
||||||
AppLog.append("IPv6: captured and blocked to prevent bypass");
|
AppLog.append("IPv6: captured and blocked to prevent bypass");
|
||||||
|
} catch (InterruptedException stopped) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
stopEverything(null);
|
||||||
} catch (Throwable t) {
|
} catch (Throwable t) {
|
||||||
failStart(t.getMessage() != null ? t.getMessage() : t.toString());
|
failStart(t.getMessage() != null ? t.getMessage() : t.toString());
|
||||||
}
|
}
|
||||||
@@ -311,6 +336,7 @@ public class DragonService extends VpnService {
|
|||||||
stopping = true;
|
stopping = true;
|
||||||
cleanupComponentsLocked();
|
cleanupComponentsLocked();
|
||||||
if (logMessage != null) AppLog.append(logMessage);
|
if (logMessage != null) AppLog.append(logMessage);
|
||||||
|
publishState("DISCONNECTED", false);
|
||||||
try { stopForeground(true); } catch (Throwable ignored) {}
|
try { stopForeground(true); } catch (Throwable ignored) {}
|
||||||
stopSelf();
|
stopSelf();
|
||||||
stopping = false;
|
stopping = false;
|
||||||
@@ -333,6 +359,16 @@ public class DragonService extends VpnService {
|
|||||||
return super.onBind(intent);
|
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() {
|
private void createNotificationChannel() {
|
||||||
if (Build.VERSION.SDK_INT >= 26) {
|
if (Build.VERSION.SDK_INT >= 26) {
|
||||||
NotificationManager nm = getSystemService(NotificationManager.class);
|
NotificationManager nm = getSystemService(NotificationManager.class);
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
package com.dragontcp.client;
|
package com.dragontcp.client;
|
||||||
|
|
||||||
import android.app.Activity;
|
import android.app.Activity;
|
||||||
|
import android.content.BroadcastReceiver;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
|
import android.content.IntentFilter;
|
||||||
import android.content.SharedPreferences;
|
import android.content.SharedPreferences;
|
||||||
|
import android.graphics.Color;
|
||||||
import android.graphics.Typeface;
|
import android.graphics.Typeface;
|
||||||
|
import android.graphics.drawable.GradientDrawable;
|
||||||
import android.net.VpnService;
|
import android.net.VpnService;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
@@ -12,18 +16,32 @@ import android.text.InputType;
|
|||||||
import android.view.Gravity;
|
import android.view.Gravity;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
import android.view.ViewGroup;
|
import android.view.ViewGroup;
|
||||||
|
import android.view.Window;
|
||||||
import android.widget.Button;
|
import android.widget.Button;
|
||||||
import android.widget.EditText;
|
import android.widget.EditText;
|
||||||
import android.widget.LinearLayout;
|
import android.widget.LinearLayout;
|
||||||
import android.widget.ScrollView;
|
import android.widget.ScrollView;
|
||||||
import android.widget.TableLayout;
|
|
||||||
import android.widget.TableRow;
|
|
||||||
import android.widget.TextView;
|
import android.widget.TextView;
|
||||||
|
|
||||||
public class MainActivity extends Activity {
|
public class MainActivity extends Activity {
|
||||||
private static final int VPN_REQUEST = 100;
|
private static final int VPN_REQUEST = 100;
|
||||||
private static final String PREFS = "dragontcp";
|
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 server;
|
||||||
private EditText port;
|
private EditText port;
|
||||||
private EditText token;
|
private EditText token;
|
||||||
@@ -31,14 +49,33 @@ public class MainActivity extends Activity {
|
|||||||
private EditText chunkMin;
|
private EditText chunkMin;
|
||||||
private EditText reconnect;
|
private EditText reconnect;
|
||||||
private EditText timeout;
|
private EditText timeout;
|
||||||
|
|
||||||
|
private Button connectButton;
|
||||||
|
private Button stopButton;
|
||||||
|
private TextView statusChip;
|
||||||
private TextView logText;
|
private TextView logText;
|
||||||
private ScrollView logScroll;
|
private ScrollView logScroll;
|
||||||
|
|
||||||
|
private boolean receiverRegistered;
|
||||||
|
private boolean connectPending;
|
||||||
|
|
||||||
private final AppLog.Listener logListener = line -> runOnUiThread(() -> appendLogLine(line));
|
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
|
@Override
|
||||||
public void onCreate(Bundle state) {
|
public void onCreate(Bundle state) {
|
||||||
super.onCreate(state);
|
super.onCreate(state);
|
||||||
|
forceDarkSystemUi();
|
||||||
buildUi();
|
buildUi();
|
||||||
loadSettings();
|
loadSettings();
|
||||||
}
|
}
|
||||||
@@ -46,145 +83,303 @@ public class MainActivity extends Activity {
|
|||||||
@Override
|
@Override
|
||||||
protected void onStart() {
|
protected void onStart() {
|
||||||
super.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());
|
logText.setText(AppLog.history());
|
||||||
AppLog.addListener(logListener);
|
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
|
@Override
|
||||||
protected void onStop() {
|
protected void onStop() {
|
||||||
AppLog.removeListener(logListener);
|
AppLog.removeListener(logListener);
|
||||||
|
if (receiverRegistered) {
|
||||||
|
try { unregisterReceiver(stateReceiver); } catch (Throwable ignored) {}
|
||||||
|
receiverRegistered = false;
|
||||||
|
}
|
||||||
super.onStop();
|
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() {
|
private void buildUi() {
|
||||||
int pad = dp(14);
|
|
||||||
ScrollView outer = new ScrollView(this);
|
|
||||||
LinearLayout root = new LinearLayout(this);
|
LinearLayout root = new LinearLayout(this);
|
||||||
root.setOrientation(LinearLayout.VERTICAL);
|
root.setOrientation(LinearLayout.VERTICAL);
|
||||||
root.setPadding(pad, pad, pad, pad);
|
root.setBackgroundColor(BG);
|
||||||
outer.addView(root, new ScrollView.LayoutParams(
|
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.MATCH_PARENT,
|
||||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||||
));
|
));
|
||||||
|
|
||||||
TextView title = new TextView(this);
|
LinearLayout connectionCard = card("CONNECTION");
|
||||||
title.setText("DragonTCP Lite VPN");
|
server = addField(connectionCard, "Server", "Server IP or hostname", "", false, false);
|
||||||
title.setTextSize(24);
|
LinearLayout connectionRow = row();
|
||||||
title.setTypeface(Typeface.DEFAULT, Typeface.BOLD);
|
port = addFieldToRow(connectionRow, "Port", "53", "53", true, false, 0.34f);
|
||||||
root.addView(title);
|
token = addFieldToRow(connectionRow, "Token", "Optional", "", false, true, 0.66f);
|
||||||
|
connectionCard.addView(connectionRow);
|
||||||
|
settings.addView(connectionCard, cardParams());
|
||||||
|
|
||||||
TextView subtitle = new TextView(this);
|
LinearLayout transportCard = card("TRANSPORT");
|
||||||
subtitle.setText("Android VPN → local HTTP CONNECT proxy → adaptive XOR over TCP/53");
|
LinearLayout chunks = row();
|
||||||
subtitle.setTextSize(13);
|
chunkMax = addFieldToRow(chunks, "Max chunk", "1048576", "1048576", true, false, 0.58f);
|
||||||
subtitle.setPadding(0, dp(4), 0, dp(12));
|
chunkMin = addFieldToRow(chunks, "Min chunk", "32", "32", true, false, 0.42f);
|
||||||
root.addView(subtitle);
|
transportCard.addView(chunks);
|
||||||
|
|
||||||
TableLayout table = new TableLayout(this);
|
LinearLayout timing = row();
|
||||||
table.setStretchAllColumns(false);
|
reconnect = addFieldToRow(timing, "Reconnect every", "1", "1", true, false, 0.58f);
|
||||||
table.setColumnStretchable(1, true);
|
timeout = addFieldToRow(timing, "Timeout (s)", "2", "2", true, false, 0.42f);
|
||||||
root.addView(table, new LinearLayout.LayoutParams(
|
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.MATCH_PARENT,
|
||||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
0,
|
||||||
|
1f
|
||||||
));
|
));
|
||||||
|
|
||||||
server = addField(table, "Server", "", false, false);
|
// Fixed log panel. New lines can never change the position/size of the settings area.
|
||||||
port = addField(table, "Port", "53", true, false);
|
LinearLayout logPanel = new LinearLayout(this);
|
||||||
token = addField(table, "Token", "", false, true);
|
logPanel.setOrientation(LinearLayout.VERTICAL);
|
||||||
chunkMax = addField(table, "Max chunk", "1048576", true, false);
|
logPanel.setBackground(roundRect(CARD, 16, BORDER, 1));
|
||||||
chunkMin = addField(table, "Min chunk", "32", true, false);
|
logPanel.setPadding(dp(10), dp(8), dp(10), dp(10));
|
||||||
reconnect = addField(table, "Reconnect every", "1", true, false);
|
|
||||||
timeout = addField(table, "Timeout (s)", "2", true, false);
|
|
||||||
|
|
||||||
TextView fixed = new TextView(this);
|
LinearLayout logHeader = row();
|
||||||
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);
|
|
||||||
logHeader.setGravity(Gravity.CENTER_VERTICAL);
|
logHeader.setGravity(Gravity.CENTER_VERTICAL);
|
||||||
TextView logLabel = new TextView(this);
|
TextView logLabel = text("Live log", 15, TEXT, true);
|
||||||
logLabel.setText("Live log");
|
Button clear = smallButton("CLEAR");
|
||||||
logLabel.setTextSize(16);
|
logHeader.addView(logLabel, new LinearLayout.LayoutParams(0, dp(40), 1f));
|
||||||
logLabel.setTypeface(Typeface.DEFAULT, Typeface.BOLD);
|
logHeader.addView(clear, new LinearLayout.LayoutParams(dp(82), dp(38)));
|
||||||
Button clear = new Button(this);
|
logPanel.addView(logHeader);
|
||||||
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);
|
|
||||||
|
|
||||||
logScroll = new ScrollView(this);
|
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 = new TextView(this);
|
||||||
logText.setTextSize(12);
|
logText.setTextSize(11.5f);
|
||||||
|
logText.setTextColor(LOG_TEXT);
|
||||||
logText.setTypeface(Typeface.MONOSPACE);
|
logText.setTypeface(Typeface.MONOSPACE);
|
||||||
logText.setTextIsSelectable(true);
|
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(
|
logScroll.addView(logText, new ScrollView.LayoutParams(
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||||
));
|
));
|
||||||
root.addView(logScroll, new LinearLayout.LayoutParams(
|
logPanel.addView(logScroll, new LinearLayout.LayoutParams(
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
dp(280)
|
0,
|
||||||
|
1f
|
||||||
));
|
));
|
||||||
|
root.addView(logPanel, new LinearLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
dp(238)
|
||||||
|
));
|
||||||
|
|
||||||
clear.setOnClickListener(v -> {
|
clear.setOnClickListener(v -> {
|
||||||
AppLog.clear();
|
AppLog.clear();
|
||||||
logText.setText("");
|
logText.setText("");
|
||||||
|
logScroll.scrollTo(0, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
TextView note = new TextView(this);
|
connectButton.setOnClickListener(v -> requestConnect());
|
||||||
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.");
|
stopButton.setOnClickListener(v -> requestStop());
|
||||||
note.setTextSize(11);
|
|
||||||
note.setPadding(0, dp(8), 0, dp(12));
|
|
||||||
root.addView(note);
|
|
||||||
|
|
||||||
setContentView(outer);
|
setContentView(root);
|
||||||
|
updateConnectionUi(false, "DISCONNECTED");
|
||||||
}
|
}
|
||||||
|
|
||||||
private EditText addField(TableLayout table, String label, String defaultValue, boolean numeric, boolean password) {
|
private LinearLayout card(String title) {
|
||||||
TableRow row = new TableRow(this);
|
LinearLayout card = new LinearLayout(this);
|
||||||
row.setPadding(0, dp(2), 0, dp(2));
|
card.setOrientation(LinearLayout.VERTICAL);
|
||||||
TextView name = new TextView(this);
|
card.setPadding(dp(13), dp(11), dp(13), dp(12));
|
||||||
name.setText(label);
|
card.setBackground(roundRect(CARD, 16, BORDER, 1));
|
||||||
name.setGravity(Gravity.CENTER_VERTICAL);
|
TextView label = text(title, 11, MUTED, true);
|
||||||
name.setPadding(0, 0, dp(10), 0);
|
label.setLetterSpacing(0.08f);
|
||||||
EditText value = new EditText(this);
|
label.setPadding(dp(2), 0, 0, dp(7));
|
||||||
value.setSingleLine(true);
|
card.addView(label);
|
||||||
value.setText(defaultValue);
|
return card;
|
||||||
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)));
|
private LinearLayout.LayoutParams cardParams() {
|
||||||
row.addView(value, new TableRow.LayoutParams(0, dp(50), 1f));
|
LinearLayout.LayoutParams p = new LinearLayout.LayoutParams(
|
||||||
table.addView(row);
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
return value;
|
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() {
|
private void requestConnect() {
|
||||||
|
if (connectPending || DragonService.isActive()) return;
|
||||||
try {
|
try {
|
||||||
validateAndSave();
|
validateAndSave();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
AppLog.append("CONFIG: " + e.getMessage());
|
AppLog.append("CONFIG: " + e.getMessage());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Grey it immediately, before Android's VPN permission dialog can be double-tapped.
|
||||||
|
connectPending = true;
|
||||||
|
updateConnectionUi(true, "CONNECTING");
|
||||||
|
|
||||||
Intent prepare = VpnService.prepare(this);
|
Intent prepare = VpnService.prepare(this);
|
||||||
if (prepare != null) {
|
if (prepare != null) {
|
||||||
startActivityForResult(prepare, VPN_REQUEST);
|
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
|
@Override
|
||||||
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
|
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||||
super.onActivityResult(requestCode, resultCode, data);
|
super.onActivityResult(requestCode, resultCode, data);
|
||||||
if (requestCode == VPN_REQUEST) {
|
if (requestCode == VPN_REQUEST) {
|
||||||
if (resultCode == RESULT_OK) startDragonService();
|
if (resultCode == RESULT_OK) {
|
||||||
else AppLog.append("VPN permission was not granted");
|
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)));
|
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) {
|
private void appendLogLine(String line) {
|
||||||
// Only auto-scroll when the user was already near the bottom.
|
|
||||||
View child = logScroll.getChildAt(0);
|
View child = logScroll.getChildAt(0);
|
||||||
int gap = child == null ? 0 : child.getBottom() - (logScroll.getScrollY() + logScroll.getHeight());
|
int contentHeight = child == null ? 0 : child.getHeight();
|
||||||
boolean follow = gap < dp(48);
|
int maxScroll = Math.max(0, contentHeight - logScroll.getHeight());
|
||||||
|
boolean follow = maxScroll - logScroll.getScrollY() < dp(36);
|
||||||
|
|
||||||
logText.append(line + "\n");
|
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) {
|
private int dp(int value) {
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user