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);
@@ -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);
@@ -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(),
@@ -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);
@@ -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();
}
diff --git a/app/src/main/java/io/netbird/client/ServiceAccessor.java b/app/src/main/java/io/netbird/client/ServiceAccessor.java
index 08ef2fd5..46e3df57 100644
--- a/app/src/main/java/io/netbird/client/ServiceAccessor.java
+++ b/app/src/main/java/io/netbird/client/ServiceAccessor.java
@@ -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;
@@ -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();
}
\ No newline at end of file
diff --git a/app/src/main/java/io/netbird/client/ui/SegmentedSwitch.java b/app/src/main/java/io/netbird/client/ui/SegmentedSwitch.java
new file mode 100644
index 00000000..77c2ef8b
--- /dev/null
+++ b/app/src/main/java/io/netbird/client/ui/SegmentedSwitch.java
@@ -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.
+ *
+ * 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;
+ }
+}
diff --git a/app/src/main/java/io/netbird/client/ui/apps/AppsFragment.java b/app/src/main/java/io/netbird/client/ui/apps/AppsFragment.java
new file mode 100644
index 00000000..e393d0e8
--- /dev/null
+++ b/app/src/main/java/io/netbird/client/ui/apps/AppsFragment.java
@@ -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);
+ }
+}
diff --git a/app/src/main/java/io/netbird/client/ui/files/FileDropFragment.java b/app/src/main/java/io/netbird/client/ui/files/FileDropFragment.java
new file mode 100644
index 00000000..8b542780
--- /dev/null
+++ b/app/src/main/java/io/netbird/client/ui/files/FileDropFragment.java
@@ -0,0 +1,581 @@
+package io.netbird.client.ui.files;
+
+import android.annotation.SuppressLint;
+import android.app.AlertDialog;
+import android.content.ClipData;
+import android.content.ClipboardManager;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.text.Editable;
+import android.text.TextWatcher;
+import android.text.format.DateFormat;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.core.content.ContextCompat;
+import androidx.core.content.FileProvider;
+import androidx.fragment.app.Fragment;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.google.android.material.button.MaterialButton;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.List;
+import java.util.Locale;
+
+import io.netbird.client.R;
+import io.netbird.client.databinding.FragmentFileDropBinding;
+import io.netbird.client.databinding.ListItemFileDayHeaderBinding;
+import io.netbird.client.databinding.ListItemFileOfferBinding;
+import io.netbird.client.databinding.ListItemFileTransferBinding;
+import io.netbird.client.tool.files.FileDropManager;
+import io.netbird.gomobile.android.Android;
+
+/**
+ * The Files screen: offers awaiting consent pinned to the top, then the transfer
+ * log grouped by day, newest first. The receiving policy is not here; it lives
+ * under Settings, so this screen stays a log.
+ */
+public class FileDropFragment extends Fragment {
+
+ private static final int TYPE_OFFER = 0;
+ private static final int TYPE_DAY = 1;
+ private static final int TYPE_TRANSFER = 2;
+
+ private FragmentFileDropBinding binding;
+ private final TransfersAdapter adapter = new TransfersAdapter();
+ private List allTransfers = new ArrayList<>();
+ private String query = "";
+
+ // The manager notifies from its own executor, so every update is posted
+ // back to the view before it touches the adapter.
+ private final FileDropManager.TransfersListener transfersListener =
+ transfers -> onUiThread(() -> onTransfers(transfers));
+
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
+ @Nullable Bundle savedInstanceState) {
+ binding = FragmentFileDropBinding.inflate(inflater, container, false);
+ return binding.getRoot();
+ }
+
+ @Override
+ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+
+ binding.transfersRecycler.setLayoutManager(new LinearLayoutManager(requireContext()));
+ binding.transfersRecycler.setAdapter(adapter);
+
+ binding.searchView.addTextChangedListener(new TextWatcher() {
+ @Override public void beforeTextChanged(CharSequence s, int a, int b, int c) {}
+ @Override public void onTextChanged(CharSequence s, int a, int b, int c) {
+ query = s.toString().trim().toLowerCase(Locale.getDefault());
+ render();
+ }
+ @Override public void afterTextChanged(Editable s) {}
+ });
+
+ FileDropManager.get().addTransfersListener(transfersListener);
+
+ // The list is only readable through the bound service, which may have
+ // arrived after the manager last published.
+ FileDropManager.get().refresh();
+ }
+
+ @Override
+ public void onDestroyView() {
+ super.onDestroyView();
+ FileDropManager.get().removeTransfersListener(transfersListener);
+ binding = null;
+ }
+
+ private void onTransfers(List transfers) {
+ allTransfers = transfers;
+ render();
+ }
+
+ private void render() {
+ if (binding == null) {
+ return;
+ }
+ List shown = filtered(allTransfers);
+ adapter.submit(buildRows(shown));
+ binding.emptyView.setVisibility(shown.isEmpty() ? View.VISIBLE : View.GONE);
+ binding.emptyView.setText(query.isEmpty()
+ ? R.string.file_drop_empty
+ : R.string.file_drop_no_results);
+ }
+
+ /** Matches the file names and the peer, the same fields the desktop searches. */
+ private List filtered(List transfers) {
+ if (query.isEmpty()) {
+ return transfers;
+ }
+
+ List out = new ArrayList<>();
+ for (FileDropManager.Transfer t : transfers) {
+ if (matches(t)) {
+ out.add(t);
+ }
+ }
+ return out;
+ }
+
+ private boolean matches(FileDropManager.Transfer transfer) {
+ Locale locale = Locale.getDefault();
+ if (transfer.peerName().toLowerCase(locale).contains(query)) {
+ return true;
+ }
+ if (transfer.isText() && transfer.text().toLowerCase(locale).contains(query)) {
+ return true;
+ }
+ for (String name : transfer.fileNames()) {
+ if (name.toLowerCase(locale).contains(query)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Flattens the transfer log into display rows: pending incoming offers
+ * first, then the rest under a header per day.
+ */
+ private List buildRows(List transfers) {
+ List rows = new ArrayList<>();
+
+ for (FileDropManager.Transfer transfer : transfers) {
+ if (isAnswerable(transfer)) {
+ rows.add(Row.offer(transfer));
+ }
+ }
+
+ String currentDay = null;
+ for (FileDropManager.Transfer transfer : transfers) {
+ if (isAnswerable(transfer)) {
+ continue;
+ }
+ String day = dayLabel(transfer.createdAtMillis());
+ if (!day.equals(currentDay)) {
+ rows.add(Row.day(day));
+ currentDay = day;
+ }
+ rows.add(Row.transfer(transfer));
+ }
+ return rows;
+ }
+
+ private static boolean isAnswerable(FileDropManager.Transfer transfer) {
+ return transfer.isPending() && !transfer.outgoing();
+ }
+
+ private String dayLabel(long millis) {
+ if (millis <= 0) {
+ return getString(R.string.file_drop_group_earlier);
+ }
+
+ Calendar day = Calendar.getInstance();
+ day.setTimeInMillis(millis);
+ Calendar today = Calendar.getInstance();
+
+ if (isSameDay(day, today)) {
+ return getString(R.string.file_drop_group_today);
+ }
+ today.add(Calendar.DAY_OF_YEAR, -1);
+ if (isSameDay(day, today)) {
+ return getString(R.string.file_drop_group_yesterday);
+ }
+ return DateFormat.getMediumDateFormat(requireContext()).format(new Date(millis));
+ }
+
+ private static boolean isSameDay(Calendar a, Calendar b) {
+ return a.get(Calendar.YEAR) == b.get(Calendar.YEAR)
+ && a.get(Calendar.DAY_OF_YEAR) == b.get(Calendar.DAY_OF_YEAR);
+ }
+
+ private String timeLabel(long millis) {
+ if (millis <= 0) {
+ return "";
+ }
+ return DateFormat.getTimeFormat(requireContext()).format(new Date(millis));
+ }
+
+ private void report(boolean ok, @Nullable String error) {
+ if (ok) {
+ return;
+ }
+ onUiThread(() -> {
+ if (binding != null) {
+ Toast.makeText(requireContext(), error, Toast.LENGTH_LONG).show();
+ }
+ });
+ }
+
+ /**
+ * Opens a received file. A delivered entry in shared storage is already a
+ * content Uri any app can read; a plain path is one this app owns alone, so
+ * it needs a FileProvider grant to leave the app at all.
+ */
+ private void open(FileDropManager.Transfer transfer) {
+ if (transfer.deliveredPaths().isEmpty()) {
+ return;
+ }
+
+ try {
+ Uri uri = readableUri(transfer.deliveredPaths().get(0));
+
+ Intent intent = new Intent(Intent.ACTION_VIEW);
+ intent.setDataAndType(uri, requireContext().getContentResolver().getType(uri));
+ intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
+ startActivity(Intent.createChooser(intent, getString(R.string.file_drop_open)));
+ } catch (Exception e) {
+ Toast.makeText(requireContext(), e.getMessage(), Toast.LENGTH_LONG).show();
+ }
+ }
+
+ private Uri readableUri(String delivered) {
+ if (delivered.startsWith(ContentResolver.SCHEME_CONTENT + ":")) {
+ return Uri.parse(delivered);
+ }
+ return FileProvider.getUriForFile(requireContext(),
+ requireContext().getPackageName() + ".fileprovider", new File(delivered));
+ }
+
+ private String outcomeLabel(FileDropManager.Transfer transfer) {
+ if (transfer.isUnreachable()) {
+ return getString(R.string.file_drop_state_unreachable);
+ }
+
+ long state = transfer.state();
+ if (state == Android.FileDropStateTransferring) {
+ return progressLabel(transfer);
+ }
+ if (state == Android.FileDropStatePending) {
+ return getString(R.string.file_drop_state_pending);
+ }
+ if (state == Android.FileDropStateCompleted) {
+ return getString(transfer.outgoing()
+ ? R.string.file_drop_state_sent
+ : R.string.file_drop_state_received);
+ }
+ if (state == Android.FileDropStateDeclined) {
+ return getString(R.string.file_drop_state_declined);
+ }
+ if (state == Android.FileDropStateExpired) {
+ return getString(R.string.file_drop_state_expired);
+ }
+ if (state == Android.FileDropStateCancelled) {
+ return getString(R.string.file_drop_state_cancelled);
+ }
+ return getString(R.string.file_drop_state_failed);
+ }
+
+ private String progressLabel(FileDropManager.Transfer transfer) {
+ if (transfer.totalSize() <= 0) {
+ return getString(R.string.file_drop_state_transferring);
+ }
+ int percent = (int) (transfer.transferred() * 100 / transfer.totalSize());
+ return getString(transfer.outgoing()
+ ? R.string.file_drop_state_progress
+ : R.string.file_drop_state_progress_incoming, percent);
+ }
+
+ /**
+ * Colours only what the eye should catch scanning the outcome column, as on
+ * the desktop: a refusal or failure in red, a completed send in green.
+ * Everything else, a received file and a transfer in flight included, stays
+ * neutral so the exceptions stand out.
+ */
+ private int outcomeColor(FileDropManager.Transfer transfer) {
+ long state = transfer.state();
+ int color = R.color.nb_txt_light;
+ if (transfer.isUnreachable()
+ || state == Android.FileDropStateDeclined
+ || state == Android.FileDropStateFailed) {
+ color = R.color.nb_danger;
+ } else if (state == Android.FileDropStateCompleted && transfer.outgoing()) {
+ color = R.color.nb_latency_good;
+ }
+ return ContextCompat.getColor(requireContext(), color);
+ }
+
+ /** Manager callbacks come off its executor; view work has to go back. */
+ private void onUiThread(Runnable action) {
+ View root = getView();
+ if (root != null) {
+ root.post(action);
+ }
+ }
+
+ private static final class Row {
+ final int type;
+ final FileDropManager.Transfer transfer;
+ final String label;
+
+ private Row(int type, FileDropManager.Transfer transfer, String label) {
+ this.type = type;
+ this.transfer = transfer;
+ this.label = label;
+ }
+
+ static Row offer(FileDropManager.Transfer transfer) {
+ return new Row(TYPE_OFFER, transfer, null);
+ }
+
+ static Row day(String label) {
+ return new Row(TYPE_DAY, null, label);
+ }
+
+ static Row transfer(FileDropManager.Transfer transfer) {
+ return new Row(TYPE_TRANSFER, transfer, null);
+ }
+ }
+
+ private final class TransfersAdapter extends RecyclerView.Adapter {
+
+ private final List rows = new ArrayList<>();
+
+ void submit(List next) {
+ rows.clear();
+ rows.addAll(next);
+ notifyDataSetChanged();
+ }
+
+ @Override
+ public int getItemViewType(int position) {
+ return rows.get(position).type;
+ }
+
+ @NonNull
+ @Override
+ public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ LayoutInflater inflater = LayoutInflater.from(parent.getContext());
+ switch (viewType) {
+ case TYPE_OFFER:
+ return new OfferViewHolder(
+ ListItemFileOfferBinding.inflate(inflater, parent, false));
+ case TYPE_DAY:
+ return new DayViewHolder(
+ ListItemFileDayHeaderBinding.inflate(inflater, parent, false));
+ default:
+ return new TransferViewHolder(
+ ListItemFileTransferBinding.inflate(inflater, parent, false));
+ }
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
+ Row row = rows.get(position);
+ if (holder instanceof OfferViewHolder) {
+ ((OfferViewHolder) holder).bind(row.transfer);
+ } else if (holder instanceof DayViewHolder) {
+ ((DayViewHolder) holder).bind(row.label);
+ } else {
+ ((TransferViewHolder) holder).bind(row.transfer);
+ }
+ }
+
+ @Override
+ public int getItemCount() {
+ return rows.size();
+ }
+ }
+
+ private final class DayViewHolder extends RecyclerView.ViewHolder {
+
+ private final ListItemFileDayHeaderBinding binding;
+
+ DayViewHolder(ListItemFileDayHeaderBinding binding) {
+ super(binding.getRoot());
+ this.binding = binding;
+ }
+
+ void bind(String label) {
+ binding.dayLabel.setText(label);
+ }
+ }
+
+ private final class OfferViewHolder extends RecyclerView.ViewHolder {
+
+ private final ListItemFileOfferBinding binding;
+
+ OfferViewHolder(ListItemFileOfferBinding binding) {
+ super(binding.getRoot());
+ this.binding = binding;
+ }
+
+ void bind(FileDropManager.Transfer transfer) {
+ binding.offerLabel.setText(transfer.isText()
+ ? title(transfer)
+ : getString(R.string.file_drop_offer_label,
+ title(transfer), formatSize(transfer.totalSize())));
+ binding.offerSubtitle.setText(
+ getString(R.string.file_drop_offer_subtitle, transfer.peerName()));
+
+ binding.offerAccept.setOnClickListener(v ->
+ FileDropManager.get().accept(transfer.id(), FileDropFragment.this::report));
+ binding.offerDecline.setOnClickListener(v ->
+ FileDropManager.get().decline(transfer.id(), FileDropFragment.this::report));
+ }
+ }
+
+ private final class TransferViewHolder extends RecyclerView.ViewHolder {
+
+ private final ListItemFileTransferBinding binding;
+
+ TransferViewHolder(ListItemFileTransferBinding binding) {
+ super(binding.getRoot());
+ this.binding = binding;
+ }
+
+ void bind(FileDropManager.Transfer transfer) {
+ binding.transferDirection.setImageResource(transfer.outgoing()
+ ? R.drawable.ic_arrow_up_small
+ : R.drawable.ic_arrow_down_small);
+ binding.transferDirection.setColorFilter(ContextCompat.getColor(requireContext(),
+ transfer.outgoing() ? R.color.nb_orange : R.color.nb_latency_good));
+
+ binding.transferLabel.setText(title(transfer));
+ binding.transferPeer.setText(getString(transfer.outgoing()
+ ? R.string.file_drop_direction_sent
+ : R.string.file_drop_direction_received,
+ transfer.peerName()));
+ binding.transferMeta.setText(meta(transfer));
+
+ // A received snippet is worth copying, not opening, so the status
+ // slot carries the action instead of the outcome.
+ boolean copyable = transfer.isText() && !transfer.outgoing();
+ if (copyable) {
+ binding.transferStatus.setText(R.string.file_drop_copy);
+ binding.transferStatus.setTextColor(
+ ContextCompat.getColor(requireContext(), R.color.nb_orange));
+ binding.transferStatus.setOnClickListener(v -> copy(transfer));
+ } else {
+ binding.transferStatus.setText(outcomeLabel(transfer));
+ binding.transferStatus.setTextColor(outcomeColor(transfer));
+ binding.transferStatus.setOnClickListener(null);
+ }
+ binding.transferStatus.setClickable(copyable);
+
+ // The bar only says how far along a live transfer is; a finished one
+ // has its outcome in the status slot and needs nothing else.
+ boolean moving = transfer.isRunning() && transfer.totalSize() > 0;
+ binding.transferProgress.setVisibility(moving ? View.VISIBLE : View.GONE);
+ if (moving) {
+ binding.transferProgress.setProgress(
+ (int) (transfer.transferred() * 100 / transfer.totalSize()));
+ }
+
+ boolean openable = !transfer.outgoing()
+ && transfer.state() == Android.FileDropStateCompleted
+ && !transfer.deliveredPaths().isEmpty();
+ binding.getRoot().setOnClickListener(openable ? v -> open(transfer) : null);
+ binding.getRoot().setClickable(openable);
+
+ binding.getRoot().setOnLongClickListener(v -> {
+ confirmStopOrDelete(transfer);
+ return true;
+ });
+ }
+
+ /** "14:32 · 214 MB". A text snippet has no size worth stating. */
+ private String meta(FileDropManager.Transfer transfer) {
+ String time = timeLabel(transfer.createdAtMillis());
+ if (transfer.isText()) {
+ return time;
+ }
+ String size = formatSize(transfer.totalSize());
+ return time.isEmpty() ? size : time + " · " + size;
+ }
+ }
+
+ /**
+ * Long press does one thing at a time: it stops a transfer that is still
+ * running, and removes one that has finished. Stopping something and
+ * dropping its record are separate decisions, so a running transfer takes
+ * two presses to disappear and the first one is never destructive.
+ */
+ private void confirmStopOrDelete(FileDropManager.Transfer transfer) {
+ if (transfer.isTerminal()) {
+ confirm(title(transfer), R.string.file_drop_delete_confirm, R.string.file_drop_delete,
+ () -> FileDropManager.get().delete(transfer.id()));
+ return;
+ }
+ confirm(title(transfer), R.string.file_drop_stop_confirm, R.string.file_drop_stop,
+ () -> FileDropManager.get().cancel(transfer.id()));
+ }
+
+ /** Puts one question with one answer in front of the user. */
+ @SuppressLint("InflateParams")
+ private void confirm(String title, int message, int action, Runnable onConfirm) {
+ View view = LayoutInflater.from(requireContext())
+ .inflate(R.layout.dialog_simple_edit_text, null);
+
+ ((TextView) view.findViewById(R.id.text_title_dialog)).setText(title);
+ ((TextView) view.findViewById(R.id.text_label_dialog)).setText(message);
+ view.findViewById(R.id.edit_text_dialog).setVisibility(View.GONE);
+
+ AlertDialog dialog = new AlertDialog.Builder(requireContext(), R.style.AlertDialogTheme)
+ .setView(view)
+ .create();
+
+ MaterialButton confirm = view.findViewById(R.id.btn_ok_dialog);
+ confirm.setText(action);
+ confirm.setOnClickListener(v -> {
+ onConfirm.run();
+ dialog.dismiss();
+ });
+ view.findViewById(R.id.btn_cancel_dialog).setOnClickListener(v -> dialog.dismiss());
+
+ dialog.show();
+ }
+
+ private void copy(FileDropManager.Transfer transfer) {
+ ClipboardManager clipboard =
+ (ClipboardManager) requireContext().getSystemService(Context.CLIPBOARD_SERVICE);
+ clipboard.setPrimaryClip(ClipData.newPlainText("", transfer.text()));
+ Toast.makeText(requireContext(), R.string.file_drop_copied, Toast.LENGTH_SHORT).show();
+ }
+
+ /** A text snippet reads as the quoted text; files read as their names. */
+ private String title(FileDropManager.Transfer transfer) {
+ if (transfer.isText()) {
+ return getString(R.string.file_drop_text_title, transfer.text());
+ }
+ List names = transfer.fileNames();
+ if (names.size() == 1) {
+ return names.get(0);
+ }
+ return String.join(", ", names);
+ }
+
+ private static String formatSize(long bytes) {
+ if (bytes < 1024) {
+ return bytes + " B";
+ }
+ String[] units = {"KB", "MB", "GB", "TB"};
+ double size = bytes;
+ int unit = -1;
+ do {
+ size /= 1024;
+ unit++;
+ } while (size >= 1024 && unit < units.length - 1);
+
+ if (size >= 10) {
+ return String.format("%.0f %s", size, units[unit]);
+ }
+ return String.format("%.1f %s", size, units[unit]);
+ }
+}
diff --git a/app/src/main/java/io/netbird/client/ui/files/FileSharingFragment.java b/app/src/main/java/io/netbird/client/ui/files/FileSharingFragment.java
new file mode 100644
index 00000000..b1d830ae
--- /dev/null
+++ b/app/src/main/java/io/netbird/client/ui/files/FileSharingFragment.java
@@ -0,0 +1,113 @@
+package io.netbird.client.ui.files;
+
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+
+import io.netbird.client.R;
+import io.netbird.client.databinding.FragmentFileSharingBinding;
+import io.netbird.client.tool.files.FileDropManager;
+import io.netbird.gomobile.android.Android;
+
+/**
+ * The receiving policy of the active profile: whether incoming offers are
+ * refused, prompted for, or accepted outright, plus where files land. The
+ * transfer log itself is the Files tab, not this screen.
+ */
+public class FileSharingFragment extends Fragment {
+
+ private FragmentFileSharingBinding binding;
+
+ // Set while the radio group is being populated from Go, so the resulting
+ // check callbacks are not mistaken for the user's own choice.
+ private boolean applyingMode;
+
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
+ @Nullable Bundle savedInstanceState) {
+ binding = FragmentFileSharingBinding.inflate(inflater, container, false);
+ return binding.getRoot();
+ }
+
+ @Override
+ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+
+ binding.modeGroup.setOnCheckedChangeListener((group, checkedId) -> {
+ if (applyingMode) {
+ return;
+ }
+ FileDropManager.get().setMode(modeOf(checkedId), this::report);
+ });
+
+ load();
+ }
+
+ @Override
+ public void onDestroyView() {
+ super.onDestroyView();
+ binding = null;
+ }
+
+ private void load() {
+ FileDropManager.get().mode(mode -> onUiThread(() -> {
+ if (binding == null) {
+ return;
+ }
+ applyingMode = true;
+ binding.modeGroup.check(checkIdOf(mode));
+ applyingMode = false;
+ }));
+
+ FileDropManager.get().destinationDir(dir -> onUiThread(() -> {
+ if (binding != null) {
+ binding.destinationValue.setText(dir);
+ }
+ }));
+ }
+
+ private long modeOf(int checkedId) {
+ if (checkedId == R.id.mode_off) {
+ return Android.FileDropModeOff;
+ }
+ if (checkedId == R.id.mode_auto) {
+ return Android.FileDropModeAutoAccept;
+ }
+ return Android.FileDropModeAsk;
+ }
+
+ private int checkIdOf(long mode) {
+ if (mode == Android.FileDropModeOff) {
+ return R.id.mode_off;
+ }
+ if (mode == Android.FileDropModeAutoAccept) {
+ return R.id.mode_auto;
+ }
+ return R.id.mode_ask;
+ }
+
+ private void report(boolean ok, @Nullable String error) {
+ if (ok) {
+ return;
+ }
+ onUiThread(() -> {
+ if (binding != null) {
+ Toast.makeText(requireContext(), error, Toast.LENGTH_LONG).show();
+ }
+ });
+ }
+
+ /** Manager callbacks come off its executor; view work has to go back. */
+ private void onUiThread(Runnable action) {
+ View root = getView();
+ if (root != null) {
+ root.post(action);
+ }
+ }
+}
diff --git a/app/src/main/java/io/netbird/client/ui/files/ShareTargetActivity.java b/app/src/main/java/io/netbird/client/ui/files/ShareTargetActivity.java
new file mode 100644
index 00000000..89502ca2
--- /dev/null
+++ b/app/src/main/java/io/netbird/client/ui/files/ShareTargetActivity.java
@@ -0,0 +1,766 @@
+package io.netbird.client.ui.files;
+
+import android.annotation.SuppressLint;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.ServiceConnection;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.IBinder;
+import android.text.Editable;
+import android.text.TextWatcher;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ProgressBar;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AlertDialog;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.core.content.ContextCompat;
+import androidx.core.graphics.Insets;
+import androidx.core.view.ViewCompat;
+import androidx.core.view.WindowInsetsCompat;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.function.Consumer;
+
+import com.google.android.material.button.MaterialButton;
+
+import io.netbird.client.R;
+import io.netbird.client.databinding.ActivityShareTargetBinding;
+import io.netbird.client.tool.VPNService;
+import io.netbird.client.tool.files.ContentFileSource;
+import io.netbird.client.tool.files.FileDropManager;
+import io.netbird.client.ui.home.Status;
+import io.netbird.gomobile.android.Android;
+import io.netbird.gomobile.android.PeerInfo;
+import io.netbird.gomobile.android.PeerInfoArray;
+
+/**
+ * Receives the system share sheet's files and sends them to a peer the user
+ * picks. A full screen rather than a dialog: an account can hold hundreds of
+ * peers, which needs a search field and room to scroll.
+ *
+ * Deliberately separate from MainActivity: that one is singleTask, so a share
+ * would land inside the running task and fight its back stack. Binds to the VPN
+ * service for the duration, both to read the peer list and because
+ * {@link FileDropManager}'s handle only exists while the service does.
+ */
+public class ShareTargetActivity extends AppCompatActivity {
+
+ private static final String LOGTAG = "ShareTargetActivity";
+
+ private final ExecutorService executor = Executors.newSingleThreadExecutor();
+ private final List shared = new ArrayList<>();
+ private final List allTargets = new ArrayList<>();
+ private final PeerAdapter adapter = new PeerAdapter(this::send);
+
+ private ActivityShareTargetBinding binding;
+ private VPNService.MyLocalBinder binder;
+ private boolean bound;
+ private String sharedText;
+
+ // The manager notifies from its own executor, so updates are posted back.
+ private final FileDropManager.TransfersListener transfersListener =
+ transfers -> runOnUiThread(() -> onTransfers(transfers));
+
+ private final ServiceConnection connection = new ServiceConnection() {
+ @Override
+ public void onServiceConnected(ComponentName name, IBinder service) {
+ binder = (VPNService.MyLocalBinder) service;
+ loadPeers();
+ }
+
+ @Override
+ public void onServiceDisconnected(ComponentName name) {
+ binder = null;
+ }
+ };
+
+ @Override
+ protected void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ if (!readSharedContent(getIntent())) {
+ toastAndFinish(getString(R.string.file_drop_share_nothing));
+ return;
+ }
+
+ binding = ActivityShareTargetBinding.inflate(getLayoutInflater());
+ setContentView(binding.getRoot());
+ applySystemBarInsets();
+
+ describeShared();
+ binding.peerList.setLayoutManager(new LinearLayoutManager(this));
+ binding.peerList.setAdapter(adapter);
+ binding.btnCancelDialog.setOnClickListener(v -> finish());
+
+ binding.searchView.addTextChangedListener(new TextWatcher() {
+ @Override public void beforeTextChanged(CharSequence s, int a, int b, int c) {}
+ @Override public void onTextChanged(CharSequence s, int a, int b, int c) {
+ showMatching(s.toString().trim().toLowerCase(Locale.getDefault()));
+ }
+ @Override public void afterTextChanged(Editable s) {}
+ });
+
+ // Binds without BIND_AUTO_CREATE on purpose: starting the VPN service
+ // from a share would ask for the VPN permission out of nowhere. If the
+ // tunnel is down there is nothing to send over anyway.
+ Intent bindIntent = new Intent(this, VPNService.class);
+ bindIntent.setAction(VPNService.INTENT_ACTION_START);
+ bound = bindService(bindIntent, connection, 0);
+ if (!bound) {
+ toastAndFinish(getString(R.string.file_drop_share_not_running));
+ }
+ }
+
+ @Override
+ protected void onDestroy() {
+ super.onDestroy();
+ if (bound) {
+ unbindService(connection);
+ bound = false;
+ }
+ FileDropManager.get().removeTransfersListener(transfersListener);
+ // shutdown, never shutdownNow: an in-flight copy has to run to
+ // completion or the transfer it feeds would start on a truncated file.
+ executor.shutdown();
+ binding = null;
+ }
+
+ /**
+ * Keeps the header clear of the status bar and the last row clear of the
+ * navigation bar. Mirrors MainActivity: the root takes the top inset, the
+ * bottom one becomes list padding so the rows can still scroll under the
+ * navigation bar instead of stopping short of it.
+ */
+ private void applySystemBarInsets() {
+ int listBottom = getResources().getDimensionPixelSize(R.dimen.share_list_bottom_padding);
+ ViewCompat.setOnApplyWindowInsetsListener(binding.getRoot(), (view, windowInsets) -> {
+ Insets bars = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars());
+ view.setPadding(bars.left, bars.top, bars.right, 0);
+ binding.peerList.setPadding(0, 0, 0, listBottom + bars.bottom);
+ return windowInsets;
+ });
+ }
+
+ private boolean readSharedContent(Intent intent) {
+ String action = intent.getAction();
+
+ if (Intent.ACTION_SEND.equals(action)) {
+ Uri uri = intent.getParcelableExtra(Intent.EXTRA_STREAM);
+ if (uri != null) {
+ shared.add(uri);
+ } else {
+ sharedText = intent.getStringExtra(Intent.EXTRA_TEXT);
+ }
+ } else if (Intent.ACTION_SEND_MULTIPLE.equals(action)) {
+ ArrayList uris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
+ if (uris != null) {
+ shared.addAll(uris);
+ }
+ }
+
+ return !shared.isEmpty() || (sharedText != null && !sharedText.isEmpty());
+ }
+
+ /**
+ * Names what is about to be sent, so the screen is not just a peer list.
+ * Reading the metadata queries the content provider, so it happens off the
+ * UI thread and fills in the header when it lands.
+ */
+ private void describeShared() {
+ if (shared.isEmpty()) {
+ binding.shareTitle.setText(R.string.file_drop_share_text_title);
+ binding.shareSubtitle.setText(sharedText);
+ return;
+ }
+
+ // A count stands in until the providers answer; the name and type
+ // replace it once the metadata lands.
+ binding.shareTitle.setText(getResources().getQuantityString(
+ R.plurals.file_drop_share_items, shared.size(), shared.size()));
+ binding.shareSubtitle.setText("");
+
+ Context app = getApplicationContext();
+ executor.execute(() -> {
+ List details = new ArrayList<>();
+ for (Uri uri : shared) {
+ ContentFileSource.Details d = ContentFileSource.describe(app, uri);
+ if (d != null) {
+ details.add(d);
+ }
+ }
+
+ runOnUiThread(() -> {
+ if (binding == null || details.isEmpty()) {
+ return;
+ }
+ binding.shareTitle.setText(titleFor(details));
+ binding.shareSubtitle.setText(describeAll(details));
+ });
+ });
+ }
+
+ /** One file reads as its name; several read as a count. */
+ private String titleFor(List details) {
+ if (details.size() == 1) {
+ return details.get(0).name();
+ }
+ return getResources().getQuantityString(R.plurals.file_drop_share_items,
+ details.size(), details.size());
+ }
+
+ /**
+ * Size and kind for a single file; for several, the names on one line and
+ * the combined size after them.
+ */
+ private String describeAll(List details) {
+ if (details.size() == 1) {
+ return describeMeta(details.get(0));
+ }
+
+ StringBuilder names = new StringBuilder();
+ long total = 0;
+ boolean sizeKnown = true;
+
+ for (ContentFileSource.Details d : details) {
+ if (names.length() > 0) {
+ names.append(", ");
+ }
+ names.append(d.name());
+
+ if (d.size() >= 0) {
+ total += d.size();
+ } else {
+ sizeKnown = false;
+ }
+ }
+
+ if (sizeKnown) {
+ names.append(" · ").append(formatSize(total));
+ }
+ return names.toString();
+ }
+
+ private String describeMeta(ContentFileSource.Details d) {
+ String kind = kindLabel(d.contentType());
+ if (d.size() < 0) {
+ return kind;
+ }
+ String size = formatSize(d.size());
+ return kind.isEmpty() ? size : size + " · " + kind;
+ }
+
+ /**
+ * Turns a MIME type into something readable: the subtype uppercased, with
+ * the general class after it, so "image/jpeg" reads as "JPEG image".
+ */
+ private String kindLabel(String contentType) {
+ if (contentType == null || contentType.isEmpty()) {
+ return "";
+ }
+
+ int slash = contentType.indexOf('/');
+ if (slash < 0) {
+ return contentType;
+ }
+
+ String general = contentType.substring(0, slash);
+ String specific = contentType.substring(slash + 1);
+ if (specific.isEmpty() || "*".equals(specific)) {
+ return general;
+ }
+ // "svg+xml" reads better as "SVG".
+ int plus = specific.indexOf('+');
+ if (plus > 0) {
+ specific = specific.substring(0, plus);
+ }
+
+ // Vendor subtypes ("vnd.android.package-archive") carry no meaning for
+ // the reader; the file extension in the name already says what it is.
+ if (specific.startsWith("vnd.") || specific.startsWith("x-")) {
+ return "application".equals(general) ? "" : general;
+ }
+
+ String label = specific.toUpperCase(Locale.ROOT);
+ if ("application".equals(general)) {
+ return label;
+ }
+ return label + " " + general;
+ }
+
+ private static String formatSize(long bytes) {
+ if (bytes < 1024) {
+ return bytes + " B";
+ }
+ String[] units = {"KB", "MB", "GB", "TB"};
+ double size = bytes;
+ int unit = -1;
+ do {
+ size /= 1024;
+ unit++;
+ } while (size >= 1024 && unit < units.length - 1);
+
+ if (size >= 10) {
+ return String.format(Locale.getDefault(), "%.0f %s", size, units[unit]);
+ }
+ return String.format(Locale.getDefault(), "%.1f %s", size, units[unit]);
+ }
+
+ /**
+ * Reads the peer list off the UI thread: it is a JNI call that can take
+ * seconds while the engine is starting up or tearing down.
+ */
+ private void loadPeers() {
+ executor.execute(() -> {
+ PeerInfoArray peers = binder == null ? null : binder.peersInfo();
+
+ List targets = new ArrayList<>();
+ if (peers != null) {
+ for (int i = 0; i < peers.size(); i++) {
+ PeerInfo peer = peers.get(i);
+ // Deliberately not filtered on connection status: an idle
+ // peer is the normal resting state under lazy connections,
+ // and the transfer's own packets are what wake it. Only a
+ // peer without an overlay address has nothing to dial.
+ if (peer == null || peer.getIP().isEmpty()) {
+ continue;
+ }
+ targets.add(new PeerTarget(peer.getPubKey(), peer.getFQDN(), peer.getIP(),
+ Status.fromLong(peer.getConnStatus()) == Status.CONNECTED));
+ }
+ }
+
+ // Same order as the main peer list: connected peers first, then by
+ // name, so the same peer sits in the same place on both screens.
+ targets.sort((a, b) -> {
+ int byStatus = Boolean.compare(b.connected, a.connected);
+ return byStatus != 0 ? byStatus : a.name.compareToIgnoreCase(b.name);
+ });
+
+ runOnUiThread(() -> {
+ allTargets.clear();
+ allTargets.addAll(targets);
+ showMatching("");
+ });
+ });
+ }
+
+ private void showMatching(String query) {
+ if (binding == null) {
+ return;
+ }
+
+ List shown = new ArrayList<>();
+ for (PeerTarget target : allTargets) {
+ if (target.matches(query)) {
+ shown.add(target);
+ }
+ }
+
+ adapter.submit(shown);
+ binding.emptyView.setVisibility(shown.isEmpty() ? View.VISIBLE : View.GONE);
+ binding.emptyView.setText(allTargets.isEmpty()
+ ? R.string.file_drop_share_no_peers
+ : R.string.file_drop_share_no_matches);
+ }
+
+ /**
+ * Tap is the send: no confirm step, and the row itself carries the whole
+ * lifecycle. The screen stays put so the same file can be fanned out to
+ * several peers, each row tracking its own transfer.
+ */
+ private void send(PeerTarget target) {
+ if (target.state != SendState.IDLE) {
+ return;
+ }
+
+ target.state = SendState.WAITING;
+ adapter.refresh(target);
+ FileDropManager.get().addTransfersListener(transfersListener);
+
+ // Keeping the id is what ties this row to its own transfer: matching on
+ // the peer alone would latch onto an older, already finished send to the
+ // same peer and report it as this one's outcome.
+ FileDropManager.ValueCallback callback = new FileDropManager.ValueCallback<>() {
+ @Override
+ public void onValue(String transferId) {
+ runOnUiThread(() -> {
+ if (binding == null) {
+ return;
+ }
+ target.transferId = transferId;
+ });
+ }
+
+ @Override
+ public void onResult(boolean ok, String error) {
+ if (ok) {
+ return;
+ }
+ runOnUiThread(() -> {
+ if (binding == null) {
+ return;
+ }
+ target.state = SendState.FAILED;
+ target.detail = error;
+ adapter.refresh(target);
+ });
+ }
+ };
+
+ if (shared.isEmpty()) {
+ FileDropManager.get().sendText(sharedText, target.pubKey, target.name, target.ip,
+ callback);
+ return;
+ }
+
+ // Copying happens here, not lazily during the upload: the share grants
+ // read access only while this activity lives, and the upload outlives
+ // it. Uses the application context so a finished activity cannot take
+ // the copy down with it.
+ Context app = getApplicationContext();
+ executor.execute(() -> {
+ List sources = new ArrayList<>();
+ for (Uri uri : shared) {
+ ContentFileSource source = ContentFileSource.of(app, uri);
+ if (source == null) {
+ Log.w(LOGTAG, "skipping unreadable " + uri);
+ continue;
+ }
+ sources.add(source);
+ }
+
+ if (sources.isEmpty()) {
+ runOnUiThread(() -> {
+ if (binding == null) {
+ return;
+ }
+ target.state = SendState.FAILED;
+ target.detail = getString(R.string.file_drop_share_unreadable);
+ adapter.refresh(target);
+ });
+ return;
+ }
+ FileDropManager.get().send(sources, target.pubKey, target.name, target.ip, callback);
+ });
+ }
+
+ /**
+ * Maps live transfers onto the rows that started them. Matching is by peer
+ * key, so a row keeps following its own transfer while other rows run
+ * theirs.
+ */
+ private void onTransfers(List transfers) {
+ if (binding == null) {
+ return;
+ }
+
+ for (PeerTarget target : allTargets) {
+ if (target.state == SendState.IDLE || target.settled()) {
+ continue;
+ }
+
+ FileDropManager.Transfer mine = find(transfers, target.transferId);
+
+ SendState before = target.state;
+ String detailBefore = target.detail;
+
+ // A cancel can also come from elsewhere, the Files tab included.
+ // Either way the row goes back to being pickable rather than
+ // reporting a failure it did not have.
+ if (mine != null && mine.state() == Android.FileDropStateCancelled) {
+ reset(target);
+ continue;
+ }
+
+ if (mine != null && mine.isTerminal()) {
+ target.state = terminalStateOf(mine);
+ target.detail = null;
+ } else if (mine != null && mine.isRunning() && mine.totalSize() > 0) {
+ target.state = SendState.SENDING;
+ target.progress = (int) (mine.transferred() * 100 / mine.totalSize());
+ }
+
+ // A row in flight is redrawn on every update even when nothing about
+ // it changed: the item animator's cross-fade is what makes it pulse,
+ // which is the only sign of life a row has while it waits for the
+ // peer to answer and nothing is moving yet.
+ if (target.state != before || !java.util.Objects.equals(target.detail, detailBefore)
+ || target.state == SendState.SENDING || target.state == SendState.WAITING) {
+ adapter.refresh(target);
+ }
+ }
+ }
+
+ /** The transfer this row started, or null while its id is still unknown. */
+ @Nullable
+ private static FileDropManager.Transfer find(List transfers,
+ @Nullable String transferId) {
+ if (transferId == null) {
+ return null;
+ }
+ for (FileDropManager.Transfer transfer : transfers) {
+ if (transfer.id().equals(transferId)) {
+ return transfer;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Asks before killing a send in flight. Long press is easy enough to hit by
+ * accident on a list whose rows are meant to be tapped, and the bytes are
+ * already on their way, so the prompt is the safeguard.
+ */
+ @SuppressLint("InflateParams")
+ private void confirmStop(PeerTarget target) {
+ View view = LayoutInflater.from(this).inflate(R.layout.dialog_simple_edit_text, null);
+
+ ((TextView) view.findViewById(R.id.text_title_dialog)).setText(target.name);
+ ((TextView) view.findViewById(R.id.text_label_dialog))
+ .setText(R.string.file_drop_share_stop_confirm);
+ view.findViewById(R.id.edit_text_dialog).setVisibility(View.GONE);
+
+ AlertDialog dialog = new AlertDialog.Builder(this, R.style.AlertDialogTheme)
+ .setView(view)
+ .create();
+
+ MaterialButton confirm = view.findViewById(R.id.btn_ok_dialog);
+ confirm.setText(R.string.file_drop_share_stop);
+ confirm.setOnClickListener(v -> {
+ stop(target);
+ dialog.dismiss();
+ });
+ view.findViewById(R.id.btn_cancel_dialog).setOnClickListener(v -> dialog.dismiss());
+
+ dialog.show();
+ }
+
+ /**
+ * Aborts this row's transfer and hands the row back untouched, so the same
+ * files can be sent again to the same peer. Anything the transfer staged is
+ * released by the manager once Go reports it cancelled.
+ */
+ private void stop(PeerTarget target) {
+ if (target.transferId != null) {
+ FileDropManager.get().cancel(target.transferId);
+ }
+ reset(target);
+ }
+
+ /** Returns a row to its untouched, pickable state. */
+ private void reset(PeerTarget target) {
+ target.transferId = null;
+ target.state = SendState.IDLE;
+ target.progress = 0;
+ target.detail = null;
+ adapter.refresh(target);
+ }
+
+ private void toastAndFinish(String message) {
+ Toast.makeText(this, message, Toast.LENGTH_LONG).show();
+ finish();
+ }
+
+ /** Renders the pickable peers; each row shows its own send lifecycle. */
+ private final class PeerAdapter extends RecyclerView.Adapter {
+
+ private final List targets = new ArrayList<>();
+ private final Consumer onPick;
+
+ PeerAdapter(Consumer onPick) {
+ this.onPick = onPick;
+ }
+
+ void submit(List next) {
+ targets.clear();
+ targets.addAll(next);
+ notifyDataSetChanged();
+ }
+
+ /** Redraws one row in place, leaving the rest of the list untouched. */
+ void refresh(PeerTarget target) {
+ int index = targets.indexOf(target);
+ if (index >= 0) {
+ notifyItemChanged(index);
+ }
+ }
+
+ @NonNull
+ @Override
+ public Holder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ View view = LayoutInflater.from(parent.getContext())
+ .inflate(R.layout.list_item_peer_picker, parent, false);
+ return new Holder(view);
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull Holder holder, int position) {
+ PeerTarget target = targets.get(position);
+
+ holder.name.setText(target.name);
+ holder.subtitle.setText(subtitleFor(target));
+ holder.progress.setVisibility(View.GONE);
+
+ switch (target.state) {
+ case WAITING:
+ holder.state.setText(R.string.file_drop_share_state_waiting);
+ holder.state.setTextColor(color(R.color.nb_txt_light));
+ break;
+ case SENDING:
+ holder.state.setText(getString(R.string.file_drop_state_progress,
+ target.progress));
+ holder.state.setTextColor(color(R.color.nb_orange));
+ holder.progress.setVisibility(View.VISIBLE);
+ holder.progress.setProgress(target.progress);
+ break;
+ case SENT:
+ holder.state.setText(R.string.file_drop_share_state_sent);
+ holder.state.setTextColor(color(R.color.nb_latency_good));
+ break;
+ case DECLINED:
+ holder.state.setText(R.string.file_drop_state_declined);
+ holder.state.setTextColor(color(R.color.nb_danger));
+ break;
+ case EXPIRED:
+ holder.state.setText(R.string.file_drop_state_expired);
+ holder.state.setTextColor(color(R.color.nb_txt_light));
+ break;
+ case FAILED:
+ holder.state.setText(R.string.file_drop_state_failed);
+ holder.state.setTextColor(color(R.color.nb_danger));
+ break;
+ default:
+ // An untouched row says nothing: tapping to send is the
+ // only thing a row does, so a label would be noise.
+ holder.state.setText("");
+ break;
+ }
+
+ // Only an untouched row is tappable: a second tap would start a
+ // duplicate transfer to the same peer.
+ boolean tappable = target.state == SendState.IDLE;
+ holder.itemView.setOnClickListener(tappable ? v -> onPick.accept(target) : null);
+ holder.itemView.setClickable(tappable);
+
+ // Stopping is a long press rather than a button: tap already means
+ // send here, and a second meaning for it would put a 500 MB upload
+ // one stray finger away from being killed.
+ boolean stoppable = target.state == SendState.WAITING
+ || target.state == SendState.SENDING;
+ holder.itemView.setOnLongClickListener(stoppable ? v -> {
+ confirmStop(target);
+ return true;
+ } : null);
+ holder.itemView.setLongClickable(stoppable);
+ }
+
+ /** IP, plus the reason a row cannot be sent to, or why it stopped. */
+ private String subtitleFor(PeerTarget target) {
+ if (target.state == SendState.FAILED && target.detail != null) {
+ return target.detail;
+ }
+ // Idle is what a row looks like before it is picked. Once a send is
+ // under way the transfer's own packets have woken the peer, so the
+ // label would be stale, and the send state to the right says more.
+ if (target.connected || target.state != SendState.IDLE) {
+ return target.ip;
+ }
+ return target.ip + " · " + getString(R.string.peer_status_idle);
+ }
+
+ private int color(int res) {
+ return ContextCompat.getColor(ShareTargetActivity.this, res);
+ }
+
+ @Override
+ public int getItemCount() {
+ return targets.size();
+ }
+
+ final class Holder extends RecyclerView.ViewHolder {
+ final TextView name;
+ final TextView subtitle;
+ final TextView state;
+ final ProgressBar progress;
+
+ Holder(View view) {
+ super(view);
+ name = view.findViewById(R.id.peer_name);
+ subtitle = view.findViewById(R.id.peer_ip);
+ state = view.findViewById(R.id.peer_state);
+ progress = view.findViewById(R.id.peer_progress);
+ }
+ }
+ }
+
+ /**
+ * Maps a finished transfer onto the row state. Declined and expired are
+ * kept apart from a genuine failure: the first two are answers, the third
+ * means the transfer never got one.
+ */
+ private static SendState terminalStateOf(FileDropManager.Transfer transfer) {
+ long state = transfer.state();
+ if (state == Android.FileDropStateCompleted) {
+ return SendState.SENT;
+ }
+ if (state == Android.FileDropStateDeclined) {
+ return SendState.DECLINED;
+ }
+ if (state == Android.FileDropStateExpired) {
+ return SendState.EXPIRED;
+ }
+ return SendState.FAILED;
+ }
+
+ /** Where a row is in the send lifecycle; see send(). */
+ private enum SendState { IDLE, WAITING, SENDING, SENT, DECLINED, EXPIRED, FAILED }
+
+ private static final class PeerTarget {
+ final String pubKey;
+ final String name;
+ final String ip;
+ final boolean connected;
+
+ SendState state = SendState.IDLE;
+ int progress;
+ String detail;
+ // Set once the send call reports which transfer it started; see send().
+ String transferId;
+
+ /** Whether this row's transfer has reached an outcome. */
+ boolean settled() {
+ return state == SendState.SENT || state == SendState.DECLINED
+ || state == SendState.EXPIRED || state == SendState.FAILED;
+ }
+
+ PeerTarget(String pubKey, String name, String ip, boolean connected) {
+ this.pubKey = pubKey;
+ this.name = name;
+ this.ip = ip;
+ this.connected = connected;
+ }
+
+ boolean matches(String query) {
+ if (query.isEmpty()) {
+ return true;
+ }
+ Locale locale = Locale.getDefault();
+ return name.toLowerCase(locale).contains(query) || ip.contains(query);
+ }
+ }
+}
diff --git a/app/src/main/java/io/netbird/client/ui/home/PeersFragment.java b/app/src/main/java/io/netbird/client/ui/home/PeersFragment.java
index aaaf6b35..a34828ec 100644
--- a/app/src/main/java/io/netbird/client/ui/home/PeersFragment.java
+++ b/app/src/main/java/io/netbird/client/ui/home/PeersFragment.java
@@ -28,6 +28,7 @@
import io.netbird.client.ServiceAccessor;
import io.netbird.client.StateListenerRegistry;
import io.netbird.client.databinding.FragmentPeersBinding;
+import io.netbird.client.ui.SegmentedSwitch;
public class PeersFragment extends Fragment {
@@ -36,6 +37,10 @@ public class PeersFragment extends Fragment {
private StateListenerRegistry stateListenerRegistry;
private PeersFragmentViewModel model;
private final List peers = new ArrayList<>();
+ // Which half of the segmented control is showing. Peers and Resources share
+ // this screen because the bottom navigation is already at its five-item
+ // limit, and Files holds the slot Resources used to have.
+ private boolean showingResources;
private static final String ARG_IS_RUNNING_ON_TV = "isRunningOnTV";
@Override
@@ -102,11 +107,19 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat
updatePeersCounter(peers);
- ZeroPeerView.updateVisibility(binding.zeroPeerLayout, binding.peersList, !peers.isEmpty());
+ // The zero-peers view replaces the whole list container, so it must
+ // not claim the screen while the Resources view is the one showing.
+ ZeroPeerView.updateVisibility(binding.zeroPeerLayout, binding.peersList,
+ showingResources || !peers.isEmpty());
adapter.notifyDataSetChanged();
adapter.filterBySearchQuery(binding.searchView.getText().toString());
});
+ new SegmentedSwitch(view, R.id.toggle_peers_resources, R.id.segment_thumb,
+ R.id.btn_view_peers, R.id.label_view_peers,
+ R.id.btn_view_resources, R.id.label_view_resources,
+ this::showResources);
+
binding.searchView.clearFocus();
binding.searchView.addTextChangedListener(new TextWatcher() {
@Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
@@ -147,6 +160,26 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat
});
}
+ /** Swaps the peer list and its search controls for the resource list. */
+ private void showResources(boolean resources) {
+ if (binding == null) {
+ return;
+ }
+ showingResources = resources;
+
+ int listVisibility = resources ? View.GONE : View.VISIBLE;
+ binding.searchView.setVisibility(listVisibility);
+ binding.filterIcon.setVisibility(listVisibility);
+ binding.peersRecyclerView.setVisibility(listVisibility);
+ binding.resourcesContainer.setVisibility(resources ? View.VISIBLE : View.GONE);
+
+ if (resources) {
+ binding.searchView.clearFocus();
+ }
+ ZeroPeerView.updateVisibility(binding.zeroPeerLayout, binding.peersList,
+ resources || !peers.isEmpty());
+ }
+
@Override
public void onDetach() {
stateListenerRegistry = null;
diff --git a/app/src/main/java/io/netbird/client/ui/settings/SettingsFragment.java b/app/src/main/java/io/netbird/client/ui/settings/SettingsFragment.java
index 64ce0399..4ee787cd 100644
--- a/app/src/main/java/io/netbird/client/ui/settings/SettingsFragment.java
+++ b/app/src/main/java/io/netbird/client/ui/settings/SettingsFragment.java
@@ -49,6 +49,9 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat
// rather than keeping a second, differently-shaped screen for it.
binding.rowChangeServer.setOnClickListener(v -> showActiveProfileEditor());
+ binding.rowFileSharing.setOnClickListener(v ->
+ navController.navigate(R.id.nav_file_sharing));
+
binding.rowAdvanced.setOnClickListener(v ->
navController.navigate(R.id.nav_advanced));
diff --git a/app/src/main/java/io/netbird/client/ui/ssh/SshSessionsFragment.java b/app/src/main/java/io/netbird/client/ui/ssh/SshSessionsFragment.java
index 4b574782..b71a892a 100644
--- a/app/src/main/java/io/netbird/client/ui/ssh/SshSessionsFragment.java
+++ b/app/src/main/java/io/netbird/client/ui/ssh/SshSessionsFragment.java
@@ -15,7 +15,7 @@
import androidx.appcompat.widget.PopupMenu;
import androidx.fragment.app.Fragment;
import androidx.navigation.NavController;
-import androidx.navigation.fragment.NavHostFragment;
+import androidx.navigation.Navigation;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
@@ -248,7 +248,11 @@ private void reconnectAndOpen(String sessionId) {
}
private void openTerminal(String sessionId) {
- NavController nav = NavHostFragment.findNavController(SshSessionsFragment.this);
+ // Activity-scoped rather than NavHostFragment.findNavController: this
+ // list is hosted inside the Apps screen, so it is not a direct child
+ // of the nav host and the fragment-local lookup would fail.
+ NavController nav = Navigation.findNavController(requireActivity(),
+ R.id.nav_host_fragment_content_main);
Bundle args = new Bundle();
args.putString(SSHTerminalFragment.ARG_SESSION_ID, sessionId);
nav.navigate(R.id.nav_ssh_terminal, args);
diff --git a/app/src/main/res/drawable/bg_file_drop_offer.xml b/app/src/main/res/drawable/bg_file_drop_offer.xml
new file mode 100644
index 00000000..cb2982d1
--- /dev/null
+++ b/app/src/main/res/drawable/bg_file_drop_offer.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_menu_files.xml b/app/src/main/res/drawable/ic_menu_files.xml
new file mode 100644
index 00000000..54ffb795
--- /dev/null
+++ b/app/src/main/res/drawable/ic_menu_files.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_nav_apps.xml b/app/src/main/res/drawable/ic_nav_apps.xml
new file mode 100644
index 00000000..a64d2db7
--- /dev/null
+++ b/app/src/main/res/drawable/ic_nav_apps.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/layout/activity_share_target.xml b/app/src/main/res/layout/activity_share_target.xml
new file mode 100644
index 00000000..0151e5be
--- /dev/null
+++ b/app/src/main/res/layout/activity_share_target.xml
@@ -0,0 +1,124 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_apps.xml b/app/src/main/res/layout/fragment_apps.xml
new file mode 100644
index 00000000..802fd50c
--- /dev/null
+++ b/app/src/main/res/layout/fragment_apps.xml
@@ -0,0 +1,88 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_file_drop.xml b/app/src/main/res/layout/fragment_file_drop.xml
new file mode 100644
index 00000000..ba5cdde2
--- /dev/null
+++ b/app/src/main/res/layout/fragment_file_drop.xml
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_file_sharing.xml b/app/src/main/res/layout/fragment_file_sharing.xml
new file mode 100644
index 00000000..5449f296
--- /dev/null
+++ b/app/src/main/res/layout/fragment_file_sharing.xml
@@ -0,0 +1,121 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_peers.xml b/app/src/main/res/layout/fragment_peers.xml
index a576ffec..3c9900fd 100644
--- a/app/src/main/res/layout/fragment_peers.xml
+++ b/app/src/main/res/layout/fragment_peers.xml
@@ -42,11 +42,80 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -93,6 +162,18 @@
android:dividerHeight="20dp"
tools:listitem="@layout/list_item_peer"/>
+
+
diff --git a/app/src/main/res/layout/fragment_settings.xml b/app/src/main/res/layout/fragment_settings.xml
index c5fb59f7..0e3a1c9f 100644
--- a/app/src/main/res/layout/fragment_settings.xml
+++ b/app/src/main/res/layout/fragment_settings.xml
@@ -113,6 +113,45 @@
style="@style/SettingsSectionHeader"
android:text="@string/settings_section_settings" />
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/list_item_file_offer.xml b/app/src/main/res/layout/list_item_file_offer.xml
new file mode 100644
index 00000000..51692d00
--- /dev/null
+++ b/app/src/main/res/layout/list_item_file_offer.xml
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/list_item_file_transfer.xml b/app/src/main/res/layout/list_item_file_transfer.xml
new file mode 100644
index 00000000..afd6d125
--- /dev/null
+++ b/app/src/main/res/layout/list_item_file_transfer.xml
@@ -0,0 +1,97 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/list_item_peer_picker.xml b/app/src/main/res/layout/list_item_peer_picker.xml
new file mode 100644
index 00000000..840c13e5
--- /dev/null
+++ b/app/src/main/res/layout/list_item_peer_picker.xml
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/menu/bottom_nav.xml b/app/src/main/res/menu/bottom_nav.xml
index 7bd3977a..ead9b41d 100644
--- a/app/src/main/res/menu/bottom_nav.xml
+++ b/app/src/main/res/menu/bottom_nav.xml
@@ -12,14 +12,10 @@
android:title="@string/bottom_nav_peers" />
+ android:id="@+id/nav_apps"
+ android:icon="@drawable/ic_nav_apps"
+ android:title="@string/bottom_nav_apps" />
-
+
+
+
+
Start
Peers
Ressourcen
+ Dateien
SSH
+ Apps
Einstellungen
Verbindung
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index 3353a819..991f1c04 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -16,7 +16,9 @@
Inicio
Peers
Recursos
+ Archivos
SSH
+ Apps
Configuración
Conexión
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index af9b2046..b595447b 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -16,7 +16,9 @@
Accueil
Pairs
Ressources
+ Fichiers
SSH
+ Applis
Paramètres
Connexion
diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml
index c6be16ed..7ac606c7 100644
--- a/app/src/main/res/values-hu/strings.xml
+++ b/app/src/main/res/values-hu/strings.xml
@@ -16,7 +16,9 @@
Kezdőlap
Peerek
Erőforrások
+ Fájlok
SSH
+ Appok
Beállítások
Kapcsolat
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
index 4ed223e3..71c4df63 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -16,7 +16,9 @@
Home
Peer
Risorse
+ File
SSH
+ App
Impostazioni
Connessione
diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml
index f82e45ee..482be5e4 100644
--- a/app/src/main/res/values-ja/strings.xml
+++ b/app/src/main/res/values-ja/strings.xml
@@ -16,7 +16,9 @@
ホーム
ピア
リソース
+ ファイル
SSH
+ アプリ
設定
接続
diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml
index 96528fe5..1f4761ce 100644
--- a/app/src/main/res/values-pt/strings.xml
+++ b/app/src/main/res/values-pt/strings.xml
@@ -16,7 +16,9 @@
Início
Peers
Recursos
+ Arquivos
SSH
+ Apps
Configurações
Conexão
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index 7229d7a3..8b55ad38 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -16,7 +16,9 @@
Главная
Пиры
Ресурсы
+ Файлы
SSH
+ Приложения
Настройки
Подключение
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index 5e64b8de..490543c9 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -16,7 +16,9 @@
主页
对等节点
资源
+ 文件
SSH
+ 应用
设置
连接
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
index b91d9e41..38e1b0e3 100644
--- a/app/src/main/res/values/dimens.xml
+++ b/app/src/main/res/values/dimens.xml
@@ -11,6 +11,9 @@
88dp
+
+ 16dp
8dp
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 6f0c01d7..f33907bc 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -17,6 +17,7 @@
Peers
Resources
SSH
+ Apps
Settings
Connection
@@ -296,4 +297,68 @@
- Session expires in %1$d day
- Session expires in %1$d days
+
+ Files
+ Send to
+ Nothing to send
+ NetBird is not running
+ No peers to send to
+ No matching peers
+ The shared files could not be read
+ Text snippet
+ Waiting…
+ ✓ Sent
+ Sending to %1$s
+ Stop sending to this peer?
+ Stop
+ Could not send: %1$s
+ Files
+ No transfers yet
+ No matching transfers
+ Search by file or peer
+ Remove this transfer from the list?
+ Stop this transfer? It stays in the list so you can remove it afterwards.
+ Stop
+ Receiving files
+ Off
+ Ask every time
+ Accept automatically
+ Waiting
+ Transferring
+ Completed
+ Declined
+ No response
+ Cancelled
+ Failed
+ Declined
+ to %1$s
+ from %1$s
+ Sent
+ Received
+ Sending %1$d%%
+ Receiving %1$d%%
+ Today
+ Yesterday
+ Earlier
+ %1$s · %2$s
+ %1$s wants to send this
+ “%1$s”
+ Copy
+ Copied
+
+ File sharing
+ File receiving
+ How this device handles incoming file offers.
+ Received files
+ Save to
+ Files land in the app\'s own storage; open one to move or share it.
+ Accept
+ Decline
+ Cancel
+ Remove
+ Open
+
+ - %1$d file
+ - %1$d files
+
diff --git a/netbird b/netbird
index a83a07da..89853938 160000
--- a/netbird
+++ b/netbird
@@ -1 +1 @@
-Subproject commit a83a07da9411622135c6ec8761345ff5961f7068
+Subproject commit 898539381c23dcf77625cd340d84232a1b4419a3
diff --git a/tool/src/main/AndroidManifest.xml b/tool/src/main/AndroidManifest.xml
index ba1f7dd6..7eb1bf27 100644
--- a/tool/src/main/AndroidManifest.xml
+++ b/tool/src/main/AndroidManifest.xml
@@ -27,6 +27,10 @@
android:value="vpn" />