Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
dd7de89
Add in-app SSH terminal with WebView and persistent sessions
pappz May 10, 2026
2258890
Add an SSH button to each peer row
pappz Aug 9, 2026
1d9b791
Drop the password field from the SSH connect dialog
pappz Aug 9, 2026
93c3680
Prompt for the SSH password in the terminal and allow reconnecting
pappz Aug 9, 2026
6aa14e4
Persist the SSH session list per profile
pappz Aug 9, 2026
69d1fa2
Give the SSH session list disconnect, reconnect and dark-theme fixes
pappz Aug 9, 2026
c47e96f
Bump netbird submodule for the SSH password and error changes
pappz Aug 9, 2026
59de9fd
Allow duplicating an SSH session to the same host
pappz Aug 9, 2026
c1078cb
Match the SSH session FAB to the profiles page design
pappz Aug 10, 2026
90d7c00
Update the terminal to xterm.js 6.0
pappz Aug 11, 2026
8b01650
Configure the SSH terminal and fix its keyboard handling
pappz Aug 11, 2026
eea15ea
Remember the SSH username instead of defaulting to one
pappz Aug 11, 2026
5766890
Bump netbird submodule to dismiss the SSH auth browser
pappz Aug 11, 2026
dfdb1d2
Run the SSO Custom Tab calls on the main thread
pappz Aug 11, 2026
54bdfcb
Let a stored SSH session be edited
pappz Aug 11, 2026
482a7a8
Allow rotation while the SSH terminal is open
pappz Aug 11, 2026
74cf7ef
Prompt to trust an unknown SSH host key
pappz Aug 11, 2026
0161889
Localize the SSH feature into every supported language
pappz Aug 11, 2026
734295a
Bump the netbird submodule to the main merge
pappz Aug 12, 2026
7ddab90
Bump netbird submodule for the SSH close and reconnect fixes
pappz Aug 12, 2026
f905956
Keep the terminal key bar above the navigation bar
pappz Aug 12, 2026
dbfd2c8
Give the SSH session list a title bar and room to breathe
pappz Aug 12, 2026
09b8254
Call deleting a session what it is
pappz Aug 12, 2026
5493060
Refuse an SSH redial the stopped engine cannot serve
pappz Aug 12, 2026
9be5b7b
Give the settings list a title bar too
pappz Aug 12, 2026
3f26700
Fix threading and lifecycle faults around SSH sessions
pappz Aug 12, 2026
b7786b8
Reject an out-of-range port in the SSH connect dialog
pappz Aug 12, 2026
677ed09
Localize the SSH status and state text
pappz Aug 12, 2026
74fdcd9
Make the SSH rows reachable and match the peer list
pappz Aug 12, 2026
17dd2ba
Update netbird submodule to deduplicated SSH client
pappz Aug 14, 2026
4380065
Update netbird submodule to serialized wasm SSH startup
pappz Aug 14, 2026
a4945d0
Post the device-code login opener's UI work to the main thread
pappz Aug 14, 2026
334ed78
Bump netbird for constructor-style login hints
pappz Aug 14, 2026
159cca7
Bump netbird to the main merge
pappz Aug 14, 2026
791fae7
Default the peer SSH port to 22
pappz Aug 14, 2026
e64e115
Store SSH sessions and known hosts in the profile's preferences
pappz Aug 16, 2026
0bd6ae0
Add file drop to the Android client
pappz Aug 16, 2026
560f7fd
Bump the netbird submodule to the file drop tun rebind fix
pappz Aug 16, 2026
b2a4c31
Bump the netbird submodule to the new file drop port
pappz Aug 16, 2026
5601f52
Merge the Files and SSH tabs into one Apps screen
pappz Aug 17, 2026
bc1ba0e
Restyle the file transfer rows as peer-style cards
pappz Aug 17, 2026
2dd3686
Match the desktop wording for two transfer states
pappz Aug 17, 2026
499b2fe
Poll the transfer list while a transfer is live
pappz Aug 18, 2026
35bcc13
Fix the stale peer picker preview text
pappz Aug 18, 2026
1a800ff
Rework the share screen's header and peer rows
pappz Aug 18, 2026
87774b1
Let a send in flight be stopped, and order the peers
pappz Aug 18, 2026
594d550
Show a bar in the Files list, and split stopping from removing
pappz Aug 18, 2026
ac7d2ab
Bump the netbird submodule to the file drop progress and cancel fixes
pappz Aug 18, 2026
b42569a
Receive dropped files straight into Downloads
pappz Aug 25, 2026
602257f
Bump the netbird submodule to the pinned file drop port
pappz Aug 25, 2026
3f6f7d0
Merge branch 'ux/ios-style-redesign' into file-share
pappz Aug 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,23 @@
</intent-filter>
</activity>

