diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d3ade888..99254e16 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -46,6 +46,23 @@ + + + + + + + + + + + + + notificationPermissionLauncher; private AppBarConfiguration mAppBarConfiguration; private ActivityMainBinding binding; @@ -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 @@ -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 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" /> + + fileDropNotifiedStates = new ConcurrentHashMap<>(); + // Guards the first transfer list, which is the persisted history rather + // than anything that just happened. See onFileDropTransfers. + private final AtomicBoolean fileDropHistorySeeded = new AtomicBoolean(); private TUNParameters currentTUNParameters; private NetworkChangeNotifier notifier; @@ -99,6 +118,27 @@ public void onCreate() { engineRunner.addServiceStateListener(serviceStateListener); + // File drop is wired to the service rather than to an activity so an + // incoming offer still raises its consent prompt, and the answer still + // reaches Go, with no UI bound. + fileDropNotification = new FileDropNotification(this); + fileDropHandleFactory = () -> { + try { + return engineRunner.fileDrop(); + } catch (Exception e) { + Log.e(LOGTAG, "failed to open file drop", e); + return null; + } + }; + FileDropManager.get().setHandleFactory(fileDropHandleFactory); + FileDropManager.get().setOfferListener(fileDropNotification::showOffer); + fileDropTransfersListener = this::onFileDropTransfers; + FileDropManager.get().addTransfersListener(fileDropTransfersListener); + // A process killed mid-transfer leaves staged copies on both sides + // behind; nothing else will ever claim them. + ContentFileSource.clearStaging(this); + new MediaStoreFileDropSink(this).clearPartials(); + // Create network availability listener after the engine runner so we // can gate notifications on the engine actually being up; this avoids // acting on Android's initial onAvailable burst during cold start. @@ -195,6 +235,16 @@ public void onDestroy() { } } + if (fileDropTransfersListener != null) { + FileDropManager.get().removeTransfersListener(fileDropTransfersListener); + fileDropTransfersListener = null; + } + if (fileDropHandleFactory != null) { + FileDropManager.get().setOfferListener(null); + FileDropManager.get().clearHandleFactory(fileDropHandleFactory); + fileDropHandleFactory = null; + } + networkAvailabilityListener.unsubscribe(); networkChangeDetector.unsubscribe(); networkChangeDetector.unregisterNetworkCallback(); @@ -212,6 +262,47 @@ public void onDestroy() { } } + /** + * Mirrors running and finished transfers into notifications. Offers are left + * to the offer listener, which posts the consent prompt with its actions. + */ + private void onFileDropTransfers(List transfers) { + // The first non-empty list to arrive is the stored history, every entry + // of which finished long ago. Recording it without notifying is what + // keeps a restart from replaying an outcome for each past transfer. + // Empty lists are skipped: registering a listener replays the manager's + // current list, which is empty until the first read from Go lands. + boolean seeding = !transfers.isEmpty() + && fileDropHistorySeeded.compareAndSet(false, true); + + for (FileDropManager.Transfer transfer : transfers) { + if (!transfer.isRunning() && !transfer.isTerminal()) { + continue; + } + + Long last = fileDropNotifiedStates.put(transfer.id(), transfer.state()); + + if (transfer.isRunning()) { + // Shown even while seeding: a transfer still moving when the + // app restarted deserves its bar back. Re-posted on every + // update, with setOnlyAlertOnce keeping it quiet, because the + // bar would otherwise sit frozen at its last value. + fileDropNotification.showProgress(transfer); + } else if (!seeding && (last == null || last.longValue() != transfer.state())) { + fileDropNotification.showOutcome(transfer); + } + } + + fileDropNotifiedStates.keySet().removeIf(id -> { + for (FileDropManager.Transfer t : transfers) { + if (t.id().equals(id)) { + return false; + } + } + return true; + }); + } + @Override public void onRevoke() { Log.d(LOGTAG, "VPN permission on revoke"); @@ -338,6 +429,16 @@ public SSHClient newSSHClient() { } return engineRunner.newSSHClient(); } + + /** + * File drop handle of the active profile. Deliberately not gated on the + * engine: the transfer history and the receiving policy are readable + * while disconnected, and a send attempt reports the missing tunnel + * itself. + */ + public FileDrop fileDrop() throws Exception { + return engineRunner.fileDrop(); + } } public static boolean isUsingAlwaysOnVPN(Context context) { diff --git a/tool/src/main/java/io/netbird/client/tool/files/ContentFileSource.java b/tool/src/main/java/io/netbird/client/tool/files/ContentFileSource.java new file mode 100644 index 00000000..74d707bb --- /dev/null +++ b/tool/src/main/java/io/netbird/client/tool/files/ContentFileSource.java @@ -0,0 +1,283 @@ +package io.netbird.client.tool.files; + +import android.content.ContentResolver; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.provider.OpenableColumns; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.UUID; + +import io.netbird.gomobile.android.FileSource; +import io.netbird.gomobile.android.SourceStream; + +/** + * Feeds one shared file to the Go sender, which pulls bytes rather than being + * handed a path: Android hands out Uris the Go layer cannot open itself. + *

