diff --git a/README.md b/README.md index c947630..9a3feac 100644 --- a/README.md +++ b/README.md @@ -1,222 +1,54 @@ -# DragonTCP Lite VPN +# DragonTCP LiteVPN v11 - Separate Logs Page -This version deliberately returns to the lightweight DragonTCP architecture. -DragonTCP itself is an HTTP/HTTPS CONNECT proxy tunnel; Android's `VpnService` -is only the local adapter that feeds normal app TCP traffic into that proxy. +This release changes only the Android UI. The working DragonTCP Lite transport core and server binaries are unchanged from the previous Lite build. -## Architecture +## UI change -```text -Android apps - | - | IPv4 TCP / DNS packets - v -Android VpnService TUN - | - | lightweight userspace TCP adapter - v -127.0.0.1:8080 HTTP CONNECT - | - v -DragonTCP Go client - | - | adaptive records + mandatory XOR 0xAD - | TCP/53 - v -DragonTCP Lite server - | - v -Internet destination -``` +The main screen now contains only: -There is **no Linux TUN**, no server NAT, no raw-IP DragonTCP protocol, and no -packet batching on the DragonTCP server. The remote side is the same style of -lightweight stream proxy that worked in the earlier Termux tests. +- Server +- Port +- Optional token +- Max/min chunk +- Reconnect Every +- Timeout +- CONNECT +- STOP +- OPEN LOGS +- Connection status -## Defaults +The Live Log is no longer present anywhere in the main layout. -Android: +Press **OPEN LOGS** to open a dedicated full-screen Logs Activity. That page has its own scrolling area, BACK button, and CLEAR button. Log updates therefore cannot resize, overlay, or move the settings page. -```text -Server port: 53/TCP -Token: optional / empty allowed -Local proxy: 127.0.0.1:8080 -Pollers: 1 (fixed) -Reconnect Every: 1 -Chunk Start: Max Chunk -Chunk Min: 32 -Chunk Max: 1,048,576 bytes -Chunk Grow After: 16 successes -Chunk timeout: 2 seconds -XOR: 0xAD, mandatory -DNS: 1.1.1.1 through DNS-over-TCP through DragonTCP -``` +## Network behavior unchanged -The Android log is intentionally quiet. It shows service state, actual errors, -and DragonTCP adaptive changes such as: +- TCP/53 transport +- mandatory existing DragonTCP payload transform +- adaptive chunks +- 1 fixed poller +- reconnect-every default 1 +- max chunk 1 MiB +- optional empty token +- Android VPN/TUN frontend from the Lite build -```text -adaptive upload chunk: 65536 -> 32768 after transport failure -adaptive download chunk: 32 -> 64 after stable success -``` +The embedded Android DragonTCP core and both supplied server binaries are byte-for-byte identical to the previous Lite SplitUI build. -It does not redraw the screen or print periodic traffic statistics. +## Build -## Android traffic behavior - -- IPv4 TCP: forwarded through DragonTCP. -- DNS UDP/53: converted to DNS-over-TCP and sent to `1.1.1.1` through the local - DragonTCP HTTP CONNECT proxy. -- IPv6: captured by the VPN and dropped so it cannot bypass DragonTCP. -- Other UDP: not forwarded in this HTTP CONNECT build. -- ICMP/ping: not forwarded. - -This trade-off keeps DragonTCP itself lightweight and stream-oriented. - -## Start the server - -Port 53 is privileged on Linux, so run as root: - -```bash -sudo ./dragontcp-lite-server-linux-amd64 --chunk-max 1048576 -``` - -No token is required by default. To require one: - -```bash -sudo ./dragontcp-lite-server-linux-amd64 --token 'SECRET' --chunk-max 1048576 -``` - -The Android Token field must contain the same value. - -If you do not want to run the binary as root: - -```bash -sudo setcap cap_net_bind_service=+ep ./dragontcp-lite-server-linux-amd64 -./dragontcp-lite-server-linux-amd64 -``` - -If TCP/53 is already occupied by a DNS resolver, free that port first. - -## Android usage - -1. Install `DragonTCP-LiteVPN-arm64.apk`. -2. Enter the server address. -3. Leave Port at `53`. -4. Leave Token empty if the server was started without `--token`. -5. Keep `Reconnect every = 1` for restrictive networks. -6. Press **CONNECT** and approve Android's VPN dialog. -7. Press **STOP** to terminate both the TUN adapter and DragonTCP core. - -The app excludes its own UID from the VPN, so the DragonTCP TCP/53 transport -uses the physical/mobile network and does not loop into its own TUN interface. - -## Build everything from source - -Requirements: - -- Go 1.22+ -- JDK 17+ -- Android SDK platform + build-tools (35 works) -- Android 10 / API 29 or newer on the phone -- Kotlin compiler 1.9.x distribution containing - `kotlinx-coroutines-core-jvm.jar` -- `zip` - -Example environment: - -```bash -export ANDROID_SDK_ROOT="$HOME/Android/Sdk" -export KOTLIN_HOME="$HOME/.sdkman/candidates/kotlin/current" -``` - -Then: +The full source tree includes the existing build scripts. Set the Android SDK and Kotlin compiler paths and run: ```bash ./build_all.sh ``` -Outputs: - -```text -bin/dragontcp-lite-server-linux-amd64 -bin/dragontcp-lite-server-linux-arm64 -android/lib/arm64-v8a/libdragontcp_client.so -android/build/DragonTCP-LiteVPN-arm64.apk -``` - -`libdragontcp_client.so` is intentionally the Android ARM64 Go executable stored -in the APK native-library directory. `DragonService` launches it with -`ProcessBuilder`; it is not JNI. - -## Build only the Go core/server - -```bash -./build_core.sh -``` - -## Build only the APK - -After the core has been built: +Or build only the APK after the core already exists: ```bash cd android +ANDROID_SDK_ROOT=/path/to/android-sdk \ +KOTLIN_HOME=/path/to/kotlin \ ./build_apk.sh ``` -The script creates a local debug signing key if one does not already exist. -For production distribution, supply your own keystore/signing process. - -## Source layout - -```text -core/ DragonTCP Go client/server -android/src/com/dragontcp/client/ Android UI + VpnService -android/src/tech/xvanturing/... Lightweight TUN-to-proxy stack -android/lib/arm64-v8a/ Embedded DragonTCP Android core -licenses/ Third-party licenses -``` - -## Third-party stack - -The userspace Android TUN/TCP adapter is adapted from the Apache-2.0-licensed -FreeProxy project. See `THIRD_PARTY_NOTICES.md` and -`licenses/FreeProxy-APACHE-2.0.txt`. - -## Validation performed for this package - -- Go unit/build checks: PASS. -- Empty-token client/server protocol: PASS. -- Fixed 1 poller + reconnect every 1: PASS. -- 8 MiB HTTP transfer through local DragonTCP proxy: SHA-256 exact. -- Kotlin TUN adapter compilation: PASS. -- Android Java service/UI compilation: PASS. -- 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 Lite/v7 APK and has versionCode 10, 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. - -## v10 split UI update - -This release changes only the Android UI. The embedded DragonTCP transport -core and the Lite server are unchanged from the working Lite release. - -UI changes: - -- Live Log is a true sibling pane below the settings pane; it is never drawn - over the settings. -- The settings pane is explicitly clipped to its own region. -- The settings scrollbar is hidden, so no scrollbar can appear behind the log. -- Live Log keeps its own internal scroll area and receives appended lines - without rebuilding the Activity. -- A visible divider separates settings from Live Log. -- CONNECT remains disabled while connecting/connected and STOP remains state-aware. -- The visible XOR wording was removed from the Android UI. Transport behavior - itself is unchanged. -- The Activity explicitly uses `adjustResize` when the keyboard opens. - -The APK is versionCode 10 / versionName `10.0-lite-split-ui` and is signed with -the same certificate as the previous Lite UI build. +For an APK that updates the supplied release, sign with the same signing key/certificate used by the previous DragonTCP APK. diff --git a/android/AndroidManifest.xml b/android/AndroidManifest.xml index 1bb5d5f..d240009 100644 --- a/android/AndroidManifest.xml +++ b/android/AndroidManifest.xml @@ -1,8 +1,8 @@ + android:versionCode="11" + android:versionName="11.0-lite-logs-page"> @@ -27,6 +27,12 @@ + + runOnUiThread(() -> appendLogLine(line)); + + @Override + public void onCreate(Bundle state) { + super.onCreate(state); + forceDarkSystemUi(); + buildUi(); + } + + @Override + protected void onStart() { + super.onStart(); + logText.setText(AppLog.history()); + AppLog.addListener(logListener); + logScroll.post(this::scrollLogToBottom); + } + + @Override + protected void onStop() { + AppLog.removeListener(logListener); + 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() { + LinearLayout root = new LinearLayout(this); + root.setOrientation(LinearLayout.VERTICAL); + root.setBackgroundColor(BG); + root.setPadding(dp(16), dp(14), dp(16), dp(12)); + + 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("Logs", 25, TEXT, true); + TextView subtitle = text("DragonTCP Lite", 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)); + + Button back = smallButton("BACK"); + Button clear = smallButton("CLEAR"); + LinearLayout.LayoutParams bp = new LinearLayout.LayoutParams(dp(76), dp(42)); + bp.setMarginEnd(dp(8)); + header.addView(back, bp); + header.addView(clear, new LinearLayout.LayoutParams(dp(82), dp(42))); + root.addView(header); + + LinearLayout panel = new LinearLayout(this); + panel.setOrientation(LinearLayout.VERTICAL); + panel.setPadding(dp(10), dp(10), dp(10), dp(10)); + panel.setBackground(roundRect(CARD, 16, BORDER, 1)); + + 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(12f); + logText.setTextColor(LOG_TEXT); + logText.setTypeface(Typeface.MONOSPACE); + logText.setTextIsSelectable(true); + logText.setPadding(dp(11), dp(10), dp(11), dp(10)); + logText.setLineSpacing(0, 1.10f); + logScroll.addView(logText, new ScrollView.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + )); + + panel.addView(logScroll, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + 0, + 1f + )); + root.addView(panel, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + 0, + 1f + )); + + back.setOnClickListener(v -> finish()); + clear.setOnClickListener(v -> { + AppLog.clear(); + logText.setText(""); + logScroll.scrollTo(0, 0); + }); + + setContentView(root); + } + + private void appendLogLine(String line) { + View child = logScroll.getChildAt(0); + int contentHeight = child == null ? 0 : child.getHeight(); + int maxScroll = Math.max(0, contentHeight - logScroll.getHeight()); + boolean follow = maxScroll - logScroll.getScrollY() < dp(48); + + logText.append(line + "\n"); + 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 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 smallButton(String value) { + Button b = new Button(this); + b.setText(value); + b.setAllCaps(false); + b.setTextSize(11); + b.setTypeface(Typeface.DEFAULT, Typeface.BOLD); + b.setTextColor(TEXT); + 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 int dp(int value) { + return Math.round(value * getResources().getDisplayMetrics().density); + } +} diff --git a/android/src/com/dragontcp/client/MainActivity.java b/android/src/com/dragontcp/client/MainActivity.java index 5c8d1bb..3f8fb5a 100644 --- a/android/src/com/dragontcp/client/MainActivity.java +++ b/android/src/com/dragontcp/client/MainActivity.java @@ -19,10 +19,10 @@ import android.view.ViewGroup; import android.view.Window; import android.widget.Button; import android.widget.EditText; -import android.widget.FrameLayout; import android.widget.LinearLayout; import android.widget.ScrollView; import android.widget.TextView; +import android.widget.Toast; public class MainActivity extends Activity { private static final int VPN_REQUEST = 100; @@ -35,11 +35,8 @@ public class MainActivity extends Activity { 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); @@ -53,15 +50,12 @@ public class MainActivity extends Activity { private Button connectButton; private Button stopButton; + private Button logsButton; 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) { @@ -84,13 +78,6 @@ 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(this::scrollLogToBottom); - if (!receiverRegistered) { registerReceiver(stateReceiver, new IntentFilter(DragonService.ACTION_STATE)); receiverRegistered = true; @@ -100,7 +87,6 @@ public class MainActivity extends Activity { @Override protected void onStop() { - AppLog.removeListener(logListener); if (receiverRegistered) { try { unregisterReceiver(stateReceiver); } catch (Throwable ignored) {} receiverRegistered = false; @@ -112,9 +98,7 @@ public class MainActivity extends Activity { Window w = getWindow(); w.setStatusBarColor(BG); w.setNavigationBarColor(Color.rgb(7, 10, 14)); - if (Build.VERSION.SDK_INT >= 26) { - w.getDecorView().setSystemUiVisibility(0); - } + if (Build.VERSION.SDK_INT >= 26) w.getDecorView().setSystemUiVisibility(0); } private void buildUi() { @@ -122,10 +106,7 @@ public class MainActivity extends Activity { root.setOrientation(LinearLayout.VERTICAL); root.setBackgroundColor(BG); root.setPadding(dp(16), dp(14), dp(16), dp(12)); - root.setClipChildren(true); - root.setClipToPadding(true); - // Header is outside both scrolling regions. LinearLayout header = new LinearLayout(this); header.setOrientation(LinearLayout.HORIZONTAL); header.setGravity(Gravity.CENTER_VERTICAL); @@ -147,42 +128,18 @@ public class MainActivity extends Activity { header.addView(statusChip); root.addView(header); - // The remaining screen is an explicit vertical split. The settings pane and - // Live Log pane are siblings; neither is drawn over the other. - LinearLayout split = new LinearLayout(this); - split.setOrientation(LinearLayout.VERTICAL); - split.setClipChildren(true); - split.setClipToPadding(true); - root.addView(split, new LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - 0, - 1f - )); - - FrameLayout settingsPane = new FrameLayout(this); - settingsPane.setBackgroundColor(BG); - settingsPane.setClipChildren(true); - settingsPane.setClipToPadding(true); - - ScrollView settingsScroll = new ScrollView(this); - settingsScroll.setFillViewport(false); - settingsScroll.setClipToPadding(true); - settingsScroll.setClipChildren(true); - settingsScroll.setOverScrollMode(View.OVER_SCROLL_IF_CONTENT_SCROLLS); - // Avoid a scrollbar visually continuing into the separate log pane. - settingsScroll.setVerticalScrollBarEnabled(false); + ScrollView scroll = new ScrollView(this); + scroll.setFillViewport(false); + scroll.setOverScrollMode(View.OVER_SCROLL_IF_CONTENT_SCROLLS); + scroll.setVerticalScrollBarEnabled(false); LinearLayout settings = new LinearLayout(this); settings.setOrientation(LinearLayout.VERTICAL); - settings.setPadding(0, 0, 0, dp(8)); - settingsScroll.addView(settings, new ScrollView.LayoutParams( + settings.setPadding(0, 0, 0, dp(12)); + scroll.addView(settings, new ScrollView.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT )); - settingsPane.addView(settingsScroll, new FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - )); LinearLayout connectionCard = card("CONNECTION"); server = addField(connectionCard, "Server", "Server IP or hostname", "", false, false); @@ -220,6 +177,14 @@ public class MainActivity extends Activity { buttons.addView(stopButton, right); settings.addView(buttons); + logsButton = actionButton("OPEN LOGS"); + logsButton.setTextColor(TEXT); + logsButton.setBackground(roundRect(Color.rgb(32, 40, 51), 14, BORDER, 1)); + LinearLayout.LayoutParams logsParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, dp(50)); + logsParams.setMargins(0, 0, 0, dp(10)); + settings.addView(logsButton, logsParams); + 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 @@ -228,78 +193,15 @@ public class MainActivity extends Activity { note.setPadding(dp(4), dp(2), dp(4), dp(6)); settings.addView(note); - // Give settings the upper portion of the available body. This FrameLayout - // clips its ScrollView, making it impossible for settings to draw behind logs. - split.addView(settingsPane, new LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - 0, - 0.58f - )); - - View divider = new View(this); - divider.setBackgroundColor(BORDER); - LinearLayout.LayoutParams dividerParams = new LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - dp(1) - ); - dividerParams.setMargins(dp(6), dp(7), dp(6), dp(7)); - split.addView(divider, dividerParams); - - // Live Log is a true sibling below settings, not an overlay. - LinearLayout logPanel = new LinearLayout(this); - logPanel.setOrientation(LinearLayout.VERTICAL); - logPanel.setClipChildren(true); - logPanel.setClipToPadding(true); - logPanel.setBackground(roundRect(CARD, 16, BORDER, 1)); - logPanel.setPadding(dp(10), dp(8), dp(10), dp(10)); - - LinearLayout logHeader = row(); - logHeader.setGravity(Gravity.CENTER_VERTICAL); - 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.setClipChildren(true); - logScroll.setClipToPadding(true); - 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(11.5f); - logText.setTextColor(LOG_TEXT); - logText.setTypeface(Typeface.MONOSPACE); - logText.setTextIsSelectable(true); - 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 - )); - logPanel.addView(logScroll, new LinearLayout.LayoutParams( + root.addView(scroll, new LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f )); - split.addView(logPanel, new LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - 0, - 0.42f - )); - - clear.setOnClickListener(v -> { - AppLog.clear(); - logText.setText(""); - logScroll.scrollTo(0, 0); - }); - connectButton.setOnClickListener(v -> requestConnect()); stopButton.setOnClickListener(v -> requestStop()); + logsButton.setOnClickListener(v -> startActivity(new Intent(this, LogActivity.class))); setContentView(root); updateConnectionUi(false, "DISCONNECTED"); @@ -395,13 +297,6 @@ public class MainActivity extends Activity { 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); @@ -415,20 +310,18 @@ public class MainActivity extends Activity { try { validateAndSave(); } catch (Exception e) { - AppLog.append("CONFIG: " + e.getMessage()); + String msg = e.getMessage() == null ? "Invalid configuration" : e.getMessage(); + AppLog.append("CONFIG: " + msg); + Toast.makeText(this, msg, Toast.LENGTH_SHORT).show(); 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); - } else { - startDragonService(); - } + if (prepare != null) startActivityForResult(prepare, VPN_REQUEST); + else startDragonService(); } private void requestStop() { @@ -533,26 +426,6 @@ public class MainActivity extends Activity { stopButton.setBackground(roundRect(canStop ? STOP : Color.rgb(32, 38, 47), 14, canStop ? STOP : BORDER, 1)); } - private void appendLogLine(String line) { - View child = logScroll.getChildAt(0); - 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"); - - // 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) { return Math.round(value * getResources().getDisplayMetrics().density); }