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