From a97c1af0882f74813d0b6c218947f512056859d5 Mon Sep 17 00:00:00 2001 From: Ashley Mensah Date: Thu, 6 Nov 2025 16:45:42 +0100 Subject: [PATCH 1/5] feat/add-android-tv-support Add android TV support with the following changes: - Implement new URLOpener interface with updated open method that accepts UserCode - Add logic to MainActivity.java to detect Android TV, adjust UI and SSO flow accordingly - Add QRCodeDialog.java to handle QR code/user code display logic - Updated relevant layout xml files - Add TV banner icon --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 12 ++ .../io/netbird/client/CustomTabURLOpener.java | 3 +- .../java/io/netbird/client/MainActivity.java | 136 ++++++++++++++++-- .../java/io/netbird/client/QrCodeDialog.java | 68 +++++++++ .../client/ui/advanced/AdvancedFragment.java | 45 ++++++ .../main/res/color/nav_item_icon_color.xml | 10 ++ .../main/res/color/nav_item_text_color.xml | 13 ++ app/src/main/res/drawable/focus_highlight.xml | 23 +++ .../res/drawable/focus_highlight_button.xml | 24 ++++ app/src/main/res/layout/activity_main.xml | 6 +- app/src/main/res/layout/component_switch.xml | 10 +- app/src/main/res/layout/dialog_always_on.xml | 5 +- .../layout/dialog_confirm_change_server.xml | 10 +- app/src/main/res/layout/dialog_qr_code.xml | 32 +++++ app/src/main/res/layout/fragment_advanced.xml | 104 ++++++++++++-- .../main/res/layout/fragment_firstinstall.xml | 3 + app/src/main/res/layout/fragment_home.xml | 107 +++++++++----- app/src/main/res/layout/fragment_peers.xml | 7 +- app/src/main/res/layout/fragment_server.xml | 42 +++--- app/src/main/res/layout/list_item_peer.xml | 6 +- app/src/main/res/mipmap-hdpi/ic_banner.webp | Bin 0 -> 1268 bytes app/src/main/res/mipmap-mdpi/ic_banner.webp | Bin 0 -> 874 bytes app/src/main/res/mipmap-xhdpi/ic_banner.png | Bin 3577 -> 0 bytes app/src/main/res/mipmap-xhdpi/ic_banner.webp | Bin 0 -> 1590 bytes app/src/main/res/mipmap-xxhdpi/ic_banner.webp | Bin 0 -> 2604 bytes .../main/res/mipmap-xxxhdpi/ic_banner.webp | Bin 0 -> 3046 bytes gradle/libs.versions.toml | 2 + netbird | 2 +- 29 files changed, 580 insertions(+), 91 deletions(-) create mode 100644 app/src/main/java/io/netbird/client/QrCodeDialog.java create mode 100644 app/src/main/res/color/nav_item_icon_color.xml create mode 100644 app/src/main/res/color/nav_item_text_color.xml create mode 100644 app/src/main/res/drawable/focus_highlight.xml create mode 100644 app/src/main/res/drawable/focus_highlight_button.xml create mode 100644 app/src/main/res/layout/dialog_qr_code.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_banner.webp create mode 100644 app/src/main/res/mipmap-mdpi/ic_banner.webp delete mode 100644 app/src/main/res/mipmap-xhdpi/ic_banner.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_banner.webp create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_banner.webp create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_banner.webp 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..0c9218d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,9 +11,19 @@ + + + + + + + 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..c178f5e 100644 --- a/app/src/main/java/io/netbird/client/MainActivity.java +++ b/app/src/main/java/io/netbird/client/MainActivity.java @@ -14,11 +14,13 @@ 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; import android.widget.TextView; import android.widget.Toast; +import android.app.UiModeManager; import java.util.ArrayList; import java.util.List; @@ -30,6 +32,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 +49,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 +72,10 @@ private enum ConnectionState { private ActivityResultLauncher vpnActivityResultLauncher; private final List serviceStateListeners = new ArrayList<>(); - private CustomTabURLOpener urlOpener; + private URLOpener urlOpener; private boolean isSSOFinishedWell = false; + private boolean isRunningOnTV = false; // Last known state for UI updates private ConnectionState lastKnownState = ConnectionState.UNKNOWN; @@ -102,6 +107,12 @@ protected void onCreate(Bundle savedInstanceState) { setContentView(binding.getRoot()); setSupportActionBar(binding.appBarMain.toolbar); + // Detect if running on Android TV + isRunningOnTV = isRunningOnAndroidTV(); + if (isRunningOnTV) { + Log.i(LOGTAG, "Running on Android TV - optimizing for D-pad navigation"); + } + setVersionText(); DrawerLayout drawer = binding.drawerLayout; @@ -109,6 +120,46 @@ 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 NavigationView when drawer opens + // Use postDelayed to ensure the drawer animation finishes + navigationView.postDelayed(() -> { + // First, make sure the NavigationView itself can receive focus + navigationView.setFocusable(true); + navigationView.setFocusableInTouchMode(false); + + // Request focus on the NavigationView + if (!navigationView.requestFocus()) { + Log.d(LOGTAG, "NavigationView couldn't get focus, trying menu items"); + } + + // Also 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) { + // Try to find any focusable descendant + menuView.requestFocus(); + } + } + }, 100); // Small delay to let drawer animation finish + } + + @Override + public void onDrawerClosed(View drawerView) { + // When drawer closes, return focus to main content + 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 +178,31 @@ 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.newInstance(url, userCode).show(getSupportFragmentManager(), "QrCodeDialog"); + } + + @Override + public void onLoginSuccess() { + Log.d(LOGTAG, "onLoginSuccess fired for TV."); + // For TV, we don't need to do anything here as the user is already in the app + } + }; + } // VPN permission result launcher vpnActivityResultLauncher = registerForActivityResult( @@ -194,7 +260,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); @@ -485,4 +555,48 @@ public void onError(String msg) { }); } }; + + /** + * Detects if the app is running on Android TV. + * This helps us optimize the UI for remote control navigation. + * @return true if running on TV, false otherwise + */ + private boolean isRunningOnAndroidTV() { + UiModeManager uiModeManager = (UiModeManager) getSystemService(Context.UI_MODE_SERVICE); + if (uiModeManager != null) { + return uiModeManager.getCurrentModeType() == android.content.res.Configuration.UI_MODE_TYPE_TELEVISION; + } + return false; + } + + @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()); + + // Long-press left d-pad button to open drawer + // Left button otherwise works normally for navigation + if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT) { + // Only open drawer if long-pressed & not already open + 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; + } + } + + // Back button closes drawer if it's open + 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/QrCodeDialog.java b/app/src/main/java/io/netbird/client/QrCodeDialog.java new file mode 100644 index 0000000..844d1d8 --- /dev/null +++ b/app/src/main/java/io/netbird/client/QrCodeDialog.java @@ -0,0 +1,68 @@ +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"; + + public static QrCodeDialog newInstance(String url, String userCode) { + QrCodeDialog fragment = new QrCodeDialog(); + Bundle args = new Bundle(); + args.putString(ARG_URL, url); + args.putString(ARG_USER_CODE, userCode); + fragment.setArguments(args); + 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("Device Code: " + userCode); + userCodeTextView.setVisibility(View.VISIBLE); + } else { + userCodeTextView.setVisibility(View.GONE); + } + + closeButton.setOnClickListener(v -> dismiss()); + } +} 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/res/color/nav_item_icon_color.xml b/app/src/main/res/color/nav_item_icon_color.xml new file mode 100644 index 0000000..2644013 --- /dev/null +++ b/app/src/main/res/color/nav_item_icon_color.xml @@ -0,0 +1,10 @@ + + + + + + + + + + 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..bcbd786 --- /dev/null +++ b/app/src/main/res/color/nav_item_text_color.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + 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" />