Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
9 changes: 9 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,17 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="com.google.android.gms.permission.AD_ID" />

<uses-feature
android:name="android.software.leanback"
android:required="false" />
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />

<application
android:name=".MyApplication"
android:allowBackup="false"
android:banner="@mipmap/ic_banner"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
Expand All @@ -32,6 +40,7 @@
<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
</application>
Expand Down
3 changes: 1 addition & 2 deletions app/src/main/java/io/netbird/client/CustomTabURLOpener.java
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ public boolean isOpened() {
return isOpened;
}


@Override
public void onLoginSuccess() {
Log.d(TAG, "onLoginSuccess fired.");
Expand All @@ -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();
Expand Down
141 changes: 127 additions & 14 deletions app/src/main/java/io/netbird/client/MainActivity.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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 {
Expand All @@ -68,9 +71,11 @@ private enum ConnectionState {

private ActivityResultLauncher<Intent> vpnActivityResultLauncher;
private final List<StateListener> 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;
Expand Down Expand Up @@ -102,13 +107,54 @@ 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;
NavigationView navigationView = binding.navView;

// 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.
Expand All @@ -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(
Expand All @@ -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);
}
}
});
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -260,7 +337,7 @@ public void switchConnection(boolean status) {
if (prepareIntent != null) {
vpnActivityResultLauncher.launch(prepareIntent);
} else {
mBinder.runEngine(urlOpener);
mBinder.runEngine(urlOpener, isRunningOnTV);
}
}

Expand Down Expand Up @@ -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);
}
}
20 changes: 20 additions & 0 deletions app/src/main/java/io/netbird/client/PlatformUtils.java
Original file line number Diff line number Diff line change
@@ -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;
}
}

84 changes: 84 additions & 0 deletions app/src/main/java/io/netbird/client/QrCodeDialog.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
Loading
Loading