diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index b6f2f46..54fa855 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -82,6 +82,7 @@ dependencies {
androidTestImplementation(libs.espresso.core)
implementation(libs.browser) // Added for CustomTabsIntent
implementation(libs.lottie)
+ implementation(libs.zxing)
if (hasGoogleServicesJson) {
implementation(platform(libs.firebase.bom))
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index c790c56..bb289c7 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -11,9 +11,17 @@
+
+
+
+
diff --git a/app/src/main/java/io/netbird/client/CustomTabURLOpener.java b/app/src/main/java/io/netbird/client/CustomTabURLOpener.java
index 4a06326..1f27953 100644
--- a/app/src/main/java/io/netbird/client/CustomTabURLOpener.java
+++ b/app/src/main/java/io/netbird/client/CustomTabURLOpener.java
@@ -42,7 +42,6 @@ public boolean isOpened() {
return isOpened;
}
-
@Override
public void onLoginSuccess() {
Log.d(TAG, "onLoginSuccess fired.");
@@ -56,7 +55,7 @@ public void onLoginSuccess() {
}
@Override
- public void open(String url) {
+ public void open(String url, String userCode) {
isOpened = true;
try {
CustomTabsIntent customTabsIntent = new CustomTabsIntent.Builder().build();
diff --git a/app/src/main/java/io/netbird/client/MainActivity.java b/app/src/main/java/io/netbird/client/MainActivity.java
index 21fc3f6..0a72dfa 100644
--- a/app/src/main/java/io/netbird/client/MainActivity.java
+++ b/app/src/main/java/io/netbird/client/MainActivity.java
@@ -14,6 +14,7 @@
import android.os.IBinder;
import android.text.Html;
import android.util.Log;
+import android.view.KeyEvent;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
@@ -30,6 +31,7 @@
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import androidx.core.content.ContextCompat;
+import androidx.core.view.GravityCompat;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import androidx.navigation.NavController;
import androidx.navigation.Navigation;
@@ -46,6 +48,7 @@
import io.netbird.gomobile.android.ConnectionListener;
import io.netbird.gomobile.android.NetworkArray;
import io.netbird.gomobile.android.PeerInfoArray;
+import io.netbird.gomobile.android.URLOpener;
public class MainActivity extends AppCompatActivity implements NavigationView.OnNavigationItemSelectedListener, ServiceAccessor, StateListenerRegistry {
@@ -68,9 +71,11 @@ private enum ConnectionState {
private ActivityResultLauncher vpnActivityResultLauncher;
private final List serviceStateListeners = new ArrayList<>();
- private CustomTabURLOpener urlOpener;
+ private URLOpener urlOpener;
+ private QrCodeDialog qrCodeDialog;
private boolean isSSOFinishedWell = false;
+ private boolean isRunningOnTV = false;
// Last known state for UI updates
private ConnectionState lastKnownState = ConnectionState.UNKNOWN;
@@ -102,6 +107,11 @@ protected void onCreate(Bundle savedInstanceState) {
setContentView(binding.getRoot());
setSupportActionBar(binding.appBarMain.toolbar);
+ isRunningOnTV = PlatformUtils.isAndroidTV(this);
+ if (isRunningOnTV) {
+ Log.i(LOGTAG, "Running on Android TV - optimizing for D-pad navigation");
+ }
+
setVersionText();
DrawerLayout drawer = binding.drawerLayout;
@@ -109,6 +119,42 @@ protected void onCreate(Bundle savedInstanceState) {
// Set the listener for menu item selections
navigationView.setNavigationItemSelectedListener(this);
+
+ // On TV, request focus when drawer opens so D-pad navigation works
+ if (isRunningOnTV) {
+ drawer.addDrawerListener(new DrawerLayout.SimpleDrawerListener() {
+ @Override
+ public void onDrawerOpened(View drawerView) {
+ // Request focus on the drawer when it opens
+ navigationView.postDelayed(() -> {
+ navigationView.setFocusable(true);
+ navigationView.setFocusableInTouchMode(false);
+
+ if (!navigationView.requestFocus()) {
+ Log.d(LOGTAG, "NavigationView couldn't get focus, trying menu items");
+ }
+
+ // Try to find and focus the first visible menu item
+ View menuView = navigationView.getChildAt(0);
+ if (menuView != null) {
+ View firstFocusable = menuView.findFocus();
+ if (firstFocusable == null) {
+ menuView.requestFocus();
+ }
+ }
+ }, 100); // Delay to let drawer animation finish
+ }
+
+ @Override
+ public void onDrawerClosed(View drawerView) {
+ // Return focus to main content when drawer closed
+ View mainContent = findViewById(R.id.nav_host_fragment_content_main);
+ if (mainContent != null) {
+ mainContent.requestFocus();
+ }
+ }
+ });
+ }
// Passing each menu ID as a set of Ids because each
// menu should be considered as top level destinations.
@@ -127,16 +173,43 @@ protected void onCreate(Bundle savedInstanceState) {
}
});
- urlOpener = new CustomTabURLOpener(this, () -> {
- if(isSSOFinishedWell) {
- return;
- }
- if(mBinder == null) {
- return;
- }
+ if (!isRunningOnTV) {
+ urlOpener = new CustomTabURLOpener(this, () -> {
+ if (isSSOFinishedWell) {
+ return;
+ }
+ if (mBinder == null) {
+ return;
+ }
- mBinder.stopEngine();
- });
+ mBinder.stopEngine();
+ });
+ } else {
+ urlOpener = new URLOpener() {
+ @Override
+ public void open(String url, String userCode) {
+ qrCodeDialog = QrCodeDialog.newInstance(url, userCode, () -> {
+ if (isSSOFinishedWell) {
+ return;
+ }
+ if (mBinder == null) {
+ return;
+ }
+ mBinder.stopEngine();
+ });
+ qrCodeDialog.show(getSupportFragmentManager(), "QrCodeDialog");
+ }
+
+ @Override
+ public void onLoginSuccess() {
+ Log.d(LOGTAG, "onLoginSuccess fired for TV.");
+ if (qrCodeDialog != null && qrCodeDialog.isVisible()) {
+ qrCodeDialog.dismiss();
+ qrCodeDialog = null;
+ }
+ }
+ };
+ }
// VPN permission result launcher
vpnActivityResultLauncher = registerForActivityResult(
@@ -156,12 +229,12 @@ protected void onCreate(Bundle savedInstanceState) {
if (VPNService.isUsingAlwaysOnVPN(this)) {
showAlwaysOnDialog(() -> {
if (mBinder != null) {
- mBinder.runEngine(urlOpener);
+ mBinder.runEngine(urlOpener, isRunningOnTV);
}
});
} else {
if (mBinder != null) {
- mBinder.runEngine(urlOpener);
+ mBinder.runEngine(urlOpener, isRunningOnTV);
}
}
});
@@ -194,7 +267,11 @@ protected void onPause() {
protected void onStop() {
super.onStop();
Log.d(LOGTAG, "onStop");
- if (!urlOpener.isOpened() && mBinder != null) {
+ if (urlOpener instanceof CustomTabURLOpener && ((CustomTabURLOpener) urlOpener).isOpened()) {
+ return; // Keep service alive for SSO custom tab
+ }
+
+ if (mBinder != null) {
mBinder.removeConnectionStateListener();
mBinder.removeServiceStateListener(serviceStateListener);
unbindService(serviceIPC);
@@ -260,7 +337,7 @@ public void switchConnection(boolean status) {
if (prepareIntent != null) {
vpnActivityResultLauncher.launch(prepareIntent);
} else {
- mBinder.runEngine(urlOpener);
+ mBinder.runEngine(urlOpener, isRunningOnTV);
}
}
@@ -485,4 +562,40 @@ public void onError(String msg) {
});
}
};
+
+ @Override
+ public boolean onKeyDown(int keyCode, KeyEvent event) {
+ if (!isRunningOnTV) {
+ return super.onKeyDown(keyCode, event);
+ }
+ else {
+ Log.d(LOGTAG, "Key pressed: " + keyCode + " (" + KeyEvent.keyCodeToString(keyCode) + "), repeat: " + event.getRepeatCount());
+
+ if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT) {
+ boolean isOnHomeScreen = navController != null &&
+ navController.getCurrentDestination() != null &&
+ navController.getCurrentDestination().getId() == R.id.nav_home;
+
+ if (event.getRepeatCount() == 0 && isOnHomeScreen && !binding.drawerLayout.isDrawerOpen(GravityCompat.START)) {
+ Toast.makeText(this, R.string.tv_menu_hint, Toast.LENGTH_SHORT).show();
+ }
+
+ // drawer is not selectable on Android 16+, so we open via a long press of the left d-pad button instead
+ if (event.getRepeatCount() > 0 && !binding.drawerLayout.isDrawerOpen(GravityCompat.START)) {
+ Log.d(LOGTAG, "Long press LEFT detected - opening drawer");
+ binding.drawerLayout.openDrawer(GravityCompat.START);
+ binding.navView.requestFocus();
+ return true;
+ }
+ }
+
+ if (keyCode == KeyEvent.KEYCODE_BACK && binding.drawerLayout.isDrawerOpen(GravityCompat.START)) {
+ Log.d(LOGTAG, "Closing drawer with BACK");
+ binding.drawerLayout.closeDrawer(GravityCompat.START);
+ return true;
+ }
+ }
+
+ return super.onKeyDown(keyCode, event);
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/io/netbird/client/PlatformUtils.java b/app/src/main/java/io/netbird/client/PlatformUtils.java
new file mode 100644
index 0000000..dd0d6f0
--- /dev/null
+++ b/app/src/main/java/io/netbird/client/PlatformUtils.java
@@ -0,0 +1,20 @@
+package io.netbird.client;
+
+import android.app.UiModeManager;
+import android.content.Context;
+import android.content.res.Configuration;
+
+public final class PlatformUtils {
+
+ private PlatformUtils() {
+ }
+
+ public static boolean isAndroidTV(Context context) {
+ UiModeManager uiModeManager = (UiModeManager) context.getSystemService(Context.UI_MODE_SERVICE);
+ if (uiModeManager != null) {
+ return uiModeManager.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION;
+ }
+ return false;
+ }
+}
+
diff --git a/app/src/main/java/io/netbird/client/QrCodeDialog.java b/app/src/main/java/io/netbird/client/QrCodeDialog.java
new file mode 100644
index 0000000..9d0466a
--- /dev/null
+++ b/app/src/main/java/io/netbird/client/QrCodeDialog.java
@@ -0,0 +1,84 @@
+package io.netbird.client;
+
+import android.graphics.Bitmap;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Button;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.DialogFragment;
+
+import com.google.zxing.BarcodeFormat;
+import com.journeyapps.barcodescanner.BarcodeEncoder;
+
+public class QrCodeDialog extends DialogFragment {
+
+ private static final String ARG_URL = "url";
+ private static final String ARG_USER_CODE = "userCode";
+
+ private static OnDialogDismissed dismissCallback;
+
+ public interface OnDialogDismissed {
+ void onDismissed();
+ }
+
+ public static QrCodeDialog newInstance(String url, String userCode, OnDialogDismissed callback) {
+ QrCodeDialog fragment = new QrCodeDialog();
+ Bundle args = new Bundle();
+ args.putString(ARG_URL, url);
+ args.putString(ARG_USER_CODE, userCode);
+ fragment.setArguments(args);
+ dismissCallback = callback;
+ return fragment;
+ }
+
+ @Nullable
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
+ return inflater.inflate(R.layout.dialog_qr_code, container, false);
+ }
+
+ @Override
+ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+
+ ImageView qrCodeImageView = view.findViewById(R.id.qr_code_image_view);
+ TextView userCodeTextView = view.findViewById(R.id.user_code_text_view);
+ Button closeButton = view.findViewById(R.id.close_button);
+
+ String url = getArguments().getString(ARG_URL);
+ String userCode = getArguments().getString(ARG_USER_CODE);
+
+ try {
+ BarcodeEncoder barcodeEncoder = new BarcodeEncoder();
+ Bitmap bitmap = barcodeEncoder.encodeBitmap(url, BarcodeFormat.QR_CODE, 400, 400);
+ qrCodeImageView.setImageBitmap(bitmap);
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+
+ // Show user code if it exists. Needs testing on real device, emulator is not getting a Device Code
+ if (userCode != null && !userCode.isEmpty()) {
+ userCodeTextView.setText(getString(R.string.device_code, userCode));
+ userCodeTextView.setVisibility(View.VISIBLE);
+ } else {
+ userCodeTextView.setVisibility(View.GONE);
+ }
+
+ closeButton.setOnClickListener(v -> dismiss());
+ }
+
+ @Override
+ public void onDismiss(@NonNull android.content.DialogInterface dialog) {
+ super.onDismiss(dialog);
+ if (dismissCallback != null) {
+ dismissCallback.onDismissed();
+ dismissCallback = null;
+ }
+ }
+}
diff --git a/app/src/main/java/io/netbird/client/ui/advanced/AdvancedFragment.java b/app/src/main/java/io/netbird/client/ui/advanced/AdvancedFragment.java
index 5afcc47..5e7a25b 100644
--- a/app/src/main/java/io/netbird/client/ui/advanced/AdvancedFragment.java
+++ b/app/src/main/java/io/netbird/client/ui/advanced/AdvancedFragment.java
@@ -57,6 +57,11 @@ private void configureForceRelayConnectionSwitch(@NonNull ComponentSwitchBinding
showReconnectionNeededWarningDialog();
});
+
+ // Make parent layout clickable to toggle switch (for TV remote)
+ binding.getRoot().setOnClickListener(v -> {
+ binding.switchControl.toggle();
+ });
}
public View onCreateView(@NonNull LayoutInflater inflater,
@@ -102,6 +107,11 @@ public View onCreateView(@NonNull LayoutInflater inflater,
preferences.disableTraceLog();
}
});
+
+ // Make parent layout clickable to toggle switch (for TV remote)
+ binding.traceLogLayout.setOnClickListener(v -> {
+ binding.switchTraceLog.toggle();
+ });
// Handle "Share Logs" button click
binding.buttonShareLogs.setOnClickListener(v -> {
@@ -142,6 +152,11 @@ public View onCreateView(@NonNull LayoutInflater inflater,
Toast.makeText(inflater.getContext(), "Error: " + e.toString(), Toast.LENGTH_SHORT).show();
}
});
+
+ // Make parent layout clickable to toggle switch (for TV remote)
+ binding.layoutRosenpas.setOnClickListener(v -> {
+ binding.switchRosenpass.toggle();
+ });
binding.switchRosenpassPermissive.setOnCheckedChangeListener((buttonView, isChecked) -> {
goPreferences.setRosenpassPermissive(isChecked);
@@ -152,6 +167,11 @@ public View onCreateView(@NonNull LayoutInflater inflater,
Toast.makeText(inflater.getContext(), "Error: " + e, Toast.LENGTH_SHORT).show();
}
});
+
+ // Make parent layout clickable to toggle switch (for TV remote)
+ binding.layoutRosenpassPermissive.setOnClickListener(v -> {
+ binding.switchRosenpassPermissive.toggle();
+ });
configureForceRelayConnectionSwitch(binding.layoutForceRelayConnection, preferences);
@@ -247,6 +267,31 @@ private void initializeEngineConfigSwitches() {
Log.e(LOGTAG, "Failed to set block inbound", e);
}
});
+
+ // Make parent layouts clickable to toggle switches (for TV remote)
+ binding.layoutAllowSsh.setOnClickListener(v -> {
+ binding.switchAllowSsh.toggle();
+ });
+
+ binding.layoutBlockInbound.setOnClickListener(v -> {
+ binding.switchBlockInbound.toggle();
+ });
+
+ binding.layoutDisableClientRoutes.setOnClickListener(v -> {
+ binding.switchDisableClientRoutes.toggle();
+ });
+
+ binding.layoutDisableServerRoutes.setOnClickListener(v -> {
+ binding.switchDisableServerRoutes.toggle();
+ });
+
+ binding.layoutDisableDns.setOnClickListener(v -> {
+ binding.switchDisableDns.toggle();
+ });
+
+ binding.layoutDisableFirewall.setOnClickListener(v -> {
+ binding.switchDisableFirewall.toggle();
+ });
} catch (Exception e) {
Log.e(LOGTAG, "Failed to initialize engine config switches", e);
diff --git a/app/src/main/java/io/netbird/client/ui/fistinstall/FirstInstallFragment.java b/app/src/main/java/io/netbird/client/ui/fistinstall/FirstInstallFragment.java
index 831a4ad..ca8b384 100644
--- a/app/src/main/java/io/netbird/client/ui/fistinstall/FirstInstallFragment.java
+++ b/app/src/main/java/io/netbird/client/ui/fistinstall/FirstInstallFragment.java
@@ -20,6 +20,7 @@
import androidx.navigation.NavOptions;
import androidx.navigation.Navigation;
+import io.netbird.client.PlatformUtils;
import io.netbird.client.R;
import io.netbird.client.databinding.FragmentFirstinstallBinding;
import io.netbird.client.ui.server.ChangeServerFragment;
@@ -81,6 +82,10 @@ public void updateDrawState(@NonNull TextPaint ds) {
binding.txtLicense.setText(spannable);
binding.txtLicense.setMovementMethod(LinkMovementMethod.getInstance());
binding.txtLicense.setHighlightColor(Color.TRANSPARENT);
+
+ if (PlatformUtils.isAndroidTV(requireContext())) {
+ binding.btnContinue.postDelayed(() -> binding.btnContinue.requestFocus(), 200);
+ }
}
private void hideAppBar() {
diff --git a/app/src/main/java/io/netbird/client/ui/home/BottomDialogFragment.java b/app/src/main/java/io/netbird/client/ui/home/BottomDialogFragment.java
index 305f920..a945e46 100644
--- a/app/src/main/java/io/netbird/client/ui/home/BottomDialogFragment.java
+++ b/app/src/main/java/io/netbird/client/ui/home/BottomDialogFragment.java
@@ -2,13 +2,18 @@
import android.annotation.SuppressLint;
import android.app.Dialog;
+import android.content.Context;
import android.graphics.Color;
+import android.graphics.Insets;
import android.graphics.drawable.ColorDrawable;
+import android.os.Build;
import android.os.Bundle;
import android.util.DisplayMetrics;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
+import android.view.WindowInsets;
+import android.view.WindowMetrics;
import android.widget.FrameLayout;
import androidx.annotation.NonNull;
@@ -22,6 +27,7 @@
import com.google.android.material.tabs.TabLayoutMediator;
+import io.netbird.client.PlatformUtils;
import io.netbird.client.R;
import io.netbird.client.databinding.FragmentBottomDialogBinding;
@@ -29,10 +35,13 @@
public class BottomDialogFragment extends com.google.android.material.bottomsheet.BottomSheetDialogFragment {
private FragmentBottomDialogBinding binding;
+ private boolean isRunningOnTV;
@NonNull
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
+ isRunningOnTV = PlatformUtils.isAndroidTV(requireContext());
+
// Apply transparent background theme to the dialog
BottomSheetDialog dialog = new BottomSheetDialog(requireContext(), R.style.BottomSheetDialogTheme);
dialog.setOnShowListener(dialogInterface -> {
@@ -40,28 +49,38 @@ public Dialog onCreateDialog(Bundle savedInstanceState) {
if (bottomSheet != null) {
// Set the bottom sheet to be full screen
BottomSheetBehavior behavior = BottomSheetBehavior.from(bottomSheet);
+
+ behavior.setFitToContents(false);
+
behavior.setState(BottomSheetBehavior.STATE_EXPANDED);
behavior.setSkipCollapsed(true);
-
- if(binding != null ) {
- behavior.setPeekHeight(0);
+ behavior.setPeekHeight(0);
+
+ ViewGroup.LayoutParams params = bottomSheet.getLayoutParams();
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ WindowMetrics windowMetrics = requireActivity().getWindowManager().getCurrentWindowMetrics();
+ WindowInsets windowInsets = windowMetrics.getWindowInsets();
+ Insets insets = windowInsets.getInsetsIgnoringVisibility(
+ WindowInsets.Type.systemBars()
+ );
+ params.height = windowMetrics.getBounds().height() - insets.top;
+ } else {
DisplayMetrics displayMetrics = new DisplayMetrics();
requireActivity().getWindowManager().getDefaultDisplay().getMetrics(displayMetrics);
int screenHeight = displayMetrics.heightPixels;
-
- ViewGroup.LayoutParams params = bottomSheet.getLayoutParams();
params.height = (int) (screenHeight * 0.91f);
- bottomSheet.setLayoutParams(params);
}
+
+ bottomSheet.setLayoutParams(params);
// Set the background to transparent
bottomSheet.setBackground(new ColorDrawable(Color.TRANSPARENT));
bottomSheet.requestLayout();
-
- // Remove gray background (dim)
+ // Restore dim to create overlay and enable proper touch handling
if (dialog.getWindow() != null) {
- dialog.getWindow().setDimAmount(0f);
+ dialog.getWindow().setDimAmount(0.5f);
}
}
});
@@ -84,9 +103,20 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat
// Set rounded corners background on your sheet content
view.setBackground(ContextCompat.getDrawable(requireContext(), R.drawable.rounded_top_corners));
+ // Hide close button on TV (users will use the back button to exit)
+ if (isRunningOnTV) {
+ binding.buttonClose.setVisibility(View.GONE);
+
+ // Make the root view focusable to prevent focus from going to elements behind it
+ view.setFocusable(true);
+ view.setFocusableInTouchMode(false);
+
+ binding.separator.setFocusable(false);
+ binding.separator.setFocusableInTouchMode(false);
+ }
+
binding.buttonClose.setOnClickListener(v -> dismiss());
-
setupViewPager();
}
@@ -94,9 +124,9 @@ private void setupViewPager() {
ViewPager2 viewPager = binding.peersViewPager;
TabLayout tabLayout = binding.peersTabLayout;
- PagerAdapter adapter = new PagerAdapter(this);
+ PagerAdapter adapter = new PagerAdapter(this, isRunningOnTV);
viewPager.setAdapter(adapter);
-
+
new TabLayoutMediator(tabLayout, viewPager, (tab, position) -> {
switch (position) {
case 0:
@@ -111,12 +141,40 @@ private void setupViewPager() {
tab.setText("Tab " + position);
}
}).attach();
+
+ if (isRunningOnTV) {
+ viewPager.setFocusable(false);
+ viewPager.setFocusableInTouchMode(false);
+
+ tabLayout.setFocusable(true);
+ tabLayout.setFocusableInTouchMode(false);
+
+ tabLayout.postDelayed(() -> {
+ if (!tabLayout.requestFocus()) {
+ for (int i = 0; i < tabLayout.getTabCount(); i++) {
+ View tabView = tabLayout.getTabAt(i).view;
+ if (tabView != null && tabView.requestFocus()) {
+ break;
+ }
+ }
+ }
+ }, 100);
+ } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ WindowMetrics windowMetrics = requireActivity().getWindowManager().getCurrentWindowMetrics();
+ WindowInsets windowInsets = windowMetrics.getWindowInsets();
+ Insets insets = windowInsets.getInsetsIgnoringVisibility(WindowInsets.Type.systemBars());
+ tabLayout.setPadding(
+ tabLayout.getPaddingLeft(),
+ tabLayout.getPaddingTop(),
+ tabLayout.getPaddingRight(),
+ insets.bottom
+ );
+ }
}
-
@Override
public void onDestroyView() {
super.onDestroyView();
binding = null;
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/netbird/client/ui/home/HomeFragment.java b/app/src/main/java/io/netbird/client/ui/home/HomeFragment.java
index 286c2df..8125669 100644
--- a/app/src/main/java/io/netbird/client/ui/home/HomeFragment.java
+++ b/app/src/main/java/io/netbird/client/ui/home/HomeFragment.java
@@ -16,6 +16,7 @@
import com.airbnb.lottie.LottieAnimationView;
+import io.netbird.client.PlatformUtils;
import io.netbird.client.R;
import io.netbird.client.ServiceAccessor;
import io.netbird.client.StateListener;
@@ -101,10 +102,24 @@ public View onCreateView(@NonNull LayoutInflater inflater,
// peers button
FrameLayout openPanelCardView = binding.peersBtn;
openPanelCardView.setOnClickListener(v -> {
+ // Clear focus from the button to remove highlight
+ v.clearFocus();
+
BottomDialogFragment fragment = new BottomDialogFragment();
fragment.show(getParentFragmentManager(), fragment.getTag());
});
+ if (PlatformUtils.isAndroidTV(requireContext())) {
+ binding.btnRouteChanged.setFocusable(false);
+ binding.btnRouteChanged.setFocusableInTouchMode(false);
+
+ root.postDelayed(() -> {
+ if (buttonConnect != null && buttonConnect.isEnabled()) {
+ buttonConnect.requestFocus();
+ }
+ }, 200);
+ }
+
stateListenerRegistry.registerServiceStateListener(this);
return root;
}
diff --git a/app/src/main/java/io/netbird/client/ui/home/NetworksFragment.java b/app/src/main/java/io/netbird/client/ui/home/NetworksFragment.java
index 9d39add..18f32bb 100644
--- a/app/src/main/java/io/netbird/client/ui/home/NetworksFragment.java
+++ b/app/src/main/java/io/netbird/client/ui/home/NetworksFragment.java
@@ -22,18 +22,19 @@
import java.util.ArrayList;
import java.util.List;
+import io.netbird.client.PlatformUtils;
import io.netbird.client.R;
import io.netbird.client.StateListenerRegistry;
import io.netbird.client.databinding.FragmentNetworksBinding;
public class NetworksFragment extends Fragment {
- private FragmentNetworksBinding binding;
- private NetworksAdapter adapter;
- private final List resources = new ArrayList<>();
- private final List peers = new ArrayList<>();
- private NetworksFragmentViewModel model;
- private StateListenerRegistry stateListenerRegistry;
+ private FragmentNetworksBinding binding;
+ private NetworksAdapter adapter;
+ private final List resources = new ArrayList<>();
+ private final List peers = new ArrayList<>();
+ private NetworksFragmentViewModel model;
+ private StateListenerRegistry stateListenerRegistry;
@Override
public void onAttach(@NonNull Context context) {
@@ -46,61 +47,67 @@ public void onAttach(@NonNull Context context) {
}
}
- @Nullable
- @Override
- public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
- binding = FragmentNetworksBinding.inflate(inflater, container, false);
- return binding.getRoot();
- }
-
- @Override
- public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
- super.onViewCreated(view, savedInstanceState);
-
- model = new ViewModelProvider(this,
- ViewModelProvider.Factory.from(NetworksFragmentViewModel.initializer))
- .get(NetworksFragmentViewModel.class);
- stateListenerRegistry.registerServiceStateListener(model);
+ @Nullable
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
+ binding = FragmentNetworksBinding.inflate(inflater, container, false);
+ return binding.getRoot();
+ }
- ZeroPeerView.setupLearnWhyClick(binding.zeroPeerLayout, requireContext());
+ @Override
+ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+
+ model = new ViewModelProvider(this,
+ ViewModelProvider.Factory.from(NetworksFragmentViewModel.initializer))
+ .get(NetworksFragmentViewModel.class);
+ stateListenerRegistry.registerServiceStateListener(model);
+
+ if (PlatformUtils.isAndroidTV(requireContext())) {
+ binding.zeroPeerLayout.btnLearnWhy.setVisibility(View.GONE);
+ binding.searchView.setFocusable(false);
+ binding.searchView.setFocusableInTouchMode(false);
+ } else {
+ ZeroPeerView.setupLearnWhyClick(binding.zeroPeerLayout, requireContext());
+ }
- adapter = new NetworksAdapter(resources, peers, this::routeSwitchToggleHandler);
+ adapter = new NetworksAdapter(resources, peers, this::routeSwitchToggleHandler);
- RecyclerView resourcesRecyclerView = binding.networksRecyclerView;
- resourcesRecyclerView.setAdapter(adapter);
- resourcesRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext()));
+ RecyclerView resourcesRecyclerView = binding.networksRecyclerView;
+ resourcesRecyclerView.setAdapter(adapter);
+ resourcesRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext()));
- model.getUiState().observe(getViewLifecycleOwner(), uiState -> {
- resources.clear();
- resources.addAll(uiState.getResources());
+ model.getUiState().observe(getViewLifecycleOwner(), uiState -> {
+ resources.clear();
+ resources.addAll(uiState.getResources());
- peers.clear();
- peers.addAll(uiState.getPeers());
+ peers.clear();
+ peers.addAll(uiState.getPeers());
- updateResourcesCounter(resources);
- ZeroPeerView.updateVisibility(binding.zeroPeerLayout, binding.networksList, !resources.isEmpty());
- adapter.notifyDataSetChanged();
- adapter.filterBySearchQuery(binding.searchView.getText().toString());
- });
+ updateResourcesCounter(resources);
+ ZeroPeerView.updateVisibility(binding.zeroPeerLayout, binding.networksList, !resources.isEmpty());
+ adapter.notifyDataSetChanged();
+ adapter.filterBySearchQuery(binding.searchView.getText().toString());
+ });
- binding.searchView.clearFocus();
+ binding.searchView.clearFocus();
binding.searchView.addTextChangedListener(new TextWatcher() {
- @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
- @Override public void onTextChanged(CharSequence s, int start, int before, int count) {
- adapter.filterBySearchQuery(s.toString());
- }
- @Override public void afterTextChanged(Editable s) {}
+ @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
+ @Override public void onTextChanged(CharSequence s, int start, int before, int count) {
+ adapter.filterBySearchQuery(s.toString());
+ }
+ @Override public void afterTextChanged(Editable s) {}
});
- binding.searchView.setOnFocusChangeListener((v, hasFocus) -> {
- if (hasFocus) {
- binding.searchView.setCompoundDrawablesWithIntrinsicBounds(null, null, null, null);
- } else {
- Drawable icon = ContextCompat.getDrawable(requireContext(), R.drawable.search);
- binding.searchView.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null);
- }
- });
- }
+ binding.searchView.setOnFocusChangeListener((v, hasFocus) -> {
+ if (hasFocus) {
+ binding.searchView.setCompoundDrawablesWithIntrinsicBounds(null, null, null, null);
+ } else {
+ Drawable icon = ContextCompat.getDrawable(requireContext(), R.drawable.search);
+ binding.searchView.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null);
+ }
+ });
+ }
@Override
public void onDestroyView() {
@@ -109,26 +116,26 @@ public void onDestroyView() {
}
private void updateResourcesCounter(List resources) {
- TextView textPeersCount = binding.textOpenPanel;
- int connected = 0;
-
- for (var resource : resources) {
- if (resource.isSelected()) {
- connected++;
- }
- }
-
- String text = getString(R.string.resources_connected, connected, resources.size());
- textPeersCount.post(() ->
- textPeersCount.setText(Html.fromHtml(text, Html.FROM_HTML_MODE_LEGACY))
- );
- }
-
- private void routeSwitchToggleHandler(String route, boolean isChecked) throws Exception {
- if (isChecked) {
- model.selectRoute(route);
- } else {
- model.deselectRoute(route);
- }
- }
+ TextView textPeersCount = binding.textOpenPanel;
+ int connected = 0;
+
+ for (var resource : resources) {
+ if (resource.isSelected()) {
+ connected++;
+ }
+ }
+
+ String text = getString(R.string.resources_connected, connected, resources.size());
+ textPeersCount.post(() ->
+ textPeersCount.setText(Html.fromHtml(text, Html.FROM_HTML_MODE_LEGACY))
+ );
+ }
+
+ private void routeSwitchToggleHandler(String route, boolean isChecked) throws Exception {
+ if (isChecked) {
+ model.selectRoute(route);
+ } else {
+ model.deselectRoute(route);
+ }
+ }
}
diff --git a/app/src/main/java/io/netbird/client/ui/home/PagerAdapter.java b/app/src/main/java/io/netbird/client/ui/home/PagerAdapter.java
index 2cdcff3..8f2cf33 100644
--- a/app/src/main/java/io/netbird/client/ui/home/PagerAdapter.java
+++ b/app/src/main/java/io/netbird/client/ui/home/PagerAdapter.java
@@ -1,26 +1,44 @@
package io.netbird.client.ui.home;
+import android.os.Bundle;
+
import androidx.annotation.NonNull;
import androidx.fragment.app.Fragment;
import androidx.viewpager2.adapter.FragmentStateAdapter;
public class PagerAdapter extends FragmentStateAdapter {
- public PagerAdapter(@NonNull Fragment fragment) {
+ private boolean isRunningOnTV;
+
+ private static final String ARG_IS_RUNNING_ON_TV = "isRunningOnTV";
+
+ public PagerAdapter(@NonNull Fragment fragment, boolean isRunningOnTV) {
super(fragment);
+ this.isRunningOnTV = isRunningOnTV;
}
@NonNull
@Override
public Fragment createFragment(int position) {
+ Fragment fragment;
switch (position) {
case 0:
- return new PeersFragment();
+ fragment = new PeersFragment();
+ break;
case 1:
- return new NetworksFragment();
+ fragment = new NetworksFragment();
+ break;
default:
- return new Fragment();
+ fragment = new PeersFragment();
+ break;
}
+
+ // Pass TV flag to fragments
+ Bundle args = new Bundle();
+ args.putBoolean(ARG_IS_RUNNING_ON_TV, isRunningOnTV);
+ fragment.setArguments(args);
+
+ return fragment;
}
@Override
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 0a54d96..deaf539 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
@@ -36,6 +36,8 @@ public class PeersFragment extends Fragment {
private ServiceAccessor serviceAccessor;
private RecyclerView peersListView;
+ private static final String ARG_IS_RUNNING_ON_TV = "isRunningOnTV";
+
@Override
public void onAttach(@NonNull Context context) {
super.onAttach(context);
@@ -56,7 +58,22 @@ public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container,
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
- ZeroPeerView.setupLearnWhyClick(binding.zeroPeerLayout, requireContext());
+ boolean isRunningOnTV = false;
+ if (getArguments() != null) {
+ isRunningOnTV = getArguments().getBoolean(ARG_IS_RUNNING_ON_TV, false);
+ }
+
+ // Hide "Learn why" button on TV and make it non-focusable
+ if (isRunningOnTV) {
+ binding.zeroPeerLayout.btnLearnWhy.setVisibility(View.GONE);
+ // Also make search and filter non-focusable on TV when drawer is open
+ binding.searchView.setFocusable(false);
+ binding.searchView.setFocusableInTouchMode(false);
+ binding.filterIcon.setFocusable(false);
+ binding.filterIcon.setFocusableInTouchMode(false);
+ } else {
+ ZeroPeerView.setupLearnWhyClick(binding.zeroPeerLayout, requireContext());
+ }
PeerInfoArray peersInfo = serviceAccessor.getPeersList();
ZeroPeerView.updateVisibility(binding.zeroPeerLayout, binding.peersList, peersInfo.size() > 0);
diff --git a/app/src/main/java/io/netbird/client/ui/server/ChangeServerFragment.java b/app/src/main/java/io/netbird/client/ui/server/ChangeServerFragment.java
index 85b9c44..893e2bd 100644
--- a/app/src/main/java/io/netbird/client/ui/server/ChangeServerFragment.java
+++ b/app/src/main/java/io/netbird/client/ui/server/ChangeServerFragment.java
@@ -6,9 +6,12 @@
import android.os.Build;
import android.os.Bundle;
import android.util.TypedValue;
+import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
+import android.view.inputmethod.EditorInfo;
+import android.view.inputmethod.InputMethodManager;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -176,6 +179,33 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat
viewModel.loginWithSetupKey(managementServerUri, setupKey);
}
});
+
+ binding.editTextServer.setOnEditorActionListener((v, actionId, event) -> {
+ if (actionId == EditorInfo.IME_ACTION_DONE ||
+ (event != null && event.getKeyCode() == KeyEvent.KEYCODE_ENTER && event.getAction() == KeyEvent.ACTION_DOWN)) {
+ hideKeyboard(v);
+ v.clearFocus();
+ return true;
+ }
+ return false;
+ });
+
+ binding.editTextSetupKey.setOnEditorActionListener((v, actionId, event) -> {
+ if (actionId == EditorInfo.IME_ACTION_DONE ||
+ (event != null && event.getKeyCode() == KeyEvent.KEYCODE_ENTER && event.getAction() == KeyEvent.ACTION_DOWN)) {
+ hideKeyboard(v);
+ v.clearFocus();
+ return true;
+ }
+ return false;
+ });
+ }
+
+ private void hideKeyboard(View view) {
+ InputMethodManager imm = (InputMethodManager) requireContext().getSystemService(Context.INPUT_METHOD_SERVICE);
+ if (imm != null) {
+ imm.hideSoftInputFromWindow(view.getWindowToken(), 0);
+ }
}
@Override
diff --git a/app/src/main/res/color/nav_item_icon_color.xml b/app/src/main/res/color/nav_item_icon_color.xml
new file mode 100644
index 0000000..47ac8b7
--- /dev/null
+++ b/app/src/main/res/color/nav_item_icon_color.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/color/nav_item_text_color.xml b/app/src/main/res/color/nav_item_text_color.xml
new file mode 100644
index 0000000..6502863
--- /dev/null
+++ b/app/src/main/res/color/nav_item_text_color.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/focus_highlight.xml b/app/src/main/res/drawable/focus_highlight.xml
new file mode 100644
index 0000000..2aa541a
--- /dev/null
+++ b/app/src/main/res/drawable/focus_highlight.xml
@@ -0,0 +1,23 @@
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/focus_highlight_button.xml b/app/src/main/res/drawable/focus_highlight_button.xml
new file mode 100644
index 0000000..077d9f4
--- /dev/null
+++ b/app/src/main/res/drawable/focus_highlight_button.xml
@@ -0,0 +1,24 @@
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index c1e60e0..1e23e1d 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -28,10 +28,12 @@
android:layout_height="0dp"
android:layout_weight="1"
android:background="@android:color/transparent"
+ android:focusable="true"
+ android:focusableInTouchMode="false"
app:headerLayout="@layout/nav_header_main"
app:itemVerticalPadding="25dp"
- app:itemIconTint="@color/nb_orange"
- app:itemTextColor="@color/nb_txt"
+ app:itemIconTint="@color/nav_item_icon_color"
+ app:itemTextColor="@color/nav_item_text_color"
app:menu="@menu/activity_main_drawer" />
+ android:orientation="vertical"
+ android:padding="12dp"
+ android:focusable="true"
+ android:focusableInTouchMode="false"
+ android:clickable="true"
+ android:background="@drawable/focus_highlight">
+ android:layout_height="wrap_content"
+ android:focusable="false" />
diff --git a/app/src/main/res/layout/dialog_always_on.xml b/app/src/main/res/layout/dialog_always_on.xml
index 01b0fb5..bbef14c 100644
--- a/app/src/main/res/layout/dialog_always_on.xml
+++ b/app/src/main/res/layout/dialog_always_on.xml
@@ -40,7 +40,10 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:background="@drawable/btn_bg_orange"
- android:layout_marginTop="24dp" />
+ android:layout_marginTop="24dp"
+ android:focusable="true"
+ android:focusableInTouchMode="false"
+ android:foreground="@drawable/focus_highlight" />
diff --git a/app/src/main/res/layout/dialog_confirm_change_server.xml b/app/src/main/res/layout/dialog_confirm_change_server.xml
index ace9592..0710b07 100644
--- a/app/src/main/res/layout/dialog_confirm_change_server.xml
+++ b/app/src/main/res/layout/dialog_confirm_change_server.xml
@@ -53,7 +53,10 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:background="@drawable/btn_bg_orange"
- android:layout_marginTop="24dp" />
+ android:layout_marginTop="24dp"
+ android:focusable="true"
+ android:focusableInTouchMode="false"
+ android:foreground="@drawable/focus_highlight" />
+ android:layout_marginTop="12dp"
+ android:focusable="true"
+ android:focusableInTouchMode="false"
+ android:foreground="@drawable/focus_highlight" />
diff --git a/app/src/main/res/layout/dialog_qr_code.xml b/app/src/main/res/layout/dialog_qr_code.xml
new file mode 100644
index 0000000..455b18c
--- /dev/null
+++ b/app/src/main/res/layout/dialog_qr_code.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_advanced.xml b/app/src/main/res/layout/fragment_advanced.xml
index 23d5013..0382b0f 100644
--- a/app/src/main/res/layout/fragment_advanced.xml
+++ b/app/src/main/res/layout/fragment_advanced.xml
@@ -47,6 +47,8 @@
android:padding="16dp"
android:textColor="@color/nb_txt"
android:textColorHint="@color/nb_txt_light"
+ android:focusable="true"
+ android:focusableInTouchMode="true"
app:layout_constraintTop_toBottomOf="@id/text_server_label"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
@@ -59,6 +61,9 @@
android:text="@string/advanced_save"
android:textAllCaps="false"
android:background="@drawable/btn_bg_orange"
+ android:focusable="true"
+ android:focusableInTouchMode="false"
+ android:foreground="@drawable/focus_highlight"
app:layout_constraintTop_toBottomOf="@id/preshared_key"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
@@ -81,6 +86,11 @@
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginTop="24dp"
+ android:padding="12dp"
+ android:focusable="true"
+ android:focusableInTouchMode="false"
+ android:clickable="true"
+ android:background="@drawable/focus_highlight"
app:layout_constraintTop_toBottomOf="@id/separator"
app:layout_constraintEnd_toEndOf="parent">
@@ -97,7 +107,8 @@
+ android:layout_height="wrap_content"
+ android:focusable="false" />