diff --git a/app/build.gradle b/app/build.gradle
index 4e073c92..507eb3d7 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -9,8 +9,8 @@ android {
applicationId "com.doubleangels.nextdnsmanagement"
minSdkVersion 32
targetSdk 35
- versionCode 235
- versionName '5.4.1'
+ versionCode 236
+ versionName '5.4.2'
resourceConfigurations += ["en", "zh", "nl", "fi", "fr", "de", "in", "it", "ja", "pl", "pt", "es", "sv", "tr"]
}
@@ -59,7 +59,7 @@ dependencies {
implementation 'com.jakewharton:process-phoenix:3.0.0'
implementation 'com.squareup.retrofit2:converter-gson:2.11.0'
implementation 'de.hdodenhof:circleimageview:3.1.0'
- implementation 'io.sentry:sentry-android:7.20.0'
+ implementation 'io.sentry:sentry-android:7.20.1'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.14'
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 1a853c3b..ecfa539d 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -10,8 +10,9 @@
= ComponentCallbacks2.TRIM_MEMORY_MODERATE) {
+ if (!isWebViewInitialized) {
+ return;
+ }
+ cleanupWebView();
+ }
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ if (webView != null) {
+ webView.onPause();
+ }
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ if (webView != null) {
+ webView.onResume();
+ } else if (!isWebViewInitialized) {
+ setupWebViewForActivity(getString(R.string.main_url));
+ }
}
// Setup toolbar for the activity
@@ -180,13 +234,38 @@ private void setupVisualIndicatorForActivity(SentryManager sentryManager, Lifecy
}
}
- // Setup WebView for the activity
+ private void cleanupWebView() {
+ if (webView != null) {
+ try {
+ // Remove all loaded content
+ webView.loadUrl("about:blank");
+
+ // Remove all views
+ webView.removeAllViews();
+
+ // Destroy the WebView
+ webView.destroy();
+ } catch (Exception e) {
+ // Silently handle any exceptions during cleanup
+ } finally {
+ webView = null;
+ isWebViewInitialized = false;
+ }
+ }
+ }
+
@SuppressLint("SetJavaScriptEnabled")
public void setupWebViewForActivity(String url) {
webView = findViewById(R.id.webView);
+ if (webViewState != null) {
+ webView.restoreState(webViewState);
+ } else {
+ webView.loadUrl(url);
+ }
WebSettings webViewSettings = webView.getSettings();
webViewSettings.setJavaScriptEnabled(true);
webViewSettings.setDomStorageEnabled(true);
+ webViewSettings.setDomStorageEnabled(true);
webViewSettings.setDatabaseEnabled(true);
webViewSettings.setCacheMode(WebSettings.LOAD_DEFAULT);
webViewSettings.setAllowFileAccess(false);
@@ -209,6 +288,7 @@ public void onPageFinished(WebView webView, String url) {
setupDownloadManagerForActivity();
// Load URL into WebView
webView.loadUrl(url);
+ isWebViewInitialized = true;
}
// Setup DownloadManager for handling file downloads
diff --git a/app/src/main/java/com/doubleangels/nextdnsmanagement/NextDNSApplication.java b/app/src/main/java/com/doubleangels/nextdnsmanagement/NextDNSApplication.java
new file mode 100644
index 00000000..a6c08227
--- /dev/null
+++ b/app/src/main/java/com/doubleangels/nextdnsmanagement/NextDNSApplication.java
@@ -0,0 +1,44 @@
+package com.doubleangels.nextdnsmanagement;
+
+import android.app.Activity;
+import android.app.Application;
+import android.os.Bundle;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AppCompatActivity;
+
+public class NextDNSApplication extends Application {
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ // Register activity lifecycle callbacks to handle theme state
+ registerActivityLifecycleCallbacks(new ActivityLifecycleCallbacks() {
+ @Override
+ public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState) {
+ if (activity instanceof AppCompatActivity appCompatActivity) { // Ensure the activity is AppCompatActivity
+ if (savedInstanceState != null) {
+ appCompatActivity.getDelegate().applyDayNight();
+ }
+ }
+ }
+ @Override
+ public void onActivityStarted(@NonNull Activity activity) {}
+
+ @Override
+ public void onActivityResumed(@NonNull Activity activity) {}
+
+ @Override
+ public void onActivityPaused(@NonNull Activity activity) {}
+
+ @Override
+ public void onActivityStopped(@NonNull Activity activity) {}
+
+ @Override
+ public void onActivitySaveInstanceState(@NonNull Activity activity, @NonNull Bundle outState) {}
+
+ @Override
+ public void onActivityDestroyed(@NonNull Activity activity) {}
+ });
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/doubleangels/nextdnsmanagement/PermissionActivity.java b/app/src/main/java/com/doubleangels/nextdnsmanagement/PermissionActivity.java
index 14ad45e0..c6af4fef 100644
--- a/app/src/main/java/com/doubleangels/nextdnsmanagement/PermissionActivity.java
+++ b/app/src/main/java/com/doubleangels/nextdnsmanagement/PermissionActivity.java
@@ -5,17 +5,17 @@
import android.content.pm.PackageManager;
import android.content.pm.PermissionInfo;
import android.content.res.Configuration;
+import android.os.Build;
import android.os.Bundle;
-import android.view.ContextThemeWrapper;
import android.view.Menu;
-import android.view.MenuInflater;
import android.view.MenuItem;
import android.widget.ImageView;
+import androidx.annotation.NonNull;
+import androidx.annotation.RequiresApi;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.widget.Toolbar;
-import androidx.lifecycle.LifecycleOwner;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
@@ -28,43 +28,57 @@
import java.util.List;
import java.util.Locale;
+@RequiresApi(api = Build.VERSION_CODES.TIRAMISU)
public class PermissionActivity extends AppCompatActivity {
+ private static final int REQUEST_POST_NOTIFICATIONS = 100;
+ private static final String POST_NOTIFICATIONS = android.Manifest.permission.POST_NOTIFICATIONS;
- // SentryManager instance for error tracking
- public SentryManager sentryManager;
+ private SentryManager sentryManager;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_permission);
- // Initialize SentryManager for error tracking
+
sentryManager = new SentryManager(this);
try {
- // Check if Sentry is enabled and initialize it
if (sentryManager.isEnabled()) {
SentryInitializer.initialize(this);
}
- // Setup toolbar
setupToolbarForActivity();
- // Setup language/locale
String appLocale = setupLanguageForActivity();
sentryManager.captureMessage("Using locale: " + appLocale);
- // Setup visual indicator
- setupVisualIndicatorForActivity(sentryManager, this);
+ setupVisualIndicatorForActivity(sentryManager);
+
+ if (needsNotificationPermission()) {
+ requestNotificationPermission();
+ }
} catch (Exception e) {
- // Catch and log exceptions
sentryManager.captureException(e);
}
- // Setup RecyclerView for displaying permissions list
+
RecyclerView recyclerView = findViewById(R.id.permissionRecyclerView);
recyclerView.setLayoutManager(new LinearLayoutManager(this));
- // Get list of permissions and set up RecyclerView adapter
List permissions = getPermissionsList(sentryManager);
- PermissionsAdapter adapter = new PermissionsAdapter(permissions);
- recyclerView.setAdapter(adapter);
+ recyclerView.setAdapter(new PermissionsAdapter(permissions));
+ }
+
+ private boolean needsNotificationPermission() {
+ return checkSelfPermission(POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED;
+ }
+
+ private void requestNotificationPermission() {
+ requestPermissions(new String[]{POST_NOTIFICATIONS}, REQUEST_POST_NOTIFICATIONS);
+ }
+
+ @Override
+ public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+ if (requestCode == REQUEST_POST_NOTIFICATIONS) {
+ refreshPermissionsList();
+ }
}
- // Setup toolbar for the activity
private void setupToolbarForActivity() {
Toolbar toolbar = findViewById(R.id.toolbar);
setSupportActionBar(toolbar);
@@ -72,68 +86,63 @@ private void setupToolbarForActivity() {
if (actionBar != null) {
actionBar.setDisplayShowTitleEnabled(false);
}
- // Setup click listener for connection status ImageView
+
ImageView imageView = findViewById(R.id.connectionStatus);
imageView.setOnClickListener(v -> startActivity(new Intent(this, StatusActivity.class)));
}
- // Setup language/locale for the activity
private String setupLanguageForActivity() {
- Configuration config = getResources().getConfiguration();
- Locale appLocale = config.getLocales().get(0);
+ Locale appLocale = Locale.getDefault();
Locale.setDefault(appLocale);
- Configuration newConfig = new Configuration(config);
- newConfig.setLocale(appLocale);
- new ContextThemeWrapper(getBaseContext(), R.style.AppTheme).applyOverrideConfiguration(newConfig);
+ Configuration config = getResources().getConfiguration();
+ config.setLocale(appLocale);
+ getResources().updateConfiguration(config, getResources().getDisplayMetrics());
return appLocale.getLanguage();
}
- // Setup visual indicator for the activity
- private void setupVisualIndicatorForActivity(SentryManager sentryManager, LifecycleOwner lifecycleOwner) {
+ private void setupVisualIndicatorForActivity(SentryManager sentryManager) {
try {
- new VisualIndicator(this).initialize(this, lifecycleOwner, this);
+ new VisualIndicator(this).initialize(this, this, this);
} catch (Exception e) {
- // Catch and log exceptions
sentryManager.captureException(e);
}
}
- // Retrieve the list of permissions requested by the app
+ private void refreshPermissionsList() {
+ RecyclerView recyclerView = findViewById(R.id.permissionRecyclerView);
+ if (recyclerView != null) {
+ List permissions = getPermissionsList(sentryManager);
+ recyclerView.setAdapter(new PermissionsAdapter(permissions));
+ }
+ }
+
private List getPermissionsList(SentryManager sentryManager) {
List permissions = new ArrayList<>();
try {
- // Get package info including requested permissions
PackageInfo packageInfo = getPackageManager().getPackageInfo(getPackageName(), PackageManager.GET_PERMISSIONS);
if (packageInfo.requestedPermissions != null) {
- // Retrieve PermissionInfo for each requested permission and add to list
for (String permission : packageInfo.requestedPermissions) {
PermissionInfo permissionInfo = getPackageManager().getPermissionInfo(permission, 0);
permissions.add(permissionInfo);
}
}
} catch (PackageManager.NameNotFoundException e) {
- // Catch and log exceptions
sentryManager.captureException(e);
}
return permissions;
}
- // Inflate menu for the activity
- @Override
public boolean onCreateOptionsMenu(Menu menu) {
- MenuInflater inflater = getMenuInflater();
- inflater.inflate(R.menu.menu_back_only, menu);
+ getMenuInflater().inflate(R.menu.menu_back_only, menu);
return true;
}
- // Handle menu item selection
@Override
public boolean onOptionsItemSelected(MenuItem item) {
if (item.getItemId() == R.id.back) {
- // Navigate back to SettingsActivity
- Intent mainIntent = new Intent(this, SettingsActivity.class);
- startActivity(mainIntent);
+ startActivity(new Intent(this, SettingsActivity.class));
+ return true;
}
- return super.onContextItemSelected(item);
+ return super.onOptionsItemSelected(item);
}
}
diff --git a/app/src/main/java/com/doubleangels/nextdnsmanagement/adaptors/PermissionsAdapter.java b/app/src/main/java/com/doubleangels/nextdnsmanagement/adaptors/PermissionsAdapter.java
index fcbc7bf2..3712c8d9 100644
--- a/app/src/main/java/com/doubleangels/nextdnsmanagement/adaptors/PermissionsAdapter.java
+++ b/app/src/main/java/com/doubleangels/nextdnsmanagement/adaptors/PermissionsAdapter.java
@@ -1,6 +1,7 @@
package com.doubleangels.nextdnsmanagement.adaptors;
import android.annotation.SuppressLint;
+import android.content.pm.PackageManager;
import android.content.pm.PermissionInfo;
import android.view.LayoutInflater;
import android.view.View;
@@ -41,13 +42,32 @@ public PermissionViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int vi
public void onBindViewHolder(@NonNull PermissionViewHolder holder, int position) {
// Get the PermissionInfo object at the given position
PermissionInfo permissionInfo = permissions.get(position);
+
+ // Check if this is a runtime permission that needs to be checked
+ boolean isGranted = true;
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
+ if (permissionInfo.name.equals(android.Manifest.permission.POST_NOTIFICATIONS)) {
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
+ isGranted = holder.itemView.getContext().checkSelfPermission(permissionInfo.name)
+ == PackageManager.PERMISSION_GRANTED;
+ }
+ }
+ }
+
// Set the permission name in the TextView
holder.permissionName.setText(permissionInfo.loadLabel(holder.itemView.getContext().getPackageManager()).toString().toUpperCase());
+
// Set the permission description in the TextView
CharSequence description = permissionInfo.loadDescription(holder.itemView.getContext().getPackageManager());
- if (description != null && !description.toString().endsWith(".")) {
- holder.permissionDescription.setText((description + ".").toUpperCase());
+ String displayText;
+ if (description == null) {
+ displayText = "";
} else {
+ displayText = description.toString();
+ if (!displayText.endsWith(".")) {
+ displayText += ".";
+ }
+ displayText = displayText.toUpperCase() + (isGranted ? " (GRANTED)" : " (NOT GRANTED)");
holder.permissionDescription.setText(description);
}
}
diff --git a/app/src/main/java/com/doubleangels/nextdnsmanagement/protocol/VisualIndicator.java b/app/src/main/java/com/doubleangels/nextdnsmanagement/protocol/VisualIndicator.java
index 30c6f7ea..a5d2efe2 100644
--- a/app/src/main/java/com/doubleangels/nextdnsmanagement/protocol/VisualIndicator.java
+++ b/app/src/main/java/com/doubleangels/nextdnsmanagement/protocol/VisualIndicator.java
@@ -16,6 +16,8 @@
import com.doubleangels.nextdnsmanagement.R;
import com.doubleangels.nextdnsmanagement.sentry.SentryManager;
+import com.doubleangels.nextdnsmanagement.utils.DNSResolver;
+
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
@@ -181,8 +183,21 @@ private void setConnectionStatus(ImageView connectionStatus, int drawableResId,
connectionStatus.setColorFilter(ContextCompat.getColor(context, colorResId));
}
+
// Method to catch and handle network errors
private void catchNetworkErrors(@NonNull Exception e) {
+ // Special handling for UnknownHostException
+ if (e instanceof UnknownHostException) {
+ String hostname = extractHostname(e.getMessage());
+ if (hostname != null && hostname.endsWith("test.nextdns.io")) {
+ // Attempt one more DNS resolution
+ if (DNSResolver.resolveWithRetry(hostname)) {
+ sentryManager.captureMessage("DNS resolution succeeded on retry for: " + hostname);
+ return;
+ }
+ }
+ }
+
// Check type of network exception and capture message or exception
if (e instanceof UnknownHostException ||
e instanceof SocketTimeoutException ||
@@ -194,4 +209,14 @@ private void catchNetworkErrors(@NonNull Exception e) {
sentryManager.captureException(e);
}
}
+
+ private String extractHostname(String message) {
+ if (message == null) return null;
+ int startIndex = message.indexOf("\"");
+ int endIndex = message.lastIndexOf("\"");
+ if (startIndex >= 0 && endIndex > startIndex) {
+ return message.substring(startIndex + 1, endIndex);
+ }
+ return null;
+ }
}
diff --git a/app/src/main/java/com/doubleangels/nextdnsmanagement/utils/DNSResolver.java b/app/src/main/java/com/doubleangels/nextdnsmanagement/utils/DNSResolver.java
new file mode 100644
index 00000000..0c86414e
--- /dev/null
+++ b/app/src/main/java/com/doubleangels/nextdnsmanagement/utils/DNSResolver.java
@@ -0,0 +1,61 @@
+package com.doubleangels.nextdnsmanagement.utils;
+
+import androidx.annotation.NonNull;
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+
+public class DNSResolver {
+ private static final int MAX_RETRIES = 3;
+ private static final int RETRY_DELAY_MS = 1000;
+ private static final String BASE_DOMAIN = "test.nextdns.io";
+
+ /**
+ * Validates if the provided hostname is a valid NextDNS subdomain
+ *
+ * @param hostname The hostname to validate
+ * @return boolean indicating if the hostname is valid
+ */
+ public static boolean isValidNextDNSSubdomain(String hostname) {
+ if (hostname == null || hostname.isEmpty()) {
+ return false;
+ }
+
+ // Validate that it's a subdomain of test.nextdns.io
+ if (!hostname.endsWith(BASE_DOMAIN)) {
+ return false;
+ }
+
+ // For subdomains, validate the format (alphanumeric characters only)
+ if (hostname.length() > BASE_DOMAIN.length() + 1) {
+ String subdomain = hostname.substring(0, hostname.length() - BASE_DOMAIN.length() - 1);
+ return subdomain.matches("^[a-zA-Z0-9]+$");
+ }
+
+ return hostname.equals(BASE_DOMAIN);
+ }
+
+ /**
+ * Attempts to resolve a hostname with retries
+ *
+ * @param hostname The hostname to resolve
+ * @return boolean indicating if resolution was successful
+ */
+ public static boolean resolveWithRetry(@NonNull String hostname) {
+ for (int attempt = 0; attempt < MAX_RETRIES; attempt++) {
+ try {
+ InetAddress.getAllByName(hostname);
+ return true;
+ } catch (UnknownHostException e) {
+ if (attempt < MAX_RETRIES - 1) {
+ try {
+ Thread.sleep(RETRY_DELAY_MS * (attempt + 1));
+ } catch (InterruptedException ie) {
+ Thread.currentThread().interrupt();
+ return false;
+ }
+ }
+ }
+ }
+ return false;
+ }
+}
\ No newline at end of file