<activity
android:name=".ui.files.ShareTargetActivity"
android:exported="true"
android:excludeFromRecents="true"
android:theme="@style/Theme.NetBird.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
</activity>

<!-- Persists the in-app language choice on API < 33 (AppCompat per-app locales). -->
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
Expand Down
70 changes: 61 additions & 9 deletions app/src/main/java/io/netbird/client/MainActivity.java
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
package io.netbird.client;

import android.Manifest;
import android.animation.StateListAnimator;
import android.app.Activity;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.content.pm.ActivityInfo;
import android.content.pm.PackageManager;
import android.content.res.Configuration;
import android.graphics.drawable.ColorDrawable;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.IBinder;
import android.text.Html;
Expand Down Expand Up @@ -53,10 +56,12 @@
import io.netbird.client.tool.SessionEventListener;
import io.netbird.client.tool.VPNService;
import io.netbird.client.ui.PreferenceUI;
import io.netbird.client.tool.files.FileDropManager;
import io.netbird.client.ui.ssh.SshSessionManager;
import io.netbird.gomobile.android.Android;
import io.netbird.gomobile.android.ConnectionListener;
import io.netbird.gomobile.android.ErrListener;
import io.netbird.gomobile.android.FileDrop;
import io.netbird.gomobile.android.NetworkArray;
import io.netbird.gomobile.android.PeerInfoArray;
import io.netbird.gomobile.android.SSHClient;
Expand All @@ -78,6 +83,7 @@ private enum ConnectionState {
private final static String LOGTAG = "NBMainActivity";
private VPNService.MyLocalBinder mBinder;
private SshSessionManager.ClientFactory sshClientFactory;
private ActivityResultLauncher<String> notificationPermissionLauncher;

private AppBarConfiguration mAppBarConfiguration;
private ActivityMainBinding binding;
Expand Down Expand Up @@ -150,6 +156,10 @@ public void onServiceConnected(ComponentName className, IBinder binder) {
pendingExtendRequest = false;
extendSession();
}

// The transfer list is only readable through the binder, so a
// fragment that opened before this point is still showing nothing.
FileDropManager.get().refresh();
}

@Override
Expand Down Expand Up @@ -206,11 +216,12 @@ public boolean canConnect() {
NavigationBarView bottomNav = (NavigationBarView) binding.bottomNav;

// All four bottom-nav destinations are top-level — no Up arrow on those.
// BottomNavigationView rejects a sixth item outright, so anything beyond
// these lives under Settings and keeps its Up arrow.
final Set<Integer> topLevelDestinations = new HashSet<>();
topLevelDestinations.add(R.id.nav_home);
topLevelDestinations.add(R.id.nav_peers);
topLevelDestinations.add(R.id.nav_networks);
topLevelDestinations.add(R.id.nav_ssh_sessions);
topLevelDestinations.add(R.id.nav_apps);
topLevelDestinations.add(R.id.nav_settings);
mAppBarConfiguration = new AppBarConfiguration.Builder(topLevelDestinations).build();
navController = Navigation.findNavController(this, R.id.nav_host_fragment_content_main);
Expand Down Expand Up @@ -245,14 +256,13 @@ public boolean canConnect() {
}
bottomNav.setVisibility(View.VISIBLE);

// Home, Peers and Networks don't need a toolbar — bottom nav already
// identifies the screen. Sub-screens keep the toolbar with title + Up
// arrow. SSH sessions and Settings are the exceptions among the tabs:
// both are lists that run to the top of the screen, and without a title
// bar to anchor them the first row reads as cut off rather than as the
// start of a list.
// Home, Peers and Apps don't need a toolbar — bottom nav already
// identifies the screen, and Apps carries its own segmented control at
// the top. Sub-screens keep the toolbar with title + Up arrow. Settings
// is the exception among the tabs: it is a list that runs to the top of
// the screen, and without a title bar to anchor it the first row reads
// as cut off rather than as the start of a list.
boolean hideToolbar = topLevelDestinations.contains(destId)
&& destId != R.id.nav_ssh_sessions
&& destId != R.id.nav_settings;
setToolbarVisible(!hideToolbar);

Expand Down Expand Up @@ -324,6 +334,17 @@ public void onLoginSuccess() {
// opener must be built here, not lazily at tap time.
extendUrlOpener = buildExtendURLOpener();

// Asked once, on the first launch that can act on it: without it every
// notify() this app makes is dropped silently on API 33+, which takes
// the file drop consent prompt and transfer progress with it.
notificationPermissionLauncher = registerForActivityResult(
new ActivityResultContracts.RequestPermission(),
granted -> {
if (!granted) {
Log.i(LOGTAG, "notification permission denied");
}
});

// VPN permission result launcher
vpnActivityResultLauncher = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(),
Expand Down Expand Up @@ -358,9 +379,26 @@ public void onLoginSuccess() {
showFirstInstallFragment();
}

requestNotificationPermission();
handleSessionIntent(getIntent());
}

/**
* Asks for POST_NOTIFICATIONS when the platform requires it. Everything this
* app notifies about — session expiry, incoming files, transfer progress —
* is dropped silently without it.
*/
private void requestNotificationPermission() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
return;
}
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
== PackageManager.PERMISSION_GRANTED) {
return;
}
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS);
}

