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" />