From c0a76ef26361a7d25454d3735905be339d51cc44 Mon Sep 17 00:00:00 2001 From: Steffen Heil | secforge Date: Wed, 5 Nov 2025 17:47:52 +0100 Subject: [PATCH] Add WiFi hotspot port forwarding with bind address selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enables port forwards to bind to WiFi hotspot interface, allowing devices connected to the Android device's hotspot to access forwarded services. This complements the existing localhost and all-interfaces bind options. Features: - New bind address option for WiFi hotspot (access_point constant) - Automatic detection of hotspot interface IP via NetworkInterface API - Background monitoring that retries failed hotspot forwards when hotspot state changes (activated, deactivated, IP changes) - UI updates: bind address spinner in port forward editor with three options (localhost, all interfaces, WiFi hotspot) - Visual security warnings for network-exposed binds (all interfaces and hotspot) - Database schema migration from version 26 to version 27 adds bindaddr column to portforwards table - Thread-safe bridge list access in TerminalManager with synchronized helper methods to prevent concurrent modification during background monitoring - Sentinel-based state change detection that tracks last known hotspot IP without polluting state from UI queries - Filtering logic that excludes cellular interfaces (rmnet*, v4-rmnet*, clat*, ccmni*) from hotspot detection Implementation details: - NetworkUtils provides getHotspotInterfaceIP() for IP detection and hasAccessPointStateChanged() for state monitoring - TerminalManager.ApStateMonitorTask runs every 10 seconds to check for hotspot state changes and retry failed access_point forwards - getBridgesCopy(), hasBridges(), getBridgeCount() helpers ensure thread-safe iteration over active connection bridges - Port forward validation prevents binding to access_point when hotspot is not active - Removed unused Message import 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- app/src/main/AndroidManifest.xml | 1 + .../java/org/connectbot/ConsoleActivity.java | 28 +- .../java/org/connectbot/HostListActivity.java | 127 +++++- .../connectbot/PortForwardListActivity.java | 105 ++++- .../org/connectbot/bean/PortForwardBean.java | 86 +++- .../service/AccessPointReceiver.java | 86 ++++ .../service/ConnectionNotifier.java | 35 +- .../connectbot/service/TerminalBridge.java | 20 +- .../connectbot/service/TerminalManager.java | 233 +++++++++- .../java/org/connectbot/transport/SSH.java | 80 +++- .../org/connectbot/util/HostDatabase.java | 17 +- .../org/connectbot/util/NetworkUtils.java | 349 ++++++++++++++ app/src/main/res/layout/act_hostlist.xml | 1 + app/src/main/res/layout/dia_portforward.xml | 52 +++ app/src/main/res/values/strings.xml | 19 + .../org/connectbot/util/NetworkUtilsTest.java | 430 ++++++++++++++++++ 16 files changed, 1609 insertions(+), 60 deletions(-) create mode 100644 app/src/main/java/org/connectbot/service/AccessPointReceiver.java create mode 100644 app/src/main/java/org/connectbot/util/NetworkUtils.java create mode 100644 app/src/test/java/org/connectbot/util/NetworkUtilsTest.java diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index fec2d7ee61..7d6088568c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -21,6 +21,7 @@ + diff --git a/app/src/main/java/org/connectbot/ConsoleActivity.java b/app/src/main/java/org/connectbot/ConsoleActivity.java index 98b9fe6bd6..73fe04439f 100644 --- a/app/src/main/java/org/connectbot/ConsoleActivity.java +++ b/app/src/main/java/org/connectbot/ConsoleActivity.java @@ -180,13 +180,19 @@ public void onServiceConnected(ComponentName className, IBinder service) { if (requestedBridge != null) requestedBridge.promptHelper.setListener(promptListener); - if (requestedIndex != -1) { + // Check if this is a background connection (don't switch to console view) + boolean backgroundConnection = getIntent().getBooleanExtra("org.connectbot.BACKGROUND_CONNECTION", false); + + if (requestedIndex != -1 && !backgroundConnection) { pager.post(new Runnable() { @Override public void run() { setDisplayedTerminal(requestedIndex); } }); + } else if (backgroundConnection) { + // For background connections, finish the activity after connection is established + finish(); } } @@ -990,13 +996,6 @@ public void onPause() { if (forcedOrientation && bound != null) { bound.setResizeAllowed(false); } - - // Clear prompt listeners when activity is not visible to prevent stale references - if (bound != null) { - for (TerminalBridge bridge : bound.getBridges()) { - bridge.promptHelper.clearListener(); - } - } } @Override @@ -1014,13 +1013,6 @@ public void onResume() { configureOrientation(); - // Restore prompt listeners when activity becomes visible - if (bound != null) { - for (TerminalBridge bridge : bound.getBridges()) { - bridge.promptHelper.setListener(promptListener); - } - } - if (forcedOrientation && bound != null) { bound.setResizeAllowed(true); } @@ -1257,6 +1249,7 @@ public Object instantiateItem(ViewGroup container, int position) { Log.w(TAG, "Activity not bound when creating TerminalView."); } TerminalBridge bridge = bound.getBridges().get(position); + bridge.promptHelper.setListener(promptListener); // inflate each terminal view RelativeLayout view = (RelativeLayout) inflater.inflate( @@ -1276,9 +1269,6 @@ public Object instantiateItem(ViewGroup container, int position) { container.addView(view); terminalNameOverlay.startAnimation(fade_out_delayed); - - bridge.promptHelper.setListener(promptListener); - return view; } @@ -1351,7 +1341,7 @@ public TerminalView getCurrentTerminalView() { if (currentView == null) { return null; } - return currentView.findViewById(R.id.terminal_view); + return (TerminalView) currentView.findViewById(R.id.terminal_view); } } } diff --git a/app/src/main/java/org/connectbot/HostListActivity.java b/app/src/main/java/org/connectbot/HostListActivity.java index 855741adda..09d9236074 100644 --- a/app/src/main/java/org/connectbot/HostListActivity.java +++ b/app/src/main/java/org/connectbot/HostListActivity.java @@ -41,7 +41,11 @@ import androidx.core.app.ActivityCompat; import androidx.core.content.ContextCompat; import androidx.recyclerview.widget.LinearLayoutManager; +import android.text.SpannableString; +import android.text.SpannableStringBuilder; +import android.text.Spanned; import android.text.format.DateUtils; +import android.text.style.StrikethroughSpan; import android.util.Log; import android.view.ContextMenu; import android.view.LayoutInflater; @@ -55,12 +59,14 @@ import android.widget.TextView; import org.connectbot.bean.HostBean; +import org.connectbot.bean.PortForwardBean; import org.connectbot.data.HostStorage; import org.connectbot.service.OnHostStatusChangedListener; import org.connectbot.service.TerminalBridge; import org.connectbot.service.TerminalManager; import org.connectbot.transport.TransportFactory; import org.connectbot.util.HostDatabase; +import org.connectbot.util.NetworkUtils; import org.connectbot.util.PreferenceConstants; import java.util.List; @@ -446,7 +452,13 @@ protected void updateList() { @Override public void onHostStatusChanged() { - updateList(); + // Ensure UI updates happen on the main thread + runOnUiThread(new Runnable() { + @Override + public void run() { + updateList(); + } + }); } @VisibleForTesting @@ -463,6 +475,46 @@ public HostViewHolder(View v) { icon = v.findViewById(android.R.id.icon); nickname = v.findViewById(android.R.id.text1); caption = v.findViewById(android.R.id.text2); + + // Add click listener for the icon to connect/disconnect + icon.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + handleIconClick(); + } + }); + } + + /** + * Handle icon click to connect/disconnect host + */ + private void handleIconClick() { + if (host == null) { + return; + } + + // Get the adapter from the parent activity to access the manager + HostAdapter adapter = (HostAdapter) mListView.getAdapter(); + if (adapter == null || adapter.manager == null) { + return; + } + + int state = adapter.getConnectedState(host); + if (state == HostAdapter.STATE_CONNECTED) { + // Disconnect if connected + TerminalBridge bridge = adapter.manager.getConnectedBridge(host); + if (bridge != null) { + bridge.dispatchDisconnect(true); + } + } else { + // Connect directly using the bound service + try { + Uri uri = host.getUri(); + adapter.manager.openConnection(uri); + } catch (Exception e) { + Log.e(TAG, "Failed to open connection for host: " + host.getNickname(), e); + } + } } @Override @@ -566,7 +618,7 @@ public void onClick(DialogInterface dialog, int which) { } } - @VisibleForTesting + private class HostAdapter extends ItemAdapter { private final List hosts; private final TerminalManager manager; @@ -678,9 +730,80 @@ public void onBindViewHolder(ItemViewHolder holder, int position) { nice = DateUtils.getRelativeTimeSpanString(host.getLastConnect() * 1000); } + // For connected hosts, show port forwarding information + if (this.getConnectedState(host) == STATE_CONNECTED && this.manager != null) { + TerminalBridge bridge = this.manager.getConnectedBridge(host); + if (bridge != null) { + List portForwards = bridge.getPortForwards(); + if (portForwards != null && !portForwards.isEmpty()) { + SpannableStringBuilder portInfo = new SpannableStringBuilder(); + for (int i = 0; i < portForwards.size(); i++) { + PortForwardBean forward = portForwards.get(i); + if (i > 0) { + portInfo.append("\n"); + } + portInfo.append(getSimplePortForwardDescription(forward)); + } + nice = portInfo; + } + } + } + hostHolder.caption.setText(nice); } + /** + * Check if a port forward is dysfunctional (should be struck through) + * Uses the same logic as PortForwardListActivity: hostBridge != null && !forward.isEnabled() + * But adds special case for AP forwards when AP is unavailable + */ + private boolean isPortForwardDysfunctional(PortForwardBean forward) { + // Special case: AP forwards are dysfunctional when AP is not available + // regardless of their enabled state + if (NetworkUtils.BIND_ACCESS_POINT.equals(forward.getBindAddress())) { + String apIP = NetworkUtils.getAccessPointIP(context); + if (apIP == null) { + return true; // AP forward but no AP available = dysfunctional + } + } + + // For all forwards (including AP when AP is available): + // Use the same logic as PortForwardListActivity + // Strike through if not enabled (connected but forward failed) + return !forward.isEnabled(); + } + + /** + * Generate a simplified port forward description with strike-through for dysfunctional forwards + */ + private CharSequence getSimplePortForwardDescription(PortForwardBean forward) { + String bindInfo = NetworkUtils.getSimpleBindAddressDisplay(forward.getBindAddress(), context); + String description; + + if (HostDatabase.PORTFORWARD_LOCAL.equals(forward.getType())) { + description = String.format("Local %s:%d → %s:%d", bindInfo, forward.getSourcePort(), + forward.getDestAddr(), forward.getDestPort()); + } else if (HostDatabase.PORTFORWARD_REMOTE.equals(forward.getType())) { + description = String.format("Remote %d → %s:%d", forward.getSourcePort(), + forward.getDestAddr(), forward.getDestPort()); + } else if (HostDatabase.PORTFORWARD_DYNAMIC5.equals(forward.getType())) { + description = String.format("Dynamic %s:%d (SOCKS)", bindInfo, forward.getSourcePort()); + } else { + description = "Unknown type"; + } + + // Apply strike-through only for dysfunctional port forwards + if (isPortForwardDysfunctional(forward)) { + SpannableString spannableDescription = new SpannableString(description); + spannableDescription.setSpan(new StrikethroughSpan(), 0, description.length(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + return spannableDescription; + } + + return description; + } + + @Override public long getItemId(int position) { return hosts.get(position).getId(); diff --git a/app/src/main/java/org/connectbot/PortForwardListActivity.java b/app/src/main/java/org/connectbot/PortForwardListActivity.java index e0a541f521..63ab904ebc 100644 --- a/app/src/main/java/org/connectbot/PortForwardListActivity.java +++ b/app/src/main/java/org/connectbot/PortForwardListActivity.java @@ -25,6 +25,7 @@ import org.connectbot.service.TerminalBridge; import org.connectbot.service.TerminalManager; import org.connectbot.util.HostDatabase; +import org.connectbot.util.NetworkUtils; import android.annotation.SuppressLint; import android.content.ComponentName; @@ -51,6 +52,7 @@ import android.widget.AdapterView; import android.widget.AdapterView.OnItemSelectedListener; import android.widget.EditText; +import android.widget.RadioGroup; import android.widget.Spinner; import android.widget.TextView; import android.widget.Toast; @@ -155,16 +157,44 @@ public void onClick(View v) { final View portForwardView = View.inflate(PortForwardListActivity.this, R.layout.dia_portforward, null); final EditText destEdit = portForwardView.findViewById(R.id.portforward_destination); final Spinner typeSpinner = portForwardView.findViewById(R.id.portforward_type); + final RadioGroup bindAddressGroup = portForwardView.findViewById(R.id.bind_address_group); + final View bindAddressLabel = portForwardView.findViewById(R.id.bind_address_label); + final View securityWarningRow = portForwardView.findViewById(R.id.security_warning_row); typeSpinner.setOnItemSelectedListener(new OnItemSelectedListener() { @Override public void onItemSelected(AdapterView value, View view, int position, long id) { destEdit.setEnabled(position != 2); + // Update bind address options visibility for different forward types + if (position == 1) { // Remote forward + // Remote forwards don't need bind address selection + bindAddressGroup.setVisibility(View.GONE); + bindAddressLabel.setVisibility(View.GONE); + securityWarningRow.setVisibility(View.GONE); + } else { + bindAddressGroup.setVisibility(View.VISIBLE); + bindAddressLabel.setVisibility(View.VISIBLE); + updateSecurityWarning(); + } } @Override public void onNothingSelected(AdapterView arg0) { } + + private void updateSecurityWarning() { + int checkedId = bindAddressGroup.getCheckedRadioButtonId(); + boolean showWarning = (checkedId == R.id.bind_all_interfaces || checkedId == R.id.bind_access_point); + securityWarningRow.setVisibility(showWarning ? View.VISIBLE : View.GONE); + } + }); + + bindAddressGroup.setOnCheckedChangeListener(new RadioGroup.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(RadioGroup group, int checkedId) { + boolean showWarning = (checkedId == R.id.bind_all_interfaces || checkedId == R.id.bind_access_point); + securityWarningRow.setVisibility(showWarning ? View.VISIBLE : View.GONE); + } }); new androidx.appcompat.app.AlertDialog.Builder( @@ -201,12 +231,22 @@ public void onClick(DialogInterface dialog, int which) { destination = destEdit.getHint().toString(); } + // Get bind address selection + String bindAddress = NetworkUtils.BIND_LOCALHOST; // default + int checkedId = bindAddressGroup.getCheckedRadioButtonId(); + if (checkedId == R.id.bind_all_interfaces) { + bindAddress = NetworkUtils.BIND_ALL_INTERFACES; + } else if (checkedId == R.id.bind_access_point) { + bindAddress = NetworkUtils.BIND_ACCESS_POINT; + } + PortForwardBean portForward = new PortForwardBean( host != null ? host.getId() : -1, nicknameEdit.getText().toString(), type, sourcePort, - destination); + destination, + bindAddress); if (hostBridge != null) { hostBridge.addPortForward(portForward); @@ -305,16 +345,69 @@ else if (HostDatabase.PORTFORWARD_REMOTE.equals(portForward.getType())) destEdit.setText(String.format("%s:%d", portForward.getDestAddr(), portForward.getDestPort())); } + final RadioGroup bindAddressGroup = editTunnelView.findViewById(R.id.bind_address_group); + final View bindAddressLabel = editTunnelView.findViewById(R.id.bind_address_label); + final View securityWarningRow = editTunnelView.findViewById(R.id.security_warning_row); + + // Set bind address selection based on current value + String currentBindAddress = portForward.getBindAddress(); + if (NetworkUtils.BIND_ALL_INTERFACES.equals(currentBindAddress)) { + bindAddressGroup.check(R.id.bind_all_interfaces); + } else if (NetworkUtils.BIND_ACCESS_POINT.equals(currentBindAddress)) { + bindAddressGroup.check(R.id.bind_access_point); + } else { + bindAddressGroup.check(R.id.bind_localhost); + } + + // Show/hide bind address options based on forward type + if (HostDatabase.PORTFORWARD_REMOTE.equals(portForward.getType())) { + bindAddressGroup.setVisibility(View.GONE); + bindAddressLabel.setVisibility(View.GONE); + securityWarningRow.setVisibility(View.GONE); + } else { + bindAddressGroup.setVisibility(View.VISIBLE); + bindAddressLabel.setVisibility(View.VISIBLE); + // Show security warning if needed + int checkedId = bindAddressGroup.getCheckedRadioButtonId(); + boolean showWarning = (checkedId == R.id.bind_all_interfaces || checkedId == R.id.bind_access_point); + securityWarningRow.setVisibility(showWarning ? View.VISIBLE : View.GONE); + } + typeSpinner.setOnItemSelectedListener(new OnItemSelectedListener() { @Override public void onItemSelected(AdapterView value, View view, int position, long id) { destEdit.setEnabled(position != 2); + // Update bind address options visibility for different forward types + if (position == 1) { // Remote forward + // Remote forwards don't need bind address selection + bindAddressGroup.setVisibility(View.GONE); + bindAddressLabel.setVisibility(View.GONE); + securityWarningRow.setVisibility(View.GONE); + } else { + bindAddressGroup.setVisibility(View.VISIBLE); + bindAddressLabel.setVisibility(View.VISIBLE); + updateSecurityWarning(); + } } @Override public void onNothingSelected(AdapterView arg0) { } + + private void updateSecurityWarning() { + int checkedId = bindAddressGroup.getCheckedRadioButtonId(); + boolean showWarning = (checkedId == R.id.bind_all_interfaces || checkedId == R.id.bind_access_point); + securityWarningRow.setVisibility(showWarning ? View.VISIBLE : View.GONE); + } + }); + + bindAddressGroup.setOnCheckedChangeListener(new RadioGroup.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(RadioGroup group, int checkedId) { + boolean showWarning = (checkedId == R.id.bind_all_interfaces || checkedId == R.id.bind_access_point); + securityWarningRow.setVisibility(showWarning ? View.VISIBLE : View.GONE); + } }); new androidx.appcompat.app.AlertDialog.Builder( @@ -344,6 +437,16 @@ public void onClick(DialogInterface dialog, int which) { portForward.setSourcePort(Integer.parseInt(sourcePortEdit.getText().toString())); portForward.setDest(destEdit.getText().toString()); + // Get bind address selection + String bindAddress = NetworkUtils.BIND_LOCALHOST; // default + int checkedId = bindAddressGroup.getCheckedRadioButtonId(); + if (checkedId == R.id.bind_all_interfaces) { + bindAddress = NetworkUtils.BIND_ALL_INTERFACES; + } else if (checkedId == R.id.bind_access_point) { + bindAddress = NetworkUtils.BIND_ACCESS_POINT; + } + portForward.setBindAddress(bindAddress); + // Use the new settings for the existing connection. if (hostBridge != null) updateHandler.postDelayed(new Runnable() { diff --git a/app/src/main/java/org/connectbot/bean/PortForwardBean.java b/app/src/main/java/org/connectbot/bean/PortForwardBean.java index 5254ac4be1..4b03405fa9 100644 --- a/app/src/main/java/org/connectbot/bean/PortForwardBean.java +++ b/app/src/main/java/org/connectbot/bean/PortForwardBean.java @@ -18,9 +18,11 @@ package org.connectbot.bean; import org.connectbot.util.HostDatabase; +import org.connectbot.util.NetworkUtils; import android.annotation.SuppressLint; import android.content.ContentValues; +import android.content.Context; /** @@ -38,6 +40,7 @@ public class PortForwardBean extends AbstractBean { private int sourcePort = -1; private String destAddr = null; private int destPort = -1; + private String bindAddress = "localhost"; /* Transient values */ private boolean enabled = false; @@ -59,6 +62,27 @@ public PortForwardBean(long id, long hostId, String nickname, String type, int s this.sourcePort = sourcePort; this.destAddr = destAddr; this.destPort = destPort; + this.bindAddress = "localhost"; + } + + /** + * @param id database ID of port forward + * @param nickname Nickname to use to identify port forward + * @param type One of the port forward types from {@link HostDatabase} + * @param sourcePort Source port number + * @param destAddr Destination hostname or IP address + * @param destPort Destination port number + * @param bindAddress Bind address for port forward + */ + public PortForwardBean(long id, long hostId, String nickname, String type, int sourcePort, String destAddr, int destPort, String bindAddress) { + this.id = id; + this.hostId = hostId; + this.nickname = nickname; + this.type = type; + this.sourcePort = sourcePort; + this.destAddr = destAddr; + this.destPort = destPort; + this.bindAddress = bindAddress != null ? bindAddress : "localhost"; } /** @@ -71,6 +95,23 @@ public PortForwardBean(long hostId, String nickname, String type, String source, this.nickname = nickname; this.type = type; this.sourcePort = Integer.parseInt(source); + this.bindAddress = "localhost"; + + setDest(dest); + } + + /** + * @param type One of the port forward types from {@link HostDatabase} + * @param source Source port number + * @param dest Destination is "host:port" format + * @param bindAddress Bind address for port forward + */ + public PortForwardBean(long hostId, String nickname, String type, String source, String dest, String bindAddress) { + this.hostId = hostId; + this.nickname = nickname; + this.type = type; + this.sourcePort = Integer.parseInt(source); + this.bindAddress = bindAddress != null ? bindAddress : "localhost"; setDest(dest); } @@ -175,6 +216,20 @@ public int getDestPort() { return destPort; } + /** + * @param bindAddress the bindAddress to set + */ + public void setBindAddress(String bindAddress) { + this.bindAddress = bindAddress != null ? bindAddress : "localhost"; + } + + /** + * @return the bindAddress + */ + public String getBindAddress() { + return bindAddress; + } + /** * @param enabled the enabled to set */ @@ -208,19 +263,45 @@ public Object getIdentifier() { */ @SuppressLint("DefaultLocale") public CharSequence getDescription() { + return getDescription(null); + } + + /** + * @param context Android context for hotspot IP resolution (can be null) + * @return human readable description of the port forward + */ + @SuppressLint("DefaultLocale") + public CharSequence getDescription(Context context) { String description = "Unknown type"; + String bindInfo = context != null ? + NetworkUtils.getBindAddressDisplayName(bindAddress, context) : + getSimpleBindAddressDisplayName(); if (HostDatabase.PORTFORWARD_LOCAL.equals(type)) { - description = String.format("Local port %d to %s:%d", sourcePort, destAddr, destPort); + description = String.format("Local port %s:%d to %s:%d", bindInfo, sourcePort, destAddr, destPort); } else if (HostDatabase.PORTFORWARD_REMOTE.equals(type)) { description = String.format("Remote port %d to %s:%d", sourcePort, destAddr, destPort); } else if (HostDatabase.PORTFORWARD_DYNAMIC5.equals(type)) { - description = String.format("Dynamic port %d (SOCKS)", sourcePort); + description = String.format("Dynamic port %s:%d (SOCKS)", bindInfo, sourcePort); } return description; } + /** + * Simple bind address display name without context (fallback) + * @return human readable bind address name + */ + private String getSimpleBindAddressDisplayName() { + if (NetworkUtils.BIND_ALL_INTERFACES.equals(bindAddress)) { + return "all interfaces"; + } else if (NetworkUtils.BIND_ACCESS_POINT.equals(bindAddress)) { + return "WiFi hotspot"; + } else { + return "localhost"; + } + } + /** * @return */ @@ -234,6 +315,7 @@ public ContentValues getValues() { values.put(HostDatabase.FIELD_PORTFORWARD_SOURCEPORT, sourcePort); values.put(HostDatabase.FIELD_PORTFORWARD_DESTADDR, destAddr); values.put(HostDatabase.FIELD_PORTFORWARD_DESTPORT, destPort); + values.put(HostDatabase.FIELD_PORTFORWARD_BINDADDR, bindAddress); return values; } diff --git a/app/src/main/java/org/connectbot/service/AccessPointReceiver.java b/app/src/main/java/org/connectbot/service/AccessPointReceiver.java new file mode 100644 index 0000000000..ad2d3209ce --- /dev/null +++ b/app/src/main/java/org/connectbot/service/AccessPointReceiver.java @@ -0,0 +1,86 @@ +/* + * ConnectBot: simple, powerful, open-source SSH client for Android + * Copyright 2007 Kenny Root, Jeffrey Sharkey + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.connectbot.service; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.util.Log; + +/** + * BroadcastReceiver to monitor WiFi hotspot state changes for faster response times. + * This provides immediate notification when the hotspot state changes, complementing + * the polling timer fallback for devices that don't reliably send these broadcasts. + * + * @author ConnectBot Team + */ +public class AccessPointReceiver extends BroadcastReceiver { + private static final String TAG = "CB.AccessPointReceiver"; + + // WiFi AP state change action (may not be reliable on all devices) + private static final String WIFI_AP_STATE_CHANGED_ACTION = "android.net.wifi.WIFI_AP_STATE_CHANGED"; + + final private TerminalManager mTerminalManager; + + public AccessPointReceiver(TerminalManager manager) { + mTerminalManager = manager; + + // Register for WiFi AP state changes + // Note: This action is not officially part of the Android API and may not work on all devices + // That's why we keep the polling timer as a reliable fallback + final IntentFilter filter = new IntentFilter(); + filter.addAction(WIFI_AP_STATE_CHANGED_ACTION); + + try { + manager.registerReceiver(this, filter); + Log.d(TAG, "AccessPointReceiver registered for WIFI_AP_STATE_CHANGED"); + } catch (Exception e) { + // Some devices might not support this broadcast + Log.w(TAG, "Failed to register for WIFI_AP_STATE_CHANGED: " + e.getMessage()); + } + } + + @Override + public void onReceive(Context context, Intent intent) { + final String action = intent.getAction(); + + if (!WIFI_AP_STATE_CHANGED_ACTION.equals(action)) { + Log.w(TAG, "onReceive() called with unexpected action: " + action); + return; + } + + Log.d(TAG, "WiFi AP state change detected via broadcast - triggering immediate check"); + + // Trigger immediate AP state check for faster response + // The existing checkAccessPointStateChange() method handles all the logic + mTerminalManager.checkAccessPointStateChange(); + } + + /** + * Cleanup and unregister the receiver + */ + public void cleanup() { + try { + mTerminalManager.unregisterReceiver(this); + Log.d(TAG, "AccessPointReceiver unregistered"); + } catch (Exception e) { + // Receiver might not have been registered successfully + Log.w(TAG, "Failed to unregister AccessPointReceiver: " + e.getMessage()); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/connectbot/service/ConnectionNotifier.java b/app/src/main/java/org/connectbot/service/ConnectionNotifier.java index 43e2f5586d..a1fd9125fa 100644 --- a/app/src/main/java/org/connectbot/service/ConnectionNotifier.java +++ b/app/src/main/java/org/connectbot/service/ConnectionNotifier.java @@ -126,6 +126,10 @@ else if (HostDatabase.COLOR_BLUE.equals(host.getColor())) } private Notification newRunningNotification(Context context) { + return newRunningNotification(context, null, false); + } + + private Notification newRunningNotification(Context context, String apIP, boolean hasApForwards) { NotificationCompat.Builder builder = newNotificationBuilder(context, NOTIFICATION_CHANNEL); Resources res = context.getResources(); @@ -142,12 +146,26 @@ private Notification newRunningNotification(Context context) { disconnectIntent, pendingIntentFlags); + String contentText = res.getString(R.string.app_is_running); + + // Add AP forwarding information if relevant + if (hasApForwards) { + if (apIP != null) { + contentText = res.getString(R.string.app_is_running) + "\n" + + res.getString(R.string.notification_access_point_text, apIP); + } else { + contentText = res.getString(R.string.app_is_running) + "\n" + + res.getString(R.string.notification_ap_disabled_text); + } + } + builder.setOngoing(true) .setWhen(0) .setSilent(true) .setContentIntent(pendingIntent) .setContentTitle(res.getString(R.string.app_name)) - .setContentText(res.getString(R.string.app_is_running)) + .setContentText(contentText) + .setStyle(new NotificationCompat.BigTextStyle().bigText(contentText)) .addAction( android.R.drawable.ic_menu_close_clear_cancel, res.getString(R.string.list_host_disconnect), @@ -161,17 +179,26 @@ void showActivityNotification(Service context, HostBean host) { } void showRunningNotification(Service context) { + showRunningNotification(context, null, false); + } + + void showRunningNotification(Service context, String apIP, boolean hasApForwards) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - showRunningNotificationWithType(context); + showRunningNotificationWithType(context, apIP, hasApForwards); return; } - context.startForeground(ONLINE_NOTIFICATION, newRunningNotification(context)); + context.startForeground(ONLINE_NOTIFICATION, newRunningNotification(context, apIP, hasApForwards)); } @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) void showRunningNotificationWithType(Service context) { - context.startForeground(ConnectionNotifier.ONLINE_NOTIFICATION, newRunningNotification(context), + showRunningNotificationWithType(context, null, false); + } + + @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + void showRunningNotificationWithType(Service context, String apIP, boolean hasApForwards) { + context.startForeground(ConnectionNotifier.ONLINE_NOTIFICATION, newRunningNotification(context, apIP, hasApForwards), ServiceInfo.FOREGROUND_SERVICE_TYPE_REMOTE_MESSAGING); } diff --git a/app/src/main/java/org/connectbot/service/TerminalBridge.java b/app/src/main/java/org/connectbot/service/TerminalBridge.java index 8d7fb14ce8..ebfea23295 100644 --- a/app/src/main/java/org/connectbot/service/TerminalBridge.java +++ b/app/src/main/java/org/connectbot/service/TerminalBridge.java @@ -954,7 +954,15 @@ public boolean enablePortForward(PortForwardBean portForward) { return false; } - return transport.enablePortForward(portForward); + boolean result = transport.enablePortForward(portForward); + + // Notify listeners when port forward state changes so UI can update + // Only notify if the state actually changed + if (manager != null && result) { + manager.notifyHostStatusChanged(); + } + + return result; } /** @@ -969,7 +977,15 @@ public boolean disablePortForward(PortForwardBean portForward) { return false; } - return transport.disablePortForward(portForward); + boolean result = transport.disablePortForward(portForward); + + // Notify listeners when port forward state changes so UI can update + // Only notify if the state actually changed + if (manager != null && result) { + manager.notifyHostStatusChanged(); + } + + return result; } /** diff --git a/app/src/main/java/org/connectbot/service/TerminalManager.java b/app/src/main/java/org/connectbot/service/TerminalManager.java index 2f27b2871f..488b3426ac 100644 --- a/app/src/main/java/org/connectbot/service/TerminalManager.java +++ b/app/src/main/java/org/connectbot/service/TerminalManager.java @@ -31,11 +31,13 @@ import org.connectbot.R; import org.connectbot.bean.HostBean; +import org.connectbot.bean.PortForwardBean; import org.connectbot.bean.PubkeyBean; import org.connectbot.data.ColorStorage; import org.connectbot.data.HostStorage; import org.connectbot.transport.TransportFactory; import org.connectbot.util.HostDatabase; +import org.connectbot.util.NetworkUtils; import org.connectbot.util.PreferenceConstants; import org.connectbot.util.ProviderLoader; import org.connectbot.util.ProviderLoaderListener; @@ -94,18 +96,23 @@ public class TerminalManager extends Service implements BridgeDisconnectedListen final private IBinder binder = new TerminalBinder(); private ConnectivityReceiver connectivityManager; + private AccessPointReceiver accessPointReceiver; private MediaPlayer mediaPlayer; private Timer pubkeyTimer; private Timer idleTimer; + + private Timer apStateTimer; + private boolean apMonitoringActive = false; private final long IDLE_TIMEOUT = 300000; // 5 minutes private Vibrator vibrator; private volatile boolean wantKeyVibration; public static final long VIBRATE_DURATION = 30; + private boolean wantBellVibration; private boolean resizeAllowed = true; @@ -116,6 +123,43 @@ public class TerminalManager extends Service implements BridgeDisconnectedListen public boolean hardKeyboardHidden; + /** + * Create a thread-safe copy of the bridges list for iteration. + * This helper prevents ConcurrentModificationException when iterating + * bridges from background threads while the list may be modified. + * + * @return array of bridges (empty array if no bridges, never null) + */ + private TerminalBridge[] getBridgesCopy() { + synchronized (bridges) { + return bridges.toArray(new TerminalBridge[bridges.size()]); + } + } + + /** + * Thread-safe check if there are any active bridges. + * This is more efficient than getBridgesCopy() when only checking existence. + * + * @return true if there are active bridges + */ + private boolean hasBridges() { + synchronized (bridges) { + return !bridges.isEmpty(); + } + } + + /** + * Get the number of active bridges in a thread-safe manner. + * Primarily used for logging and debugging. + * + * @return number of active bridges + */ + private int getBridgeCount() { + synchronized (bridges) { + return bridges.size(); + } + } + @Override public void onCreate() { Log.i(TAG, "Starting service"); @@ -126,6 +170,7 @@ public void onCreate() { res = getResources(); pubkeyTimer = new Timer("pubkeyTimer", true); + apStateTimer = new Timer("apStateTimer", true); hostdb = HostDatabase.get(this); colordb = HostDatabase.get(this); @@ -150,12 +195,15 @@ public void onCreate() { wantBellVibration = prefs.getBoolean(PreferenceConstants.BELL_VIBRATE, true); enableMediaPlayer(); + updateAccessPointMonitoring(); + hardKeyboardHidden = (res.getConfiguration().hardKeyboardHidden == Configuration.HARDKEYBOARDHIDDEN_YES); final boolean lockingWifi = prefs.getBoolean(PreferenceConstants.WIFI_LOCK, true); connectivityManager = new ConnectivityReceiver(this, lockingWifi); + accessPointReceiver = new AccessPointReceiver(this); ProviderLoader.load(this, this); } @@ -178,9 +226,14 @@ public void onDestroy() { idleTimer.cancel(); if (pubkeyTimer != null) pubkeyTimer.cancel(); + if (apStateTimer != null) { + apStateTimer.cancel(); + apMonitoringActive = false; + } } connectivityManager.cleanup(); + accessPointReceiver.cleanup(); ConnectionNotifier.getInstance().hideRunningNotification(this); @@ -191,21 +244,11 @@ public void onDestroy() { * Disconnect all currently connected bridges. */ public void disconnectAll(final boolean immediate, final boolean excludeLocal) { - TerminalBridge[] tmpBridges = null; - - synchronized (bridges) { - if (bridges.size() > 0) { - tmpBridges = bridges.toArray(new TerminalBridge[bridges.size()]); - } - } - - if (tmpBridges != null) { - // disconnect and dispose of any existing bridges - for (TerminalBridge tmpBridge : tmpBridges) { - if (excludeLocal && !tmpBridge.isUsingNetwork()) - continue; - tmpBridge.dispatchDisconnect(immediate); - } + // disconnect and dispose of any existing bridges + for (TerminalBridge tmpBridge : getBridgesCopy()) { + if (excludeLocal && !tmpBridge.isUsingNetwork()) + continue; + tmpBridge.dispatchDisconnect(immediate); } } @@ -238,8 +281,10 @@ private TerminalBridge openConnection(HostBean host) throws IllegalArgumentExcep } if (prefs.getBoolean(PreferenceConstants.CONNECTION_PERSIST, true)) { - ConnectionNotifier.getInstance().showRunningNotification(this); + updateRunningNotificationWithApInfo(); } + + updateAccessPointMonitoring(); // also update database with new connected time touchHost(host); @@ -352,7 +397,12 @@ public void onDisconnected(TerminalBridge bridge) { if (shouldHideRunningNotification) { ConnectionNotifier.getInstance().hideRunningNotification(this); + } else { + // Update notification in case this bridge had AP forwards + updateRunningNotificationWithApInfo(); } + + updateAccessPointMonitoring(); } public boolean isKeyLoaded(String nickname) { @@ -447,7 +497,7 @@ private void stopWithDelay() { } protected void stopNow() { - if (bridges.size() == 0) { + if (!hasBridges()) { stopSelf(); } } @@ -481,7 +531,7 @@ public TerminalManager getService() { @Override public IBinder onBind(Intent intent) { - Log.i(TAG, "Someone bound to TerminalManager with " + bridges.size() + " bridges active"); + Log.i(TAG, "Someone bound to TerminalManager with " + getBridgeCount() + " bridges active"); keepServiceAlive(); setResizeAllowed(true); return binder; @@ -507,23 +557,25 @@ public int onStartCommand(Intent intent, int flags, int startId) { @Override public void onRebind(Intent intent) { super.onRebind(intent); - Log.i(TAG, "Someone rebound to TerminalManager with " + bridges.size() + " bridges active"); + Log.i(TAG, "Someone rebound to TerminalManager with " + getBridgeCount() + " bridges active"); keepServiceAlive(); setResizeAllowed(true); } @Override public boolean onUnbind(Intent intent) { - Log.i(TAG, "Someone unbound from TerminalManager with " + bridges.size() + " bridges active"); + Log.i(TAG, "Someone unbound from TerminalManager with " + getBridgeCount() + " bridges active"); setResizeAllowed(true); - if (bridges.isEmpty()) { + // Get snapshot once to avoid TOCTOU race between hasBridges() and getBridgesCopy() + TerminalBridge[] bridgesCopy = getBridgesCopy(); + if (bridgesCopy.length == 0) { stopWithDelay(); } else { // tell each bridge to forget about their previous prompt handler - for (TerminalBridge bridge : bridges) { - bridge.promptHelper.setListener(null); + for (TerminalBridge bridge : bridgesCopy) { + bridge.promptHelper.clearListener(); } } @@ -733,9 +785,142 @@ public void unregisterOnHostStatusChangedListener(OnHostStatusChangedListener li hostStatusChangedListeners.remove(listener); } - private void notifyHostStatusChanged() { + public void notifyHostStatusChanged() { for (OnHostStatusChangedListener listener : hostStatusChangedListeners) { listener.onHostStatusChanged(); } } + + /** + * Update access point notification state based on current port forwards + * Called when port forwards are enabled/disabled + */ + public void updateAccessPointNotification() { + updateRunningNotificationWithApInfo(); + updateAccessPointMonitoring(); + } + + /** + * Check for AP state changes and update notification if needed + * Should be called periodically to keep notification in sync with AP state + */ + public void checkAccessPointStateChange() { + if (NetworkUtils.hasAccessPointStateChanged(this)) { + Log.d(TAG, "AP state changed, updating notification"); + + // Check if AP became available - if so, retry failed AP port forwards + String currentApIP = NetworkUtils.getAccessPointIP(this); + if (currentApIP != null) { + Log.d(TAG, "AP is now available, retrying failed AP port forwards"); + retryFailedAccessPointForwards(); + } + + updateRunningNotificationWithApInfo(); + + // Notify host status listeners so UI can update (like port forwarding display in host list) + notifyHostStatusChanged(); + } + } + + /** + * Update access point monitoring state based on current needs + * Starts monitoring if there are active connections with AP port forwards + * Stops monitoring if there are no connections or no AP forwards configured + * + * Note: Android does not provide reliable broadcast intents for WiFi hotspot state changes. + * The WIFI_AP_STATE_CHANGED intent is not documented in the public API and may not work + * consistently across all devices and Android versions. Therefore, we use periodic polling + * to detect AP state changes for reliable cross-device compatibility. + */ + private void updateAccessPointMonitoring() { + boolean shouldMonitor = hasBridges() && hasActiveAccessPointForwards(); + + if (shouldMonitor && !apMonitoringActive) { + // Start monitoring - hybrid approach: broadcast receiver for fast response + timer for reliability + Log.d(TAG, "Starting AP state monitoring (hybrid: broadcast + 10s polling)"); + apStateTimer.schedule(new ApStateMonitorTask(), 0, 10000); // 10s polling + apMonitoringActive = true; + } else if (!shouldMonitor && apMonitoringActive) { + // Stop monitoring + Log.d(TAG, "Stopping AP state monitoring"); + apStateTimer.cancel(); + apStateTimer = new Timer("apStateTimer", true); + apMonitoringActive = false; + } + } + + /** + * Timer task to monitor access point state changes every 10 seconds. + * This provides a reliable fallback for AP state monitoring that works on all devices. + * Combined with AccessPointReceiver for immediate response on devices that support broadcasts. + * Only runs when monitoring is active (connections exist with AP port forwards). + */ + private class ApStateMonitorTask extends TimerTask { + @Override + public void run() { + Log.d(TAG, "ApStateMonitorTask running - checking for AP state changes"); + checkAccessPointStateChange(); + } + } + + /** + * Update the running notification to include AP information when relevant + */ + private void updateRunningNotificationWithApInfo() { + // Only update if we have active connections (running notification is showing) + if (!hasBridges()) { + return; + } + + boolean hasActiveForwards = hasActiveAccessPointForwards(); + String apIP = null; + + if (hasActiveForwards) { + apIP = NetworkUtils.getAccessPointIP(this); + } + + Log.d(TAG, "Updating running notification: hasApForwards=" + hasActiveForwards + ", apIP=" + apIP); + ConnectionNotifier.getInstance().showRunningNotification(this, apIP, hasActiveForwards); + } + + /** + * Check if there are any configured access point port forwards + * @return true if any access point forwards are configured (enabled or failed to enable) + */ + private boolean hasActiveAccessPointForwards() { + // Check all active terminal bridges for access point forwards + for (TerminalBridge bridge : getBridgesCopy()) { + if (bridge != null) { + List forwards = bridge.getPortForwards(); + for (PortForwardBean forward : forwards) { + // Count both enabled forwards and those configured for AP (even if failed to bind) + if (NetworkUtils.BIND_ACCESS_POINT.equals(forward.getBindAddress())) { + return true; // Found at least one + } + } + } + } + + return false; + } + + /** + * Retry failed access point port forwards when AP becomes available + */ + private void retryFailedAccessPointForwards() { + // Check all active terminal bridges for failed AP forwards + for (TerminalBridge bridge : getBridgesCopy()) { + if (bridge != null) { + List forwards = bridge.getPortForwards(); + for (PortForwardBean forward : forwards) { + // Look for AP forwards that are not enabled (likely failed due to no AP) + if (NetworkUtils.BIND_ACCESS_POINT.equals(forward.getBindAddress()) && !forward.isEnabled()) { + Log.d(TAG, "Retrying failed AP port forward: " + forward.getNickname()); + // Ask the bridge to retry enabling this forward + bridge.enablePortForward(forward); + } + } + } + } + } } diff --git a/app/src/main/java/org/connectbot/transport/SSH.java b/app/src/main/java/org/connectbot/transport/SSH.java index 5fecb605e6..01db654ac5 100644 --- a/app/src/main/java/org/connectbot/transport/SSH.java +++ b/app/src/main/java/org/connectbot/transport/SSH.java @@ -55,6 +55,7 @@ import org.connectbot.service.TerminalManager; import org.connectbot.service.TerminalManager.KeyHolder; import org.connectbot.util.HostDatabase; +import org.connectbot.util.NetworkUtils; import org.connectbot.util.PubkeyDatabase; import org.connectbot.util.PubkeyUtils; @@ -631,6 +632,33 @@ public boolean removePortForward(PortForwardBean portForward) { return portForwards.remove(portForward); } + /** + * Resolve bind address string to actual InetAddress + * @param bindAddress the bind address type + * @return InetAddress object, or null if access_point is requested but unavailable + */ + private InetAddress resolveBindAddress(String bindAddress) { + try { + if (NetworkUtils.BIND_ALL_INTERFACES.equals(bindAddress)) { + return InetAddress.getByName(NetworkUtils.BIND_ALL_INTERFACES); + } else if (NetworkUtils.BIND_ACCESS_POINT.equals(bindAddress)) { + String apIP = NetworkUtils.getAccessPointIP(manager); + if (apIP != null) { + Log.d(TAG, "Binding to access point IP: " + apIP); + return InetAddress.getByName(apIP); + } else { + Log.w(TAG, "Access point IP not available, cannot bind port forward"); + return null; // Do not fall back to localhost for security + } + } else { + return InetAddress.getLoopbackAddress(); + } + } catch (Exception e) { + Log.e(TAG, "Error resolving bind address: " + bindAddress, e); + return NetworkUtils.BIND_ACCESS_POINT.equals(bindAddress) ? null : InetAddress.getLoopbackAddress(); + } + } + @Override public boolean enablePortForward(PortForwardBean portForward) { if (!portForwards.contains(portForward)) { @@ -644,8 +672,13 @@ public boolean enablePortForward(PortForwardBean portForward) { if (HostDatabase.PORTFORWARD_LOCAL.equals(portForward.getType())) { LocalPortForwarder lpf = null; try { + InetAddress bindAddr = resolveBindAddress(portForward.getBindAddress()); + if (bindAddr == null) { + Log.e(TAG, "Cannot bind port forward - bind address unavailable: " + portForward.getBindAddress()); + return false; + } lpf = connection.createLocalPortForwarder( - new InetSocketAddress(InetAddress.getLocalHost(), portForward.getSourcePort()), + new InetSocketAddress(bindAddr, portForward.getSourcePort()), portForward.getDestAddr(), portForward.getDestPort()); } catch (Exception e) { Log.e(TAG, "Could not create local port forward", e); @@ -659,6 +692,15 @@ public boolean enablePortForward(PortForwardBean portForward) { portForward.setIdentifier(lpf); portForward.setEnabled(true); + + // Update notification if access point binding is being used + if (NetworkUtils.BIND_ACCESS_POINT.equals(portForward.getBindAddress())) { + manager.updateAccessPointNotification(); + } + + // Notify host status listeners so UI can update port forward states + manager.notifyHostStatusChanged(); + return true; } else if (HostDatabase.PORTFORWARD_REMOTE.equals(portForward.getType())) { try { @@ -669,13 +711,25 @@ public boolean enablePortForward(PortForwardBean portForward) { } portForward.setEnabled(true); + + // Notify host status listeners so UI can update port forward states + manager.notifyHostStatusChanged(); + + // Update notification for any access point forwards (remote forwards don't use bind address) + manager.updateAccessPointNotification(); + return true; } else if (HostDatabase.PORTFORWARD_DYNAMIC5.equals(portForward.getType())) { DynamicPortForwarder dpf = null; try { + InetAddress bindAddr = resolveBindAddress(portForward.getBindAddress()); + if (bindAddr == null) { + Log.e(TAG, "Cannot bind port forward - bind address unavailable: " + portForward.getBindAddress()); + return false; + } dpf = connection.createDynamicPortForwarder( - new InetSocketAddress(InetAddress.getLocalHost(), portForward.getSourcePort())); + new InetSocketAddress(bindAddr, portForward.getSourcePort())); } catch (Exception e) { Log.e(TAG, "Could not create dynamic port forward", e); return false; @@ -683,6 +737,15 @@ public boolean enablePortForward(PortForwardBean portForward) { portForward.setIdentifier(dpf); portForward.setEnabled(true); + + // Update notification if access point binding is being used + if (NetworkUtils.BIND_ACCESS_POINT.equals(portForward.getBindAddress())) { + manager.updateAccessPointNotification(); + } + + // Notify host status listeners so UI can update port forward states + manager.notifyHostStatusChanged(); + return true; } else { // Unsupported type @@ -713,6 +776,11 @@ public boolean disablePortForward(PortForwardBean portForward) { portForward.setEnabled(false); lpf.close(); + + // Update notification if access point binding was being used + if (NetworkUtils.BIND_ACCESS_POINT.equals(portForward.getBindAddress())) { + manager.updateAccessPointNotification(); + } return true; } else if (HostDatabase.PORTFORWARD_REMOTE.equals(portForward.getType())) { @@ -724,6 +792,9 @@ public boolean disablePortForward(PortForwardBean portForward) { Log.e(TAG, "Could not stop remote port forwarding, setting enabled to false", e); return false; } + + // Update notification for any access point forwards (remote forwards don't use bind address) + manager.updateAccessPointNotification(); return true; } else if (HostDatabase.PORTFORWARD_DYNAMIC5.equals(portForward.getType())) { @@ -738,6 +809,11 @@ public boolean disablePortForward(PortForwardBean portForward) { portForward.setEnabled(false); dpf.close(); + + // Update notification if access point binding was being used + if (NetworkUtils.BIND_ACCESS_POINT.equals(portForward.getBindAddress())) { + manager.updateAccessPointNotification(); + } return true; } else { diff --git a/app/src/main/java/org/connectbot/util/HostDatabase.java b/app/src/main/java/org/connectbot/util/HostDatabase.java index 525177c660..52a72569e9 100644 --- a/app/src/main/java/org/connectbot/util/HostDatabase.java +++ b/app/src/main/java/org/connectbot/util/HostDatabase.java @@ -52,7 +52,7 @@ public class HostDatabase extends RobustSQLiteOpenHelper implements HostStorage, public final static String TAG = "CB.HostDatabase"; public final static String DB_NAME = "hosts"; - public final static int DB_VERSION = 26; + public final static int DB_VERSION = 27; public final static String TABLE_HOSTS = "hosts"; public final static String FIELD_HOST_NICKNAME = "nickname"; @@ -86,6 +86,7 @@ public class HostDatabase extends RobustSQLiteOpenHelper implements HostStorage, public final static String FIELD_PORTFORWARD_SOURCEPORT = "sourceport"; public final static String FIELD_PORTFORWARD_DESTADDR = "destaddr"; public final static String FIELD_PORTFORWARD_DESTPORT = "destport"; + public final static String FIELD_PORTFORWARD_BINDADDR = "bindaddr"; public final static String TABLE_COLORS = "colors"; public final static String FIELD_COLOR_SCHEME = "scheme"; @@ -244,6 +245,7 @@ private void createTables(SQLiteDatabase db) { + FIELD_PORTFORWARD_SOURCEPORT + " INTEGER NOT NULL DEFAULT 8080, " + FIELD_PORTFORWARD_DESTADDR + " TEXT, " + FIELD_PORTFORWARD_DESTPORT + " INTEGER, " + + FIELD_PORTFORWARD_BINDADDR + " TEXT DEFAULT 'localhost', " + "FOREIGN KEY (" + FIELD_PORTFORWARD_HOSTID + ") REFERENCES " + TABLE_HOSTS + "(_id) ON DELETE CASCADE)"); db.execSQL("CREATE INDEX " + TABLE_PORTFORWARDS + FIELD_PORTFORWARD_HOSTID + "index ON " @@ -310,7 +312,8 @@ public void onRobustUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) t + FIELD_PORTFORWARD_TYPE + " TEXT NOT NULL DEFAULT '" + PORTFORWARD_LOCAL + "', " + FIELD_PORTFORWARD_SOURCEPORT + " INTEGER NOT NULL DEFAULT 8080, " + FIELD_PORTFORWARD_DESTADDR + " TEXT, " - + FIELD_PORTFORWARD_DESTPORT + " INTEGER)"); + + FIELD_PORTFORWARD_DESTPORT + " INTEGER, " + + FIELD_PORTFORWARD_BINDADDR + " TEXT DEFAULT 'localhost')"); // fall through case 12: db.execSQL("ALTER TABLE " + TABLE_HOSTS @@ -471,6 +474,11 @@ public void onRobustUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) t // Re-enable foreign keys (will be automatically enabled on next connection via onConfigure) db.execSQL("PRAGMA foreign_keys = ON"); } + // fall through + case 26: + // Add bind address column to port forwards table + db.execSQL("ALTER TABLE " + TABLE_PORTFORWARDS + + " ADD COLUMN " + FIELD_PORTFORWARD_BINDADDR + " TEXT DEFAULT 'localhost'"); } } @@ -839,7 +847,7 @@ public List getPortForwardsForHost(HostBean host) { Cursor c = mDb.query(TABLE_PORTFORWARDS, new String[] { "_id", FIELD_PORTFORWARD_NICKNAME, FIELD_PORTFORWARD_TYPE, FIELD_PORTFORWARD_SOURCEPORT, - FIELD_PORTFORWARD_DESTADDR, FIELD_PORTFORWARD_DESTPORT}, + FIELD_PORTFORWARD_DESTADDR, FIELD_PORTFORWARD_DESTPORT, FIELD_PORTFORWARD_BINDADDR}, FIELD_PORTFORWARD_HOSTID + " = ?", new String[] {String.valueOf(host.getId())}, null, null, null); @@ -851,7 +859,8 @@ public List getPortForwardsForHost(HostBean host) { c.getString(2), c.getInt(3), c.getString(4), - c.getInt(5)); + c.getInt(5), + c.getString(6)); portForwards.add(pfb); } diff --git a/app/src/main/java/org/connectbot/util/NetworkUtils.java b/app/src/main/java/org/connectbot/util/NetworkUtils.java new file mode 100644 index 0000000000..764c5efcb8 --- /dev/null +++ b/app/src/main/java/org/connectbot/util/NetworkUtils.java @@ -0,0 +1,349 @@ +/* + * ConnectBot: simple, powerful, open-source SSH client for Android + * Copyright 2007 Kenny Root, Jeffrey Sharkey + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.connectbot.util; + +import android.content.Context; +import android.net.ConnectivityManager; +import android.net.Network; +import android.net.NetworkCapabilities; +import android.net.NetworkInfo; +import android.util.Log; + +import java.net.InetAddress; +import java.net.InterfaceAddress; +import java.net.NetworkInterface; +import java.net.SocketException; +import java.util.Collections; +import java.util.List; + +/** + * Utility class for network-related operations, specifically for detecting + * WiFi hotspot IP addresses for port forwarding bind address selection. + * + * @author ConnectBot Team + */ +public class NetworkUtils { + public final static String TAG = "CB.NetworkUtils"; + + // Bind address constants + public static final String BIND_LOCALHOST = "localhost"; + public static final String BIND_ALL_INTERFACES = "0.0.0.0"; + public static final String BIND_ACCESS_POINT = "access_point"; + + // Common AP interface name patterns + private static final String[] AP_INTERFACE_PATTERNS = { + "ap", "wlan1", "p2p", "hotspot", "softap", "wifi_ap" + }; + + // Cellular interface name patterns for security filtering + private static final String[] CELLULAR_INTERFACE_PATTERNS = { + "rmnet", "ccmni", "pdp", "ppp", "cellular", "mobile", "radio", "baseband" + }; + + /** + * Interface for listening to network changes + */ + public interface NetworkChangeListener { + void onAccessPointIPChanged(String newIP); + void onAccessPointDisconnected(); + } + + private static String lastKnownApIP = null; + + /** + * Get the current WiFi access point IP address + * Read-only method - does not update internal state to avoid masking changes + * from the background monitor task + * @param context Android context + * @return IP address as string, or null if not available + */ + public static String getAccessPointIP(Context context) { + // Only use explicitly identified AP interface IPs for security + // Do NOT update lastKnownApIP here - that's only for hasAccessPointStateChanged() + String apInterfaceIP = getHotspotInterfaceIP(); + if (apInterfaceIP != null) { + Log.d(TAG, "Found AP interface IP: " + apInterfaceIP); + return apInterfaceIP; + } + + Log.d(TAG, "No valid AP interface found"); + return null; + } + + /** + * Check if the access point state has changed since last check + * @param context Android context + * @return true if AP state changed + */ + public static boolean hasAccessPointStateChanged(Context context) { + String currentApIP = getHotspotInterfaceIP(); // Don't update lastKnownApIP yet + boolean changed = !java.util.Objects.equals(lastKnownApIP, currentApIP); + if (changed) { + Log.d(TAG, "AP state changed: " + lastKnownApIP + " -> " + currentApIP); + lastKnownApIP = currentApIP; // Update only when we detect a change + } + return changed; + } + + /** + * Get the hotspot interface IP address from provided interfaces (pure logic, testable) + * @param interfaces List of network interfaces to check + * @return IP address string, or null if not found + */ + static String getHotspotInterfaceIP(List interfaces) { + if (interfaces == null) return null; + + for (NetworkInterface intf : interfaces) { + try { + if (!intf.isUp() || intf.isLoopback() || intf.isVirtual()) { + continue; + } + + String name = intf.getName().toLowerCase(); + + // SECURITY: Block cellular interfaces using device-agnostic detection + if (isCellularInterface(intf)) { + continue; + } + + // Look for common AP interface names + boolean isApInterface = false; + for (String pattern : AP_INTERFACE_PATTERNS) { + if (name.contains(pattern)) { + isApInterface = true; + break; + } + } + if (isApInterface) { + + List addrs = intf.getInterfaceAddresses(); + for (InterfaceAddress addr : addrs) { + InetAddress inetAddr = addr.getAddress(); + if (!inetAddr.isLoopbackAddress() && + !inetAddr.isLinkLocalAddress() && + inetAddr.isSiteLocalAddress()) { + + String ip = inetAddr.getHostAddress(); + if (ip != null && !ip.contains(":")) { // IPv4 only + + // Verify this looks like an AP IP (typically x.x.x.1) + if (isLikelyApIP(ip)) { + return ip; + } + } + } + } + } + } catch (Exception e) { + // Skip this interface if there's an error checking it + continue; + } + } + + // No fallback - only use explicitly named AP interfaces for security + return null; + } + + /** + * Get the hotspot interface IP address when phone is acting as WiFi AP + * @return IP address string, or null if not found + */ + private static String getHotspotInterfaceIP() { + try { + List interfaces = Collections.list(NetworkInterface.getNetworkInterfaces()); + String result = getHotspotInterfaceIP(interfaces); + if (result != null) { + Log.d(TAG, "Found AP interface with IP: " + result); + } else { + Log.d(TAG, "No hotspot interface IP found"); + } + return result; + } catch (SocketException e) { + Log.e(TAG, "Error getting hotspot interface IP", e); + return null; + } + } + + /** + * Check if an IP address looks like an access point IP + * @param ip the IP address to check + * @return true if it looks like an AP IP + */ + private static boolean isLikelyApIP(String ip) { + if (ip == null) return false; + + // Accept private IP ranges that could be AP addresses + // Since we've already filtered out cellular interfaces by their + // network characteristics, we can be permissive with IP ranges + return ip.startsWith("192.168.") || + ip.startsWith("10.") || + ip.matches("172\\.(1[6-9]|2[0-9]|3[0-1])\\..*"); + } + + + /** + * Check if an interface is a cellular interface + * Uses only interface name patterns for reliable detection + * @param intf the network interface to check + * @return true if this appears to be a cellular interface + */ + private static boolean isCellularInterface(NetworkInterface intf) { + if (intf == null) return false; + + String name = intf.getName().toLowerCase(); + + // Only use interface name patterns for detection + // This is the most reliable method across all devices + for (String pattern : CELLULAR_INTERFACE_PATTERNS) { + if (name.contains(pattern)) { + Log.d(TAG, "Interface " + name + " identified as cellular by name pattern: " + pattern); + return true; + } + } + + return false; + } + + /** + * Check if WiFi is currently connected + * @param context Android context + * @return true if WiFi is connected + */ + public static boolean isWifiConnected(Context context) { + ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + if (cm == null) { + return false; + } + + try { + // API 23+ + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { + Network activeNetwork = cm.getActiveNetwork(); + if (activeNetwork == null) { + return false; + } + NetworkCapabilities caps = cm.getNetworkCapabilities(activeNetwork); + return caps != null && caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI); + } else { + // Legacy API + NetworkInfo networkInfo = cm.getActiveNetworkInfo(); + return networkInfo != null && + networkInfo.isConnected() && + networkInfo.getType() == ConnectivityManager.TYPE_WIFI; + } + } catch (Exception e) { + Log.e(TAG, "Error checking WiFi connection", e); + return false; + } + } + + /** + * Check if access point is available for binding + * @param context Android context + * @return true if access point IP is available + */ + public static boolean isAccessPointAvailable(Context context) { + return getAccessPointIP(context) != null; + } + + /** + * Get display name for bind address type (pure logic, testable) + * @param bindAddress the bind address string + * @param apIP the current AP IP (null if unavailable) + * @return human readable name + */ + public static String getBindAddressDisplayName(String bindAddress, String apIP) { + if (BIND_ALL_INTERFACES.equals(bindAddress)) { + return "all interfaces"; + } else if (BIND_ACCESS_POINT.equals(bindAddress)) { + if (apIP != null) { + return "WiFi hotspot (" + apIP + ")"; + } else { + return "WiFi hotspot (unavailable)"; + } + } else { + return "localhost"; + } + } + + /** + * Get display name for bind address type + * @param bindAddress the bind address string + * @param context Android context for hotspot IP resolution + * @return human readable name + */ + public static String getBindAddressDisplayName(String bindAddress, Context context) { + String apIP = (context != null) ? getAccessPointIP(context) : null; + return getBindAddressDisplayName(bindAddress, apIP); + } + + /** + * Get simple bind address display for compact UI (pure logic, testable) + * @param bindAddress the bind address string + * @param apIP the current AP IP (null if unavailable) + * @return simple address display (actual IP or short form) + */ + public static String getSimpleBindAddressDisplay(String bindAddress, String apIP) { + if (BIND_ALL_INTERFACES.equals(bindAddress)) { + return BIND_ALL_INTERFACES; + } else if (BIND_ACCESS_POINT.equals(bindAddress)) { + return apIP != null ? apIP : "AP"; + } else { + return "localhost"; + } + } + + /** + * Get simple bind address display for compact UI (shows actual IPs) + * @param bindAddress the bind address string + * @param context Android context for hotspot IP resolution + * @return simple address display (actual IP or short form) + */ + public static String getSimpleBindAddressDisplay(String bindAddress, Context context) { + String apIP = (context != null) ? getAccessPointIP(context) : null; + return getSimpleBindAddressDisplay(bindAddress, apIP); + } + + /** + * Resolve bind address string to actual IP address (pure logic, testable) + * @param bindAddress the bind address type + * @param apIP the current AP IP (null if unavailable) + * @return IP address string, or null if access_point is requested but unavailable + */ + public static String resolveBindAddress(String bindAddress, String apIP) { + if (BIND_ALL_INTERFACES.equals(bindAddress)) { + return BIND_ALL_INTERFACES; + } else if (BIND_ACCESS_POINT.equals(bindAddress)) { + // Return null if AP is not available - do not fall back to localhost for security + return apIP; + } else { + return "127.0.0.1"; + } + } + + /** + * Resolve bind address string to actual IP address + * @param bindAddress the bind address type + * @param context Android context + * @return IP address string, or null if access_point is requested but unavailable + */ + public static String resolveBindAddress(String bindAddress, Context context) { + String apIP = (context != null) ? getAccessPointIP(context) : null; + return resolveBindAddress(bindAddress, apIP); + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/act_hostlist.xml b/app/src/main/res/layout/act_hostlist.xml index 3fa03509dd..7e15b99da7 100644 --- a/app/src/main/res/layout/act_hostlist.xml +++ b/app/src/main/res/layout/act_hostlist.xml @@ -26,6 +26,7 @@ android:orientation="vertical" > + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0ddbe3d97a..f12b14a02e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -153,6 +153,25 @@ "(again)" "Type:" + + "Bind to:" + + "Localhost only" + + "All interfaces (0.0.0.0)" + + "WiFi hotspot only" + + "WiFi hotspot" + + "⚠️ This will expose the forwarded port to other devices on the network" + + "Port forwarding active" + + "Hotspot IP: %s" + + "Hotspot required" + "Port forwards configured for WiFi hotspot, but hotspot is disabled" "Note: password can be blank" diff --git a/app/src/test/java/org/connectbot/util/NetworkUtilsTest.java b/app/src/test/java/org/connectbot/util/NetworkUtilsTest.java new file mode 100644 index 0000000000..7a84a3455a --- /dev/null +++ b/app/src/test/java/org/connectbot/util/NetworkUtilsTest.java @@ -0,0 +1,430 @@ +/* + * ConnectBot: simple, powerful, open-source SSH client for Android + * Copyright 2007 Kenny Root, Jeffrey Sharkey + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.connectbot.util; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +import org.junit.Test; + +import java.net.InetAddress; +import java.net.InterfaceAddress; +import java.net.NetworkInterface; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * Tests for NetworkUtils functionality, focusing on methods that can be tested + * without Android Context dependencies. + */ +public class NetworkUtilsTest { + + @Test + public void testBindAddressConstants() { + assertEquals("localhost", NetworkUtils.BIND_LOCALHOST); + assertEquals("0.0.0.0", NetworkUtils.BIND_ALL_INTERFACES); + assertEquals("access_point", NetworkUtils.BIND_ACCESS_POINT); + } + + @Test + public void testGetBindAddressDisplayName() { + // Test with null AP IP (unavailable) + assertEquals("all interfaces", + NetworkUtils.getBindAddressDisplayName("0.0.0.0", (String)null)); + assertEquals("localhost", + NetworkUtils.getBindAddressDisplayName("localhost", (String)null)); + assertEquals("localhost", + NetworkUtils.getBindAddressDisplayName("anything_else", (String)null)); + assertEquals("WiFi hotspot (unavailable)", + NetworkUtils.getBindAddressDisplayName("access_point", (String)null)); + + // Test with valid AP IP + assertEquals("WiFi hotspot (192.168.1.1)", + NetworkUtils.getBindAddressDisplayName("access_point", "192.168.1.1")); + } + + @Test + public void testGetSimpleBindAddressDisplay() { + // Test with null AP IP (unavailable) + assertEquals("0.0.0.0", + NetworkUtils.getSimpleBindAddressDisplay("0.0.0.0", (String)null)); + assertEquals("localhost", + NetworkUtils.getSimpleBindAddressDisplay("localhost", (String)null)); + assertEquals("localhost", + NetworkUtils.getSimpleBindAddressDisplay("anything_else", (String)null)); + assertEquals("AP", + NetworkUtils.getSimpleBindAddressDisplay("access_point", (String)null)); + + // Test with valid AP IP + assertEquals("192.168.1.1", + NetworkUtils.getSimpleBindAddressDisplay("access_point", "192.168.1.1")); + } + + @Test + public void testResolveBindAddress() { + // Test with null AP IP (unavailable) + assertEquals("0.0.0.0", + NetworkUtils.resolveBindAddress("0.0.0.0", (String)null)); + assertEquals("127.0.0.1", + NetworkUtils.resolveBindAddress("localhost", (String)null)); + assertEquals("127.0.0.1", + NetworkUtils.resolveBindAddress("anything_else", (String)null)); + // access_point with null AP IP should return null + assertNull(NetworkUtils.resolveBindAddress("access_point", (String)null)); + + // Test with valid AP IP + assertEquals("192.168.1.1", + NetworkUtils.resolveBindAddress("access_point", "192.168.1.1")); + } + + @Test + public void testBindAddressConstantsEdgeCases() { + // Test edge cases with constants using pure logic methods + assertEquals("localhost", NetworkUtils.getBindAddressDisplayName(NetworkUtils.BIND_LOCALHOST, (String)null)); + assertEquals("all interfaces", NetworkUtils.getBindAddressDisplayName(NetworkUtils.BIND_ALL_INTERFACES, (String)null)); + assertEquals("WiFi hotspot (unavailable)", NetworkUtils.getBindAddressDisplayName(NetworkUtils.BIND_ACCESS_POINT, (String)null)); + + // Test that constants are non-null and non-empty + assertNotNull(NetworkUtils.BIND_LOCALHOST); + assertNotNull(NetworkUtils.BIND_ALL_INTERFACES); + assertNotNull(NetworkUtils.BIND_ACCESS_POINT); + assertFalse(NetworkUtils.BIND_LOCALHOST.isEmpty()); + assertFalse(NetworkUtils.BIND_ALL_INTERFACES.isEmpty()); + assertFalse(NetworkUtils.BIND_ACCESS_POINT.isEmpty()); + } + + @Test + public void testNullInputHandling() { + // Test methods handle null inputs gracefully using pure logic methods + assertEquals("localhost", NetworkUtils.getBindAddressDisplayName(null, (String)null)); + assertEquals("localhost", NetworkUtils.getSimpleBindAddressDisplay(null, (String)null)); + assertEquals("127.0.0.1", NetworkUtils.resolveBindAddress(null, (String)null)); + + // Test empty strings + assertEquals("localhost", NetworkUtils.getBindAddressDisplayName("", (String)null)); + assertEquals("localhost", NetworkUtils.getSimpleBindAddressDisplay("", (String)null)); + assertEquals("127.0.0.1", NetworkUtils.resolveBindAddress("", (String)null)); + } + + @Test + public void testGetHotspotInterfaceIP_NullInput() { + // Test null input + assertNull(NetworkUtils.getHotspotInterfaceIP(null)); + + // Test empty list + assertNull(NetworkUtils.getHotspotInterfaceIP(Collections.emptyList())); + } + + @Test + public void testGetHotspotInterfaceIP_ValidApInterface() throws Exception { + // Create mock NetworkInterface for AP + NetworkInterface mockIntf = mock(NetworkInterface.class); + when(mockIntf.isUp()).thenReturn(true); + when(mockIntf.isLoopback()).thenReturn(false); + when(mockIntf.isVirtual()).thenReturn(false); + when(mockIntf.getName()).thenReturn("ap0"); + + // Create mock InetAddress and InterfaceAddress + InetAddress mockInetAddr = mock(InetAddress.class); + when(mockInetAddr.isLoopbackAddress()).thenReturn(false); + when(mockInetAddr.isLinkLocalAddress()).thenReturn(false); + when(mockInetAddr.isSiteLocalAddress()).thenReturn(true); + when(mockInetAddr.getHostAddress()).thenReturn("192.168.1.1"); + + InterfaceAddress mockIfaceAddr = mock(InterfaceAddress.class); + when(mockIfaceAddr.getAddress()).thenReturn(mockInetAddr); + + when(mockIntf.getInterfaceAddresses()).thenReturn(Arrays.asList(mockIfaceAddr)); + + List interfaces = Arrays.asList(mockIntf); + String result = NetworkUtils.getHotspotInterfaceIP(interfaces); + + assertEquals("192.168.1.1", result); + } + + @Test + public void testGetHotspotInterfaceIP_NoApInterface() throws Exception { + // Create mock NetworkInterface for non-AP interface + NetworkInterface mockIntf = mock(NetworkInterface.class); + when(mockIntf.isUp()).thenReturn(true); + when(mockIntf.isLoopback()).thenReturn(false); + when(mockIntf.isVirtual()).thenReturn(false); + when(mockIntf.getName()).thenReturn("wlan0"); // Not an AP interface + + List interfaces = Arrays.asList(mockIntf); + String result = NetworkUtils.getHotspotInterfaceIP(interfaces); + + assertNull(result); + } + + @Test + public void testGetHotspotInterfaceIP_InterfaceDown() throws Exception { + // Create mock NetworkInterface that's down + NetworkInterface mockIntf = mock(NetworkInterface.class); + when(mockIntf.isUp()).thenReturn(false); // Interface is down + when(mockIntf.getName()).thenReturn("ap0"); + + List interfaces = Arrays.asList(mockIntf); + String result = NetworkUtils.getHotspotInterfaceIP(interfaces); + + assertNull(result); + } + + @Test + public void testGetHotspotInterfaceIP_IPv6Filtered() throws Exception { + // Create mock NetworkInterface for AP with IPv6 address + NetworkInterface mockIntf = mock(NetworkInterface.class); + when(mockIntf.isUp()).thenReturn(true); + when(mockIntf.isLoopback()).thenReturn(false); + when(mockIntf.isVirtual()).thenReturn(false); + when(mockIntf.getName()).thenReturn("ap0"); + + // Create mock InetAddress with IPv6 (contains colon) + InetAddress mockInetAddr = mock(InetAddress.class); + when(mockInetAddr.isLoopbackAddress()).thenReturn(false); + when(mockInetAddr.isLinkLocalAddress()).thenReturn(false); + when(mockInetAddr.isSiteLocalAddress()).thenReturn(true); + when(mockInetAddr.getHostAddress()).thenReturn("2001:db8::1"); // IPv6 + + InterfaceAddress mockIfaceAddr = mock(InterfaceAddress.class); + when(mockIfaceAddr.getAddress()).thenReturn(mockInetAddr); + + when(mockIntf.getInterfaceAddresses()).thenReturn(Arrays.asList(mockIfaceAddr)); + + List interfaces = Arrays.asList(mockIntf); + String result = NetworkUtils.getHotspotInterfaceIP(interfaces); + + assertNull(result); // IPv6 should be filtered out + } + + @Test + public void testGetHotspotInterfaceIP_PublicIPFiltered() throws Exception { + // Create mock NetworkInterface for AP with public IP + NetworkInterface mockIntf = mock(NetworkInterface.class); + when(mockIntf.isUp()).thenReturn(true); + when(mockIntf.isLoopback()).thenReturn(false); + when(mockIntf.isVirtual()).thenReturn(false); + when(mockIntf.getName()).thenReturn("ap0"); + + // Create mock InetAddress with public IP + InetAddress mockInetAddr = mock(InetAddress.class); + when(mockInetAddr.isLoopbackAddress()).thenReturn(false); + when(mockInetAddr.isLinkLocalAddress()).thenReturn(false); + when(mockInetAddr.isSiteLocalAddress()).thenReturn(true); + when(mockInetAddr.getHostAddress()).thenReturn("8.8.8.8"); // Public IP + + InterfaceAddress mockIfaceAddr = mock(InterfaceAddress.class); + when(mockIfaceAddr.getAddress()).thenReturn(mockInetAddr); + + when(mockIntf.getInterfaceAddresses()).thenReturn(Arrays.asList(mockIfaceAddr)); + + List interfaces = Arrays.asList(mockIntf); + String result = NetworkUtils.getHotspotInterfaceIP(interfaces); + + assertNull(result); // Public IP should be filtered out by isLikelyApIP + } + + @Test + public void testGetHotspotInterfaceIP_AllApPatterns() throws Exception { + // Test all AP interface patterns: "ap", "wlan1", "p2p", "hotspot", "softap", "wifi_ap" + String[] apPatterns = {"ap0", "wlan1", "p2p0", "hotspot0", "softap0", "wifi_ap0"}; + + for (String interfaceName : apPatterns) { + NetworkInterface mockIntf = mock(NetworkInterface.class); + when(mockIntf.isUp()).thenReturn(true); + when(mockIntf.isLoopback()).thenReturn(false); + when(mockIntf.isVirtual()).thenReturn(false); + when(mockIntf.getName()).thenReturn(interfaceName); + + InetAddress mockInetAddr = mock(InetAddress.class); + when(mockInetAddr.isLoopbackAddress()).thenReturn(false); + when(mockInetAddr.isLinkLocalAddress()).thenReturn(false); + when(mockInetAddr.isSiteLocalAddress()).thenReturn(true); + when(mockInetAddr.getHostAddress()).thenReturn("192.168.1.1"); + + InterfaceAddress mockIfaceAddr = mock(InterfaceAddress.class); + when(mockIfaceAddr.getAddress()).thenReturn(mockInetAddr); + when(mockIntf.getInterfaceAddresses()).thenReturn(Arrays.asList(mockIfaceAddr)); + + List interfaces = Arrays.asList(mockIntf); + String result = NetworkUtils.getHotspotInterfaceIP(interfaces); + + assertEquals("AP pattern " + interfaceName + " should be detected", "192.168.1.1", result); + } + } + + @Test + public void testGetHotspotInterfaceIP_CellularInterfacesFiltered() throws Exception { + // Test that cellular interfaces are filtered out (security feature) + String[] cellularNames = {"rmnet0", "ccmni0", "pdp_ip0", "ppp0", "cellular0", "mobile0", "radio0", "baseband0"}; + + for (String cellularName : cellularNames) { + NetworkInterface mockIntf = mock(NetworkInterface.class); + when(mockIntf.isUp()).thenReturn(true); + when(mockIntf.isLoopback()).thenReturn(false); + when(mockIntf.isVirtual()).thenReturn(false); + when(mockIntf.getName()).thenReturn(cellularName); + + // Even if it has AP-like IP, should be filtered out by cellular detection + InetAddress mockInetAddr = mock(InetAddress.class); + when(mockInetAddr.isLoopbackAddress()).thenReturn(false); + when(mockInetAddr.isLinkLocalAddress()).thenReturn(false); + when(mockInetAddr.isSiteLocalAddress()).thenReturn(true); + when(mockInetAddr.getHostAddress()).thenReturn("192.168.1.1"); + + InterfaceAddress mockIfaceAddr = mock(InterfaceAddress.class); + when(mockIfaceAddr.getAddress()).thenReturn(mockInetAddr); + when(mockIntf.getInterfaceAddresses()).thenReturn(Arrays.asList(mockIfaceAddr)); + + List interfaces = Arrays.asList(mockIntf); + String result = NetworkUtils.getHotspotInterfaceIP(interfaces); + + assertNull("Cellular interface " + cellularName + " should be filtered out", result); + } + } + + @Test + public void testGetHotspotInterfaceIP_AllPrivateIPRanges() throws Exception { + // Test all supported private IP ranges: 192.168.x, 10.x, 172.16-31.x + String[] validPrivateIPs = { + "192.168.1.1", // 192.168.x.x range + "192.168.0.1", + "10.0.0.1", // 10.x.x.x range + "10.255.255.1", + "172.16.0.1", // 172.16-31.x.x range + "172.31.255.1" + }; + + for (String ip : validPrivateIPs) { + NetworkInterface mockIntf = mock(NetworkInterface.class); + when(mockIntf.isUp()).thenReturn(true); + when(mockIntf.isLoopback()).thenReturn(false); + when(mockIntf.isVirtual()).thenReturn(false); + when(mockIntf.getName()).thenReturn("ap0"); + + InetAddress mockInetAddr = mock(InetAddress.class); + when(mockInetAddr.isLoopbackAddress()).thenReturn(false); + when(mockInetAddr.isLinkLocalAddress()).thenReturn(false); + when(mockInetAddr.isSiteLocalAddress()).thenReturn(true); + when(mockInetAddr.getHostAddress()).thenReturn(ip); + + InterfaceAddress mockIfaceAddr = mock(InterfaceAddress.class); + when(mockIfaceAddr.getAddress()).thenReturn(mockInetAddr); + when(mockIntf.getInterfaceAddresses()).thenReturn(Arrays.asList(mockIfaceAddr)); + + List interfaces = Arrays.asList(mockIntf); + String result = NetworkUtils.getHotspotInterfaceIP(interfaces); + + assertEquals("Private IP " + ip + " should be accepted", ip, result); + } + } + + @Test + public void testGetHotspotInterfaceIP_InvalidPrivateIPRanges() throws Exception { + // Test IPs that should be rejected by isLikelyApIP + String[] invalidPrivateIPs = { + "172.15.255.255", // Just below 172.16-31 range + "172.32.0.0", // Just above 172.16-31 range + "172.0.0.1", // Way below range + "127.0.0.1", // Loopback + "169.254.1.1", // Link-local + "8.8.8.8" // Public IP + }; + + for (String ip : invalidPrivateIPs) { + NetworkInterface mockIntf = mock(NetworkInterface.class); + when(mockIntf.isUp()).thenReturn(true); + when(mockIntf.isLoopback()).thenReturn(false); + when(mockIntf.isVirtual()).thenReturn(false); + when(mockIntf.getName()).thenReturn("ap0"); + + InetAddress mockInetAddr = mock(InetAddress.class); + when(mockInetAddr.isLoopbackAddress()).thenReturn(false); + when(mockInetAddr.isLinkLocalAddress()).thenReturn(false); + when(mockInetAddr.isSiteLocalAddress()).thenReturn(true); + when(mockInetAddr.getHostAddress()).thenReturn(ip); + + InterfaceAddress mockIfaceAddr = mock(InterfaceAddress.class); + when(mockIfaceAddr.getAddress()).thenReturn(mockInetAddr); + when(mockIntf.getInterfaceAddresses()).thenReturn(Arrays.asList(mockIfaceAddr)); + + List interfaces = Arrays.asList(mockIntf); + String result = NetworkUtils.getHotspotInterfaceIP(interfaces); + + assertNull("Invalid private IP " + ip + " should be rejected", result); + } + } + + @Test + public void testGetHotspotInterfaceIP_MultipleInterfaces() throws Exception { + // Test realistic scenario: cellular first, then WiFi client, then AP + NetworkInterface cellularIntf = mock(NetworkInterface.class); + when(cellularIntf.isUp()).thenReturn(true); + when(cellularIntf.isLoopback()).thenReturn(false); + when(cellularIntf.isVirtual()).thenReturn(false); + when(cellularIntf.getName()).thenReturn("rmnet0"); // Should be filtered + + NetworkInterface wifiClientIntf = mock(NetworkInterface.class); + when(wifiClientIntf.isUp()).thenReturn(true); + when(wifiClientIntf.isLoopback()).thenReturn(false); + when(wifiClientIntf.isVirtual()).thenReturn(false); + when(wifiClientIntf.getName()).thenReturn("wlan0"); // Not AP pattern + + NetworkInterface apIntf = mock(NetworkInterface.class); + when(apIntf.isUp()).thenReturn(true); + when(apIntf.isLoopback()).thenReturn(false); + when(apIntf.isVirtual()).thenReturn(false); + when(apIntf.getName()).thenReturn("ap0"); // AP pattern + + InetAddress mockInetAddr = mock(InetAddress.class); + when(mockInetAddr.isLoopbackAddress()).thenReturn(false); + when(mockInetAddr.isLinkLocalAddress()).thenReturn(false); + when(mockInetAddr.isSiteLocalAddress()).thenReturn(true); + when(mockInetAddr.getHostAddress()).thenReturn("192.168.43.1"); + + InterfaceAddress mockIfaceAddr = mock(InterfaceAddress.class); + when(mockIfaceAddr.getAddress()).thenReturn(mockInetAddr); + when(apIntf.getInterfaceAddresses()).thenReturn(Arrays.asList(mockIfaceAddr)); + + // Other interfaces return empty address lists + when(cellularIntf.getInterfaceAddresses()).thenReturn(Collections.emptyList()); + when(wifiClientIntf.getInterfaceAddresses()).thenReturn(Collections.emptyList()); + + List interfaces = Arrays.asList(cellularIntf, wifiClientIntf, apIntf); + String result = NetworkUtils.getHotspotInterfaceIP(interfaces); + + assertEquals("Should find AP interface despite cellular and client interfaces", "192.168.43.1", result); + } + + @Test + public void testGetHotspotInterfaceIP_InterfaceWithNoAddresses() throws Exception { + // Test interface that matches AP pattern but has no addresses + NetworkInterface mockIntf = mock(NetworkInterface.class); + when(mockIntf.isUp()).thenReturn(true); + when(mockIntf.isLoopback()).thenReturn(false); + when(mockIntf.isVirtual()).thenReturn(false); + when(mockIntf.getName()).thenReturn("ap0"); + when(mockIntf.getInterfaceAddresses()).thenReturn(Collections.emptyList()); + + List interfaces = Arrays.asList(mockIntf); + String result = NetworkUtils.getHotspotInterfaceIP(interfaces); + + assertNull("Interface with no addresses should return null", result); + } +} \ No newline at end of file