@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
Expand Down Expand Up @@ -595,6 +633,20 @@ public SSHClient newSSHClient() {
return mBinder.newSSHClient();
}

@Override
public FileDrop fileDrop() {
if (mBinder == null) {
Log.w(LOGTAG, "VPN binder is null");
return null;
}
try {
return mBinder.fileDrop();
} catch (Exception e) {
Log.e(LOGTAG, "failed to open file drop", e);
return null;
}
}

private boolean isEngineRunning() {
return mBinder != null && mBinder.isRunning();
}
Expand Down
8 changes: 8 additions & 0 deletions app/src/main/java/io/netbird/client/ServiceAccessor.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import androidx.annotation.Nullable;

import io.netbird.client.tool.RouteChangeListener;
import io.netbird.gomobile.android.FileDrop;
import io.netbird.gomobile.android.NetworkArray;
import io.netbird.gomobile.android.PeerInfoArray;
import io.netbird.gomobile.android.SSHClient;
Expand Down Expand Up @@ -38,5 +39,12 @@ public interface ServiceAccessor {

SSHClient newSSHClient();

/**
* File drop handle of the active profile, or null while the VPN service is
* not bound. Usable with the engine stopped; only sending needs the tunnel.
*/
@Nullable
FileDrop fileDrop();

URLOpener getSSHURLOpener();
}
126 changes: 126 additions & 0 deletions app/src/main/java/io/netbird/client/ui/SegmentedSwitch.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package io.netbird.client.ui;

import android.content.Context;
import android.content.res.ColorStateList;
import android.view.View;
import android.view.ViewGroup;
import android.view.animation.PathInterpolator;
import android.widget.FrameLayout;
import android.widget.TextView;

import androidx.core.content.ContextCompat;

import io.netbird.client.R;