+ * The content is copied into the app's own cache first. A share grants read + * access only for the lifetime of the receiving activity, while an upload + * outlives it by design — reading the Uri lazily would fail with a permission + * denial as soon as the share screen closed. The copy is deleted once the + * transfer stops reading it. + */ +public class ContentFileSource implements FileSource { + + private static final String LOGTAG = "ContentFileSource"; + private static final String STAGING_DIR = "filedrop-outgoing"; + // Caps one JNI hop; the Go side asks for whatever its own buffer holds. + private static final int CHUNK_LIMIT = 256 * 1024; + // Providers are allowed to omit SIZE; -1 marks "the provider did not say". + private static final long UNKNOWN_SIZE = -1; + private static final byte[] EMPTY = new byte[0]; + + private final File staged; + private final String name; + private final long size; + private final String contentType; + + private ContentFileSource(File staged, String name, long size, String contentType) { + this.staged = staged; + this.name = name; + this.size = size; + this.contentType = contentType; + } + + /** + * Copies a shared Uri into app storage and wraps it. Returns null when the + * Uri cannot be read, which is the normal outcome for a revoked or stale + * share grant. Must be called while the grant is still live, so on the + * receiving activity's own thread of work rather than after it finishes. + */ + @Nullable + public static ContentFileSource of(@NonNull Context context, @NonNull Uri uri) { + Context app = context.getApplicationContext(); + ContentResolver resolver = app.getContentResolver(); + + String name = displayName(resolver, uri); + if (name == null) { + Log.w(LOGTAG, "no display name for " + uri); + return null; + } + + File staged = stage(app, resolver, uri); + if (staged == null) { + return null; + } + + String contentType = resolver.getType(uri); + return new ContentFileSource(staged, name, staged.length(), + contentType == null ? "" : contentType); + } + + /** + * Name, size and MIME type of a shared Uri, read without copying anything. + * Used to describe what is about to be sent before a target is picked. + * Returns null when the Uri cannot be resolved. + */ + @Nullable + public static Details describe(@NonNull Context context, @NonNull Uri uri) { + ContentResolver resolver = context.getApplicationContext().getContentResolver(); + + String name = null; + long size = UNKNOWN_SIZE; + try (Cursor cursor = resolver.query(uri, null, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + int nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); + if (nameIndex >= 0 && !cursor.isNull(nameIndex)) { + name = cursor.getString(nameIndex); + } + int sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE); + if (sizeIndex >= 0 && !cursor.isNull(sizeIndex)) { + size = cursor.getLong(sizeIndex); + } + } + } catch (Exception e) { + Log.w(LOGTAG, "cannot query " + uri, e); + } + + if (name == null) { + name = uri.getLastPathSegment(); + } + if (name == null) { + return null; + } + + String type = resolver.getType(uri); + return new Details(name, size, type == null ? "" : type); + } + + /** What a shared Uri is, before it is staged. */ + public static final class Details { + private final String name; + private final long size; + private final String contentType; + + Details(String name, long size, String contentType) { + this.name = name; + this.size = size; + this.contentType = contentType; + } + + public String name() { return name; } + + /** Byte count, or -1 when the provider does not report one. */ + public long size() { return size; } + + public String contentType() { return contentType; } + } + + /** Removes every staged copy left behind by a killed process. */ + public static void clearStaging(@NonNull Context context) { + File dir = stagingDir(context.getApplicationContext()); + File[] files = dir.listFiles(); + if (files == null) { + return; + } + for (File f : files) { + if (!f.delete()) { + Log.w(LOGTAG, "cannot delete stale staging file " + f); + } + } + } + + public String name() { + return name; + } + + public long size() { + return size; + } + + public String contentType() { + return contentType; + } + + @Override + public SourceStream open(long offset) throws Exception { + InputStream stream = new FileInputStream(staged); + try { + skipFully(stream, offset); + } catch (Exception e) { + stream.close(); + throw e; + } + return new StagedStream(stream); + } + + /** Drops the staged copy; call once the transfer no longer needs it. */ + public void release() { + if (staged.exists() && !staged.delete()) { + Log.w(LOGTAG, "cannot delete staging file " + staged); + } + } + + @Nullable + private static String displayName(ContentResolver resolver, Uri uri) { + String name = null; + try (Cursor cursor = resolver.query(uri, null, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + int nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); + if (nameIndex >= 0 && !cursor.isNull(nameIndex)) { + name = cursor.getString(nameIndex); + } + } + } catch (Exception e) { + Log.w(LOGTAG, "cannot query " + uri, e); + } + return name != null ? name : uri.getLastPathSegment(); + } + + @Nullable + private static File stage(Context app, ContentResolver resolver, Uri uri) { + File dir = stagingDir(app); + if (!dir.exists() && !dir.mkdirs()) { + Log.w(LOGTAG, "cannot create staging dir " + dir); + return null; + } + + File target = new File(dir, UUID.randomUUID().toString()); + try (InputStream in = resolver.openInputStream(uri); + FileOutputStream out = new FileOutputStream(target)) { + if (in == null) { + throw new IOException("cannot open " + uri); + } + byte[] buffer = new byte[64 * 1024]; + int read; + while ((read = in.read(buffer)) >= 0) { + out.write(buffer, 0, read); + } + } catch (Exception e) { + Log.w(LOGTAG, "cannot stage " + uri, e); + if (target.exists() && !target.delete()) { + Log.w(LOGTAG, "cannot delete partial staging file " + target); + } + return null; + } + return target; + } + + private static File stagingDir(Context app) { + return new File(app.getCacheDir(), STAGING_DIR); + } + + private static void skipFully(InputStream stream, long offset) throws IOException { + long remaining = offset; + while (remaining > 0) { + long skipped = stream.skip(remaining); + if (skipped > 0) { + remaining -= skipped; + continue; + } + if (stream.read() < 0) { + throw new IOException("stream ended " + remaining + " bytes before the requested offset"); + } + remaining--; + } + } + + /** + * Adapts InputStream to the Go-facing stream. Bytes travel as the return + * value because that is the only direction gomobile copies them in; an + * empty array marks end of stream. + */ + private static final class StagedStream implements SourceStream { + + private final InputStream stream; + + StagedStream(InputStream stream) { + this.stream = stream; + } + + @Override + public byte[] nextChunk(long max) throws Exception { + int size = (int) Math.min(Math.max(max, 1), CHUNK_LIMIT); + byte[] buffer = new byte[size]; + + int read = stream.read(buffer); + if (read <= 0) { + return EMPTY; + } + if (read == size) { + return buffer; + } + byte[] exact = new byte[read]; + System.arraycopy(buffer, 0, exact, 0, read); + return exact; + } + + @Override + public void close() throws Exception { + stream.close(); + } + } +} diff --git a/tool/src/main/java/io/netbird/client/tool/files/FileDropActionReceiver.java b/tool/src/main/java/io/netbird/client/tool/files/FileDropActionReceiver.java new file mode 100644 index 00000000..dd1b408c --- /dev/null +++ b/tool/src/main/java/io/netbird/client/tool/files/FileDropActionReceiver.java @@ -0,0 +1,48 @@ +package io.netbird.client.tool.files; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +/** + * Answers a transfer offer straight from its notification. The decision goes to + * the Go layer through {@link FileDropManager}, which is process-wide, so this + * works whether or not an activity is alive. + */ +public class FileDropActionReceiver extends BroadcastReceiver { + + private static final String LOGTAG = "FileDropActionReceiver"; + + public static final String ACTION_ACCEPT = "io.netbird.client.action.FILE_DROP_ACCEPT"; + public static final String ACTION_DECLINE = "io.netbird.client.action.FILE_DROP_DECLINE"; + public static final String EXTRA_TRANSFER_ID = "transferId"; + + @Override + public void onReceive(Context context, Intent intent) { + String transferId = intent.getStringExtra(EXTRA_TRANSFER_ID); + if (transferId == null || transferId.isEmpty()) { + Log.w(LOGTAG, "no transfer id in " + intent.getAction()); + return; + } + + new FileDropNotification(context).cancelOffer(transferId); + + String action = intent.getAction(); + if (ACTION_ACCEPT.equals(action)) { + FileDropManager.get().accept(transferId, (ok, error) -> { + if (!ok) { + Log.w(LOGTAG, "failed to accept " + transferId + ": " + error); + } + }); + } else if (ACTION_DECLINE.equals(action)) { + FileDropManager.get().decline(transferId, (ok, error) -> { + if (!ok) { + Log.w(LOGTAG, "failed to decline " + transferId + ": " + error); + } + }); + } else { + Log.w(LOGTAG, "unexpected action " + action); + } + } +} diff --git a/tool/src/main/java/io/netbird/client/tool/files/FileDropManager.java b/tool/src/main/java/io/netbird/client/tool/files/FileDropManager.java new file mode 100644 index 00000000..39e640a7 --- /dev/null +++ b/tool/src/main/java/io/netbird/client/tool/files/FileDropManager.java @@ -0,0 +1,527 @@ +package io.netbird.client.tool.files; + +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import io.netbird.gomobile.android.Android; +import io.netbird.gomobile.android.FileDrop; +import io.netbird.gomobile.android.FileDropListener; +import io.netbird.gomobile.android.FileDropPayloads; +import io.netbird.gomobile.android.FileDropTransfer; +import io.netbird.gomobile.android.FileDropTransferArray; + +/** + * Application-scoped view of the file drop state, outliving any single screen. + * Policy and history live in Go, keyed by profile; this only mirrors them out to + * {@link TransfersListener}s and keeps every Go call off the UI thread. + *

+ * Every Go call here can block for seconds during engine bootstrap or teardown, + * the same hazard the peer list has, so all of them run on a single background + * executor and callbacks arrive on that thread rather than the caller's. + */ +public class FileDropManager { + + private static final String LOGTAG = "FileDropManager"; + + private static final FileDropManager INSTANCE = new FileDropManager(); + + /** + * How often a live transfer is re-read on top of the events Go sends. Go + * reports progress as an event of its own, so this is only the safety net + * for an update that never arrives — a listener lost across a profile + * switch, say — and can stay well below the desktop's one second poll. + */ + private static final long POLL_INTERVAL_MS = 3000; + + /** Supplies the file drop handle, which only the bound service can open. */ + public interface HandleFactory { + @Nullable + FileDrop fileDrop(); + } + + /** Notified for offers that need the user's consent. */ + public interface OfferListener { + void onOffer(Transfer transfer); + } + + /** Notified whenever the transfer list changes. Called off the UI thread. */ + public interface TransfersListener { + void onTransfers(List transfers); + } + + /** Immutable snapshot of one transfer, safe to hand to adapters. */ + public static final class Transfer { + private final String id; + private final boolean outgoing; + private final String peerKey; + private final String peerName; + private final long state; + private final long transferred; + private final long totalSize; + private final String error; + private final long reason; + private final long createdAtMillis; + private final boolean isText; + private final String text; + private final List fileNames; + private final List deliveredPaths; + + Transfer(FileDropTransfer t) { + id = t.getID(); + outgoing = t.getOutgoing(); + peerKey = t.getPeerKey(); + peerName = t.getPeerName(); + state = t.getState(); + transferred = t.getTransferred(); + totalSize = t.getTotalSize(); + error = t.getError(); + reason = t.getReason(); + createdAtMillis = t.getCreatedAtMillis(); + isText = t.getIsText(); + text = isText && t.fileCount() > 0 ? t.getFile(0).getText() : ""; + + List names = new ArrayList<>(); + for (long i = 0; i < t.fileCount(); i++) { + names.add(t.getFile(i).getName()); + } + fileNames = Collections.unmodifiableList(names); + + String delivered = t.deliveredPaths(); + deliveredPaths = delivered.isEmpty() + ? Collections.emptyList() + : Collections.unmodifiableList(new ArrayList<>(List.of(delivered.split("\n")))); + } + + public String id() { return id; } + public boolean outgoing() { return outgoing; } + public String peerKey() { return peerKey; } + public String peerName() { return peerName; } + public long state() { return state; } + public long transferred() { return transferred; } + public long totalSize() { return totalSize; } + public String error() { return error; } + public long createdAtMillis() { return createdAtMillis; } + public boolean isText() { return isText; } + public String text() { return text; } + public List fileNames() { return fileNames; } + public List deliveredPaths() { return deliveredPaths; } + + public boolean isPending() { + return state == Android.FileDropStatePending; + } + + public boolean isUnreachable() { + return reason == Android.FileDropReasonUnreachable; + } + + /** Whether the transfer ended in anything other than success. */ + public boolean isFailed() { + return state == Android.FileDropStateFailed + || state == Android.FileDropStateDeclined + || state == Android.FileDropStateExpired; + } + + /** Whether bytes are moving right now. */ + public boolean isRunning() { + return state == Android.FileDropStateTransferring; + } + + /** Whether the transfer has stopped for good, in any outcome. */ + public boolean isTerminal() { + return state == Android.FileDropStateCompleted + || state == Android.FileDropStateDeclined + || state == Android.FileDropStateExpired + || state == Android.FileDropStateCancelled + || state == Android.FileDropStateFailed; + } + + public String label() { + if (fileNames.size() == 1) { + return fileNames.get(0); + } + return fileNames.size() + " files"; + } + } + + public static FileDropManager get() { + return INSTANCE; + } + + private final Set transfersListeners = ConcurrentHashMap.newKeySet(); + // Staged copies of outgoing files, keyed by transfer id; see send(). + private final Map> staged = new ConcurrentHashMap<>(); + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + // Backs up the event stream while a transfer is live; see POLL_INTERVAL_MS + // and scheduleNextPoll. + private final ScheduledExecutorService poller = Executors.newSingleThreadScheduledExecutor(); + private final AtomicBoolean pollPending = new AtomicBoolean(); + + private volatile List transfers = Collections.emptyList(); + private HandleFactory handleFactory; + private OfferListener offerListener; + private String listeningProfileId; + + private final FileDropListener goListener = (kind, transfer) -> { + Transfer snapshot = new Transfer(transfer); + if (kind == Android.FileDropEventOffer) { + OfferListener listener; + synchronized (FileDropManager.this) { + listener = offerListener; + } + if (listener != null) { + listener.onOffer(snapshot); + } + } + refresh(); + }; + + private FileDropManager() {} + + /** Last published transfer list, newest first. */ + public List transfers() { + return transfers; + } + + /** + * Registers a listener for transfer-list updates and replays the current + * list to it, so a screen opening between two updates is not left blank. + * Callbacks arrive on a background thread. + */ + public void addTransfersListener(@NonNull TransfersListener listener) { + transfersListeners.add(listener); + List current = transfers; + listener.onTransfers(current); + // The first listener to arrive during a live transfer has to restart the + // poll chain: it stops itself whenever no one is listening. + scheduleNextPoll(current); + } + + public void removeTransfersListener(@NonNull TransfersListener listener) { + transfersListeners.remove(listener); + } + + /** Set while an activity or service is bound, cleared when it goes. */ + public synchronized void setHandleFactory(@Nullable HandleFactory factory) { + handleFactory = factory; + listeningProfileId = null; + if (factory != null) { + refresh(); + } + } + + /** + * Drops a factory on the way out, unless a newer one replaced it already. + * A recreated activity starts before the old one is destroyed, so the old + * instance would otherwise clear its successor's registration. + */ + public synchronized void clearHandleFactory(@NonNull HandleFactory factory) { + if (handleFactory == factory) { + setHandleFactory(null); + } + } + + /** Set by whatever surfaces consent prompts, typically the foreground service. */ + public synchronized void setOfferListener(@Nullable OfferListener listener) { + offerListener = listener; + } + + /** Reloads the transfer list from Go and republishes it. */ + public void refresh() { + submit(FileDropManager::refreshOn); + } + + private static void refreshOn(FileDrop handle) { + FileDropTransferArray array = handle.transfers(); + List list = new ArrayList<>(); + for (long i = 0; i < array.length(); i++) { + list.add(new Transfer(array.get(i))); + } + INSTANCE.publish(Collections.unmodifiableList(list)); + } + + private void publish(List list) { + transfers = list; + releaseStaged(list); + for (TransfersListener listener : transfersListeners) { + listener.onTransfers(list); + } + scheduleNextPoll(list); + } + + /** + * Keeps the list refreshing while something is still moving. Progress + * arrives as an event like everything else, so this only covers an update + * that goes missing; a row would otherwise sit at whatever the last event + * left behind until the transfer ends. + *

+ * The poll chains off publish() rather than running on a fixed schedule: it + * starts itself when a live transfer appears and stops as soon as the last + * one settles, or when no screen is left to show it. + */ + private void scheduleNextPoll(List list) { + if (transfersListeners.isEmpty() || !hasLive(list)) { + return; + } + // One poll in flight at a time: several events landing together would + // otherwise each start their own chain. + if (!pollPending.compareAndSet(false, true)) { + return; + } + + try { + poller.schedule(() -> { + pollPending.set(false); + refresh(); + }, POLL_INTERVAL_MS, TimeUnit.MILLISECONDS); + } catch (RejectedExecutionException e) { + pollPending.set(false); + Log.w(LOGTAG, "file drop poller rejected the refresh", e); + } + } + + private static boolean hasLive(List list) { + for (Transfer transfer : list) { + if (!transfer.isTerminal()) { + return true; + } + } + return false; + } + + /** + * Drops the staged copies of transfers that have finished. A transfer that + * vanished from the log entirely counts as finished too, so a deleted entry + * does not leave its bytes behind. + */ + private void releaseStaged(List list) { + if (staged.isEmpty()) { + return; + } + + Set live = new HashSet<>(); + for (Transfer t : list) { + if (!t.isTerminal()) { + live.add(t.id()); + } + } + + for (Map.Entry> entry : staged.entrySet()) { + if (live.contains(entry.getKey())) { + continue; + } + for (ContentFileSource source : entry.getValue()) { + source.release(); + } + staged.remove(entry.getKey()); + } + } + + /** + * Sends the given content Uris to a peer. Reading their metadata can hit the + * disk, so it happens on the executor rather than at the call site. + */ + public void send(@NonNull List sources, @NonNull String peerKey, + @NonNull String peerName, @NonNull String peerIp, @Nullable ResultCallback callback) { + submitWithResult(handle -> { + FileDropPayloads payloads = new FileDropPayloads(); + for (ContentFileSource source : sources) { + payloads.addFile(source.name(), source.size(), source.contentType(), source); + } + String id; + try { + Log.i(LOGTAG, "sending " + sources.size() + " file(s) to " + peerName + + " (" + peerIp + ")"); + id = handle.send(peerKey, peerName, peerIp, payloads); + Log.i(LOGTAG, "send started, transfer " + id); + } catch (Exception e) { + // Nothing will ever report this transfer as finished, so the + // staged copies have to go here or they leak. + for (ContentFileSource source : sources) { + source.release(); + } + throw e; + } + // Sending is asynchronous, so the staged copies have to outlive this + // call and are released when the transfer reaches a terminal state. + staged.put(id, sources); + refreshOn(handle); + return id; + }, callback); + } + + /** Sends an inline text snippet to a peer. */ + public void sendText(@NonNull String text, @NonNull String peerKey, @NonNull String peerName, + @NonNull String peerIp, @Nullable ResultCallback callback) { + submitWithResult(handle -> { + FileDropPayloads payloads = new FileDropPayloads(); + payloads.addText("text", text); + String id = handle.send(peerKey, peerName, peerIp, payloads); + refreshOn(handle); + return id; + }, callback); + } + + public void accept(@NonNull String transferId, @Nullable ResultCallback callback) { + submitWithResult(handle -> { + handle.accept(transferId); + refreshOn(handle); + return null; + }, callback); + } + + public void decline(@NonNull String transferId, @Nullable ResultCallback callback) { + submitWithResult(handle -> { + handle.decline(transferId); + refreshOn(handle); + return null; + }, callback); + } + + public void cancel(@NonNull String transferId) { + submit(handle -> { + handle.cancel(transferId); + refreshOn(handle); + }); + } + + public void delete(@NonNull String transferId) { + submit(handle -> { + handle.deleteTransfer(transferId); + refreshOn(handle); + }); + } + + /** Reads the receiving mode, falling back to "ask" when Go is unreachable. */ + public void mode(@NonNull ValueCallback callback) { + submitWithResult(FileDrop::mode, callback); + } + + public void setMode(long mode, @Nullable ResultCallback callback) { + submitWithResult(handle -> { + handle.setMode(mode); + return null; + }, callback); + } + + public void peerRule(@NonNull String peerKey, @NonNull ValueCallback callback) { + submitWithResult(handle -> handle.peerRule(peerKey), callback); + } + + public void setPeerRule(@NonNull String peerKey, long rule, @Nullable ResultCallback callback) { + submitWithResult(handle -> { + handle.setPeerRule(peerKey, rule); + return null; + }, callback); + } + + public void destinationDir(@NonNull ValueCallback callback) { + submitWithResult(FileDrop::destinationDir, callback); + } + + /** Callback for an operation whose only outcome is success or a message. */ + public interface ResultCallback { + void onResult(boolean ok, @Nullable String error); + } + + /** Callback for an operation that reads a value out of Go. */ + public interface ValueCallback extends ResultCallback { + void onValue(T value); + + @Override + default void onResult(boolean ok, @Nullable String error) {} + } + + private interface Action { + void run(FileDrop handle) throws Exception; + } + + private interface Query { + T run(FileDrop handle) throws Exception; + } + + private void submit(Action action) { + submitWithResult(handle -> { + action.run(handle); + return null; + }, null); + } + + private void submitWithResult(Query query, @Nullable ResultCallback callback) { + try { + executor.execute(() -> { + FileDrop handle = openHandle(); + if (handle == null) { + Log.w(LOGTAG, "no file drop handle; is the VPN service bound?"); + report(callback, false, "NetBird is not running"); + return; + } + try { + T value = query.run(handle); + if (callback instanceof ValueCallback) { + //noinspection unchecked + ((ValueCallback) callback).onValue(value); + } + report(callback, true, null); + } catch (Exception e) { + Log.e(LOGTAG, "file drop call failed", e); + report(callback, false, e.getMessage()); + } + }); + } catch (RejectedExecutionException e) { + Log.w(LOGTAG, "file drop executor rejected the call", e); + report(callback, false, "file drop is shutting down"); + } + } + + private static void report(@Nullable ResultCallback callback, boolean ok, @Nullable String error) { + if (callback != null) { + callback.onResult(ok, error); + } + } + + /** + * Opens the handle and, on a profile switch, moves the event listener onto + * the new one: Go swaps handles per profile, and the Java listener has to + * follow so consent prompts keep arriving. + */ + @Nullable + private FileDrop openHandle() { + HandleFactory factory; + synchronized (this) { + factory = handleFactory; + } + if (factory == null) { + return null; + } + + FileDrop handle = factory.fileDrop(); + if (handle == null) { + return null; + } + + synchronized (this) { + String profileId = handle.profileID(); + if (!profileId.equals(listeningProfileId)) { + handle.setListener(goListener); + listeningProfileId = profileId; + } + } + return handle; + } +} diff --git a/tool/src/main/java/io/netbird/client/tool/files/FileDropNotification.java b/tool/src/main/java/io/netbird/client/tool/files/FileDropNotification.java new file mode 100644 index 00000000..44cb6a81 --- /dev/null +++ b/tool/src/main/java/io/netbird/client/tool/files/FileDropNotification.java @@ -0,0 +1,160 @@ +package io.netbird.client.tool.files; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.core.app.NotificationCompat; + +import io.netbird.client.tool.R; + +/** + * Posts the consent prompt for an incoming transfer, so an offer is answerable + * without the app in the foreground. The accept and decline buttons go to + * {@link FileDropActionReceiver} rather than to an activity: answering an offer + * needs no UI, and opening one would be an interruption in its own right. + *

+ * Notification ids are derived from the transfer id so several offers coexist + * instead of overwriting each other. + */ +public class FileDropNotification { + + private static final String LOGTAG = "FileDropNotification"; + private static final String CHANNEL_ID = "netbird_file_drop"; + private static final int NOTIFICATION_ID_BASE = 200; + + private final Context context; + + public FileDropNotification(@NonNull Context context) { + this.context = context.getApplicationContext(); + } + + public void showOffer(@NonNull FileDropManager.Transfer transfer) { + NotificationManager manager = manager(); + createChannel(manager); + + String title = context.getString(R.string.file_drop_notification_offer_title, transfer.peerName()); + String text = transfer.label(); + + Notification notification = new NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.notification_icon_connected) + .setContentTitle(title) + .setContentText(text) + .setStyle(new NotificationCompat.BigTextStyle().bigText(text)) + .setAutoCancel(true) + .addAction(0, context.getString(R.string.file_drop_notification_decline), + actionIntent(FileDropActionReceiver.ACTION_DECLINE, transfer.id())) + .addAction(0, context.getString(R.string.file_drop_notification_accept), + actionIntent(FileDropActionReceiver.ACTION_ACCEPT, transfer.id())) + .build(); + + post(manager, notificationId(transfer.id()), notification); + } + + public void cancelOffer(@NonNull String transferId) { + manager().cancel(notificationId(transferId)); + } + + /** + * Tracks one transfer through to its outcome. While it runs the bar shows + * progress, and the final state replaces it in place, so a send started from + * the share sheet stays visible without the app in the foreground. + */ + public void showProgress(@NonNull FileDropManager.Transfer transfer) { + NotificationManager manager = manager(); + createChannel(manager); + + int percent = transfer.totalSize() > 0 + ? (int) (transfer.transferred() * 100 / transfer.totalSize()) + : 0; + + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.notification_icon_connected) + .setContentTitle(transfer.label()) + .setContentText(context.getString(transfer.outgoing() + ? R.string.file_drop_notification_sending_to + : R.string.file_drop_notification_receiving_from, + transfer.peerName())) + .setProgress(100, percent, transfer.totalSize() <= 0) + .setOngoing(true) + .setOnlyAlertOnce(true); + + post(manager, notificationId(transfer.id()), builder.build()); + } + + /** Replaces a progress notification with its terminal outcome. */ + public void showOutcome(@NonNull FileDropManager.Transfer transfer) { + NotificationManager manager = manager(); + createChannel(manager); + + String title = transfer.label(); + String text; + int icon = transfer.isFailed() + ? R.drawable.notification_icon_error + : R.drawable.notification_icon_connected; + if (transfer.isFailed()) { + text = context.getString(R.string.file_drop_notification_failed, transfer.peerName()); + } else if (transfer.outgoing()) { + text = context.getString(R.string.file_drop_notification_sent, transfer.peerName()); + } else { + text = context.getString(R.string.file_drop_notification_received, transfer.peerName()); + } + + Notification notification = new NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(icon) + .setContentTitle(title) + .setContentText(text) + .setStyle(new NotificationCompat.BigTextStyle().bigText(text)) + .setAutoCancel(true) + .setOnlyAlertOnce(true) + .build(); + + post(manager, notificationId(transfer.id()), notification); + } + + /** + * Notification id for a transfer. Hash collisions only mean two offers share + * a notification slot, which is why the id also travels in the intent extra. + */ + static int notificationId(@NonNull String transferId) { + return NOTIFICATION_ID_BASE + Math.abs(transferId.hashCode() % 1000); + } + + private NotificationManager manager() { + return (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + } + + private void createChannel(NotificationManager manager) { + NotificationChannel channel = new NotificationChannel( + CHANNEL_ID, + context.getString(R.string.file_drop_notification_channel_name), + NotificationManager.IMPORTANCE_HIGH); + manager.createNotificationChannel(channel); + } + + private PendingIntent actionIntent(String action, String transferId) { + Intent intent = new Intent(context, FileDropActionReceiver.class); + intent.setAction(action); + intent.putExtra(FileDropActionReceiver.EXTRA_TRANSFER_ID, transferId); + + // The request code has to distinguish accept from decline for the same + // transfer, or FLAG_UPDATE_CURRENT would fold them into one intent. + int requestCode = (transferId + action).hashCode(); + return PendingIntent.getBroadcast(context, requestCode, intent, + PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); + } + + private void post(NotificationManager manager, int id, Notification notification) { + try { + manager.notify(id, notification); + } catch (SecurityException e) { + // POST_NOTIFICATIONS runtime permission not granted (API 33+) + Log.w(LOGTAG, "cannot post file drop notification", e); + } + } +} diff --git a/tool/src/main/java/io/netbird/client/tool/files/MediaStoreFileDropSink.java b/tool/src/main/java/io/netbird/client/tool/files/MediaStoreFileDropSink.java new file mode 100644 index 00000000..1450e379 --- /dev/null +++ b/tool/src/main/java/io/netbird/client/tool/files/MediaStoreFileDropSink.java @@ -0,0 +1,499 @@ +package io.netbird.client.tool.files; + +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.os.ParcelFileDescriptor; +import android.provider.MediaStore; +import android.provider.OpenableColumns; +import android.text.TextUtils; +import android.util.Log; +import android.webkit.MimeTypeMap; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import io.netbird.gomobile.android.FileDropSink; +import io.netbird.gomobile.android.FileDropWriter; + +/** + * Receives file drop payloads straight into the shared Downloads collection, so + * a delivered file is where the user looks for it rather than inside app + * storage only this app can read. + *

+ * Nothing is staged twice: bytes go into their final entry as they arrive, and + * the entry stays invisible to other apps until the transfer completes, which + * is what {@code IS_PENDING} buys. A cancelled or expired transfer is deleted + * rather than swept up later, so an abandoned partial never surfaces in the + * gallery and no cleanup has to guess which files are the user's. + *

+ * Below Android 10 there is no pending flag and no Downloads collection to + * insert into, and the public Downloads folder there needs a storage permission + * this app does not ask for. The payload lands in the app's own external files + * directory instead, staged under a hidden name and renamed on delivery, which + * keeps the same "invisible until complete" behaviour without a prompt. + */ +public class MediaStoreFileDropSink implements FileDropSink { + + private static final String LOGTAG = "FileDropSink"; + /** Subdirectory of Downloads received files land in. */ + private static final String RELATIVE_DIR = Environment.DIRECTORY_DOWNLOADS + "/NetBird"; + private static final String FALLBACK_MIME = "application/octet-stream"; + /** Marks an incomplete legacy payload, and hides it from the media scanner. */ + private static final String LEGACY_PARTIAL_PREFIX = ".nb-partial-"; + + private static final boolean HAS_PENDING_MEDIA = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q; + + private final Context context; + // One entry per payload being received, keyed by offer and index. Holds the + // destination each write reopens and each delivery publishes. + private final Map payloads = new ConcurrentHashMap<>(); + + public MediaStoreFileDropSink(@NonNull Context context) { + this.context = context.getApplicationContext(); + } + + /** One payload's destination, from the first write to delivery. */ + private static final class Payload { + private final String offerID; + private final long index; + private final String name; + // Set on the modern path: the pending MediaStore entry bytes go into. + @Nullable + private final Uri uri; + // Set on the legacy path: the hidden file bytes go into. + @Nullable + private final File file; + + Payload(String offerID, long index, String name, @Nullable Uri uri, @Nullable File file) { + this.offerID = offerID; + this.index = index; + this.name = name; + this.uri = uri; + this.file = file; + } + } + + @Override + public String destinationLabel() { + if (HAS_PENDING_MEDIA) { + return RELATIVE_DIR; + } + return legacyDir().getAbsolutePath(); + } + + @Override + public void prepare(String offerID) { + // Destinations are created lazily, by the first write of each payload: + // an offer may never be accepted, and an entry created here would have + // to be cleaned up for every offer that is not. + } + + @Override + public long received(String offerID, long index) { + Payload payload = payloads.get(key(offerID, index)); + if (payload == null) { + return 0; + } + if (payload.file != null) { + return payload.file.length(); + } + return length(payload.uri); + } + + @Override + public FileDropWriter openWriter(String offerID, long index, String name, long offset, long size) + throws Exception { + String key = key(offerID, index); + Payload payload = payloads.get(key); + if (payload == null) { + payload = create(offerID, index, name); + payloads.put(key, payload); + } + + if (payload.file != null) { + return new LegacyWriter(payload.file, offset); + } + return new PendingWriter(context.getContentResolver(), payload.uri, offset); + } + + /** + * Publishes every payload of one offer. A failure part-way leaves nothing + * half-delivered: what already went out is withdrawn again, so the transfer + * fails whole rather than dropping some of its files on the user. + */ + @Override + public String deliver(String offerID) throws Exception { + List ordered = payloadsOf(offerID); + List published = new ArrayList<>(ordered.size()); + List delivered = new ArrayList<>(ordered.size()); + + try { + for (Payload payload : ordered) { + delivered.add(publish(payload)); + published.add(payload); + } + } catch (Exception e) { + for (Payload payload : published) { + discard(payload); + } + throw e; + } finally { + for (Payload payload : ordered) { + payloads.remove(key(payload.offerID, payload.index)); + } + } + return TextUtils.join("\n", delivered); + } + + @Override + public void remove(String offerID) { + for (Payload payload : payloadsOf(offerID)) { + discard(payload); + payloads.remove(key(payload.offerID, payload.index)); + } + } + + @Override + public void cleanup(long maxAgeSeconds) { + // A destination is created only for a payload being received and is + // deleted the moment that stops, so there is nothing here to sweep. The + // process dying mid-transfer is the exception, handled by clearPartials. + } + + /** + * Deletes destinations abandoned by a killed process. Pending MediaStore + * entries this app owns and hidden legacy files are both invisible to the + * user, so a leftover is invisible clutter rather than a stray file, but it + * still occupies space until this runs. + */ + public void clearPartials() { + if (HAS_PENDING_MEDIA) { + clearPendingEntries(); + return; + } + clearLegacyPartials(); + } + + private Payload create(String offerID, long index, String name) throws IOException { + String safeName = sanitize(name, index); + if (!HAS_PENDING_MEDIA) { + return new Payload(offerID, index, safeName, null, legacyPartial(offerID, index)); + } + + ContentValues values = new ContentValues(); + values.put(MediaStore.MediaColumns.DISPLAY_NAME, safeName); + values.put(MediaStore.MediaColumns.MIME_TYPE, mimeOf(safeName)); + values.put(MediaStore.MediaColumns.RELATIVE_PATH, RELATIVE_DIR); + values.put(MediaStore.MediaColumns.IS_PENDING, 1); + + ContentResolver resolver = context.getContentResolver(); + Uri uri = resolver.insert(downloads(), values); + if (uri == null) { + throw new IOException("cannot create a Downloads entry for " + safeName); + } + return new Payload(offerID, index, safeName, uri, null); + } + + /** Makes a payload visible under its announced name and returns where it landed. */ + private String publish(Payload payload) throws IOException { + if (payload.file != null) { + return publishLegacy(payload); + } + + ContentValues values = new ContentValues(); + values.put(MediaStore.MediaColumns.IS_PENDING, 0); + if (context.getContentResolver().update(payload.uri, values, null, null) == 0) { + throw new IOException("cannot publish " + payload.uri); + } + return payload.uri.toString(); + } + + private String publishLegacy(Payload payload) throws IOException { + File dir = legacyDir(); + if (!dir.exists() && !dir.mkdirs()) { + throw new IOException("cannot create " + dir); + } + + File target = freeName(dir, payload.name); + if (!payload.file.renameTo(target)) { + throw new IOException("cannot move " + payload.file + " to " + target); + } + return target.getAbsolutePath(); + } + + private void discard(Payload payload) { + if (payload.file != null) { + if (payload.file.exists() && !payload.file.delete()) { + Log.w(LOGTAG, "cannot delete partial " + payload.file); + } + return; + } + try { + context.getContentResolver().delete(payload.uri, null, null); + } catch (Exception e) { + Log.w(LOGTAG, "cannot delete pending entry " + payload.uri, e); + } + } + + /** One offer's payloads, in the order the offer announced them. */ + private List payloadsOf(String offerID) { + List ordered = new ArrayList<>(); + for (Payload payload : payloads.values()) { + if (payload.offerID.equals(offerID)) { + ordered.add(payload); + } + } + ordered.sort(Comparator.comparingLong(p -> p.index)); + return ordered; + } + + private long length(Uri uri) { + try (Cursor cursor = context.getContentResolver() + .query(uri, new String[]{OpenableColumns.SIZE}, null, null, null)) { + if (cursor != null && cursor.moveToFirst() && !cursor.isNull(0)) { + return cursor.getLong(0); + } + } catch (Exception e) { + Log.w(LOGTAG, "cannot read the size of " + uri, e); + } + return 0; + } + + private void clearPendingEntries() { + String selection = MediaStore.MediaColumns.IS_PENDING + " = 1"; + try (Cursor cursor = context.getContentResolver().query(downloads(), + new String[]{MediaStore.MediaColumns._ID}, selection, null, null)) { + if (cursor == null) { + return; + } + while (cursor.moveToNext()) { + Uri uri = Uri.withAppendedPath(downloads(), String.valueOf(cursor.getLong(0))); + try { + context.getContentResolver().delete(uri, null, null); + } catch (Exception e) { + Log.w(LOGTAG, "cannot delete stale pending entry " + uri, e); + } + } + } catch (Exception e) { + Log.w(LOGTAG, "cannot list stale pending entries", e); + } + } + + private void clearLegacyPartials() { + File[] files = legacyPartialDir().listFiles(); + if (files == null) { + return; + } + for (File file : files) { + if (file.getName().startsWith(LEGACY_PARTIAL_PREFIX) && !file.delete()) { + Log.w(LOGTAG, "cannot delete stale partial " + file); + } + } + } + + private File legacyPartial(String offerID, long index) throws IOException { + File dir = legacyPartialDir(); + if (!dir.exists() && !dir.mkdirs()) { + throw new IOException("cannot create " + dir); + } + return new File(dir, LEGACY_PARTIAL_PREFIX + offerID + "-" + index); + } + + private File legacyPartialDir() { + return new File(context.getFilesDir(), "filedrop-incoming"); + } + + /** + * Delivery directory on the legacy path. The app-specific external + * directory needs no permission; a null volume means external storage is + * unavailable, and internal storage is the last resort. + */ + private File legacyDir() { + File external = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS); + if (external != null) { + return external; + } + return new File(context.getFilesDir(), Environment.DIRECTORY_DOWNLOADS); + } + + private static Uri downloads() { + return MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY); + } + + private static String key(String offerID, long index) { + return offerID + ":" + index; + } + + /** + * Reduces an announced name to a bare filename. A sender is not trusted to + * stay inside the destination: path separators would otherwise let an offer + * name its way out of it. + */ + private static String sanitize(String name, long index) { + String bare = name == null ? "" : name.replace('\\', '/'); + int slash = bare.lastIndexOf('/'); + if (slash >= 0) { + bare = bare.substring(slash + 1); + } + bare = bare.trim(); + if (bare.isEmpty() || bare.equals(".") || bare.equals("..")) { + return "file-" + index; + } + return bare; + } + + private static String mimeOf(String name) { + int dot = name.lastIndexOf('.'); + if (dot < 0 || dot == name.length() - 1) { + return FALLBACK_MIME; + } + String extension = name.substring(dot + 1).toLowerCase(Locale.US); + String mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension); + return mime == null ? FALLBACK_MIME : mime; + } + + /** First unused name in dir, counting up the way a browser download does. */ + private static File freeName(File dir, String name) throws IOException { + File candidate = new File(dir, name); + if (!candidate.exists()) { + return candidate; + } + + int dot = name.lastIndexOf('.'); + String stem = dot > 0 ? name.substring(0, dot) : name; + String extension = dot > 0 ? name.substring(dot) : ""; + + for (int attempt = 1; attempt < 1000; attempt++) { + candidate = new File(dir, stem + " (" + attempt + ")" + extension); + if (!candidate.exists()) { + return candidate; + } + } + throw new IOException("no free name for " + name + " in " + dir); + } + + /** + * Writes into a pending MediaStore entry through a file descriptor rather + * than a plain output stream: a resumed transfer has to position itself at + * the offset the sender confirmed, and truncate whatever a previous attempt + * left past it. Append mode cannot do the second part, and providers are + * not required to honour it at all. + */ + private static final class PendingWriter implements FileDropWriter { + + private final ParcelFileDescriptor descriptor; + private final FileOutputStream stream; + private long written; + + PendingWriter(ContentResolver resolver, Uri uri, long offset) throws IOException { + ParcelFileDescriptor pfd = resolver.openFileDescriptor(uri, "rw"); + if (pfd == null) { + throw new IOException("cannot open " + uri); + } + + FileOutputStream out = new FileOutputStream(pfd.getFileDescriptor()); + try { + out.getChannel().truncate(offset); + out.getChannel().position(offset); + } catch (IOException e) { + closeQuietly(out, pfd); + throw e; + } + + this.descriptor = pfd; + this.stream = out; + this.written = offset; + } + + @Override + public void writeChunk(byte[] p) throws Exception { + stream.write(p); + written += p.length; + } + + @Override + public long written() { + return written; + } + + @Override + public void close() throws Exception { + try { + stream.flush(); + stream.getFD().sync(); + } finally { + stream.close(); + descriptor.close(); + } + } + + private static void closeQuietly(FileOutputStream stream, ParcelFileDescriptor pfd) { + try { + stream.close(); + } catch (IOException e) { + Log.w(LOGTAG, "cannot close a rejected destination", e); + } + try { + pfd.close(); + } catch (IOException e) { + Log.w(LOGTAG, "cannot close a rejected descriptor", e); + } + } + } + + /** Writes into a hidden file, for Android versions without a pending flag. */ + private static final class LegacyWriter implements FileDropWriter { + + private final FileOutputStream stream; + private long written; + + LegacyWriter(File file, long offset) throws IOException { + FileOutputStream out = new FileOutputStream(file, true); + try { + out.getChannel().truncate(offset); + out.getChannel().position(offset); + } catch (IOException e) { + out.close(); + throw e; + } + this.stream = out; + this.written = offset; + } + + @Override + public void writeChunk(byte[] p) throws Exception { + stream.write(p); + written += p.length; + } + + @Override + public long written() { + return written; + } + + @Override + public void close() throws Exception { + try { + stream.flush(); + stream.getFD().sync(); + } finally { + stream.close(); + } + } + } +} diff --git a/tool/src/main/res/values/strings.xml b/tool/src/main/res/values/strings.xml index ef7fba4d..74855a40 100644 --- a/tool/src/main/res/values/strings.xml +++ b/tool/src/main/res/values/strings.xml @@ -8,6 +8,16 @@ NetBird session expired Your login session has expired. Open the app to sign in again. Extend session + Session expires at %1$s + Incoming files + %1$s wants to send you a file + Accept + Decline + Sending to %1$s + Receiving from %1$s + Sent to %1$s + Received from %1$s + Transfer with %1$s failed Connecting… Disconnected No network available diff --git a/tool/src/main/res/xml/provider_paths.xml b/tool/src/main/res/xml/provider_paths.xml index dcb50239..bd9922a1 100644 --- a/tool/src/main/res/xml/provider_paths.xml +++ b/tool/src/main/res/xml/provider_paths.xml @@ -3,4 +3,7 @@ - \ No newline at end of file + +