/**
* Two-way segmented control: a track laid out by {@code bg_segmented_track}
* holding a thumb and two labels, with the thumb sliding between them.
* <p>
* The view ids are passed in rather than fixed, so one implementation serves
* every segmented control in the app. {@link io.netbird.client.ui.server.ManagementServerSwitch}
* predates this and keeps its own copy along with the Cloud logo it draws.
*/
public final class SegmentedSwitch {

public interface OnSelectionChangedListener {
void onSelectionChanged(boolean second);
}

private static final long SLIDE_DURATION_MS = 180;

private final Context context;
private final FrameLayout track;
private final View thumb;
private final TextView firstLabel;
private final TextView secondLabel;
private final OnSelectionChangedListener listener;

private boolean second;
// True while the slide animation owns translationX, so a layout pass
// triggered mid-slide cannot snap the thumb back.
private boolean sliding;

public SegmentedSwitch(View root, int trackId, int thumbId, int firstButtonId,
int firstLabelId, int secondButtonId, int secondLabelId,
OnSelectionChangedListener listener) {
this.context = root.getContext();
this.listener = listener;

track = root.findViewById(trackId);
thumb = root.findViewById(thumbId);
firstLabel = root.findViewById(firstLabelId);
secondLabel = root.findViewById(secondLabelId);

track.addOnLayoutChangeListener((v, l, t, r, b, ol, ot, or, ob) -> {
int half = halfWidth();
if (half <= 0 || sliding) {
return;
}
ViewGroup.LayoutParams lp = thumb.getLayoutParams();
if (lp.width != half) {
lp.width = half;
thumb.setLayoutParams(lp);
}
thumb.setTranslationX(restingOffset(half));
});

root.findViewById(firstButtonId).setOnClickListener(v -> select(false, true));
root.findViewById(secondButtonId).setOnClickListener(v -> select(true, true));
applyLabelColors();
}

public boolean isSecondSelected() {
return second;
}

/** Selects a segment without animating or notifying, for initial state. */
public void selectSilently(boolean second) {
select(second, false);
}

private void select(boolean second, boolean notify) {
if (this.second == second) {
return;
}
this.second = second;

if (notify) {
sliding = true;
thumb.animate()
.translationX(restingOffset(halfWidth()))
.setDuration(SLIDE_DURATION_MS)
.setInterpolator(new PathInterpolator(0.2f, 0f, 0f, 1f))
.withEndAction(() -> sliding = false)
.start();
} else {
// No measured width yet at seed time; the layout listener places
// the thumb once the track is laid out.
thumb.setTranslationX(restingOffset(halfWidth()));
}

applyLabelColors();
if (notify && listener != null) {
listener.onSelectionChanged(second);
}
}

private void applyLabelColors() {
ColorStateList active = ContextCompat.getColorStateList(context, R.color.nb_txt);
ColorStateList inactive = ContextCompat.getColorStateList(context, R.color.nb_txt_light);
firstLabel.setTextColor(second ? inactive : active);
secondLabel.setTextColor(second ? active : inactive);
}

private int halfWidth() {
return (track.getWidth() - track.getPaddingLeft() - track.getPaddingRight()) / 2;
}

/** Resting X offset of the thumb for the current selection, in pixels. */
private float restingOffset(int width) {
if (!second) {
return 0f;
}
boolean rtl = track.getLayoutDirection() == View.LAYOUT_DIRECTION_RTL;
return rtl ? -width : width;
}
}
70 changes: 70 additions & 0 deletions app/src/main/java/io/netbird/client/ui/apps/AppsFragment.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package io.netbird.client.ui.apps;

import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;

import io.netbird.client.R;
import io.netbird.client.databinding.FragmentAppsBinding;
import io.netbird.client.ui.SegmentedSwitch;

/**
* Hosts SSH and Files as two halves of one screen. Both are their own
* fragments, unchanged and still reachable on their own; this only decides
* which of the two is showing, so the bottom navigation spends a single slot
* on them.
*/
public class AppsFragment extends Fragment {

private static final String STATE_SHOWING_FILES = "showing_files";

private FragmentAppsBinding binding;
private boolean showingFiles;

@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
binding = FragmentAppsBinding.inflate(inflater, container, false);
return binding.getRoot();
}

@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);

SegmentedSwitch segments = new SegmentedSwitch(view, R.id.toggle_ssh_files,
R.id.segment_thumb, R.id.btn_view_ssh, R.id.label_view_ssh,
R.id.btn_view_files, R.id.label_view_files, this::showFiles);

if (savedInstanceState != null && savedInstanceState.getBoolean(STATE_SHOWING_FILES)) {
segments.selectSilently(true);
showFiles(true);
}
}

@Override
public void onSaveInstanceState(@NonNull Bundle outState) {
super.onSaveInstanceState(outState);
outState.putBoolean(STATE_SHOWING_FILES, showingFiles);
}

@Override
public void onDestroyView() {
binding = null;
super.onDestroyView();
}

private void showFiles(boolean files) {
if (binding == null) {
return;
}
showingFiles = files;
binding.sshContainer.setVisibility(files ? View.GONE : View.VISIBLE);
binding.filesContainer.setVisibility(files ? View.VISIBLE : View.GONE);
}
}
Loading
Loading