diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 9ff75dc6e..af284a465 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -5,6 +5,7 @@
+
diff --git a/app/src/main/java/fr/gaulupeau/apps/Poche/network/WallabagConnection.java b/app/src/main/java/fr/gaulupeau/apps/Poche/network/WallabagConnection.java
index 8ea351182..a85847bfb 100644
--- a/app/src/main/java/fr/gaulupeau/apps/Poche/network/WallabagConnection.java
+++ b/app/src/main/java/fr/gaulupeau/apps/Poche/network/WallabagConnection.java
@@ -3,6 +3,9 @@
import android.content.Context;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
+import android.net.Uri;
+import android.os.Handler;
+import android.os.Looper;
import android.text.TextUtils;
import android.util.Log;
@@ -13,6 +16,7 @@
import java.io.IOException;
import java.net.CookieManager;
import java.net.CookiePolicy;
+import java.net.InetAddress;
import java.security.Security;
import java.util.concurrent.TimeUnit;
@@ -137,6 +141,120 @@ public static boolean isNetworkAvailable() {
return networkInfo != null && networkInfo.isConnectedOrConnecting();
}
+ public static boolean isLocalNetworkUrl(String urlString) {
+ if (urlString == null || urlString.isEmpty()) return false;
+ try {
+ Uri uri = Uri.parse(urlString);
+ String host = uri.getHost();
+ if (host == null) return false;
+
+ host = host.toLowerCase();
+
+ if (host.equals("localhost") || host.equals("127.0.0.1") || host.equals("::1")) {
+ return true;
+ }
+
+ // Common local domain suffixes
+ if (host.endsWith(".local") || host.endsWith(".lan") || host.endsWith(".home")
+ || host.endsWith(".internal") || host.endsWith(".fritz.box")) {
+ return true;
+ }
+
+ // Check if it's a numeric IP address and if it's in private ranges
+ if (isPrivateIPv4(host)) {
+ return true;
+ }
+
+ // IPv6 unique local address (fc00::/7) or link-local (fe80::/10)
+ if (host.startsWith("fc") || host.startsWith("fd") || host.startsWith("fe80")) {
+ return true;
+ }
+
+ } catch (Exception e) {
+ Log.e(TAG, "isLocalNetworkUrl() error parsing URL: " + urlString, e);
+ }
+ return false;
+ }
+
+ public interface LocalNetworkCheckCallback {
+ void onResult(boolean isLocal);
+ }
+
+ public static void checkIsLocalNetworkUrl(final String urlString, final LocalNetworkCheckCallback callback) {
+ if (urlString == null || urlString.isEmpty() || urlString.equals("https://") || urlString.equals("http://")) {
+ callback.onResult(false);
+ return;
+ }
+
+ // Fast path for string-based check
+ if (isLocalNetworkUrl(urlString)) {
+ callback.onResult(true);
+ return;
+ }
+
+ try {
+ Uri uri = Uri.parse(urlString);
+ String host = uri.getHost();
+ if (host == null || host.isEmpty()) {
+ callback.onResult(false);
+ return;
+ }
+ } catch (Exception e) {
+ callback.onResult(false);
+ return;
+ }
+
+ // Slow path: DNS resolution
+ new Thread(new Runnable() {
+ @Override
+ public void run() {
+ boolean isLocal = false;
+ try {
+ Uri uri = Uri.parse(urlString);
+ String host = uri.getHost();
+ if (host != null) {
+ InetAddress[] addresses = InetAddress.getAllByName(host);
+ for (InetAddress addr : addresses) {
+ if (addr.isSiteLocalAddress() || addr.isLoopbackAddress() || addr.isLinkLocalAddress()) {
+ isLocal = true;
+ break;
+ }
+ }
+ }
+ } catch (Exception e) {
+ Log.w(TAG, "checkIsLocalNetworkUrl() DNS resolution failed for " + urlString);
+ }
+
+ final boolean result = isLocal;
+ new Handler(Looper.getMainLooper()).post(new Runnable() {
+ @Override
+ public void run() {
+ callback.onResult(result);
+ }
+ });
+ }
+ }).start();
+ }
+
+ private static boolean isPrivateIPv4(String host) {
+ // Simple regex for IPv4
+ String ipv4Pattern = "^(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})$";
+ if (host.matches(ipv4Pattern)) {
+ try {
+ String[] parts = host.split("\\.");
+ int first = Integer.parseInt(parts[0]);
+ int second = Integer.parseInt(parts[1]);
+ if (first == 10) return true;
+ if (first == 172 && second >= 16 && second <= 31) return true;
+ if (first == 192 && second == 168) return true;
+ if (first == 169 && second == 254) return true; // link-local
+ } catch (NumberFormatException e) {
+ return false;
+ }
+ }
+ return false;
+ }
+
public static OkHttpClient createClient() {
return createClient(true);
}
diff --git a/app/src/main/java/fr/gaulupeau/apps/Poche/ui/MainActivity.java b/app/src/main/java/fr/gaulupeau/apps/Poche/ui/MainActivity.java
index 3af10cf98..3afae69c8 100644
--- a/app/src/main/java/fr/gaulupeau/apps/Poche/ui/MainActivity.java
+++ b/app/src/main/java/fr/gaulupeau/apps/Poche/ui/MainActivity.java
@@ -1,11 +1,14 @@
package fr.gaulupeau.apps.Poche.ui;
+import android.Manifest;
import android.annotation.SuppressLint;
import android.app.SearchManager;
import android.content.Context;
import android.content.Intent;
+import android.content.pm.PackageManager;
import android.content.res.ColorStateList;
import android.content.res.XmlResourceParser;
+import android.os.Build;
import android.os.Bundle;
import android.text.Html;
import android.text.Spanned;
@@ -86,6 +89,7 @@ public class MainActivity extends AppCompatActivity
private static final String FRAGMENT_ARTICLE_LISTS = "fragment_article_lists";
private static final String FRAGMENT_TAG_LIST = "fragment_tag_list";
private static final String FRAGMENT_TAGGED_ARTICLE_LISTS = "fragment_tagged_article_lists";
+ private static final int REQUEST_LOCAL_NETWORK = 1001;
private Settings settings;
@@ -128,6 +132,8 @@ protected void onCreate(Bundle savedInstanceState) {
setContentView(R.layout.activity_main);
+ settings = App.getSettings();
+
WindowInsetsControllerCompat windowInsetsController =
WindowCompat.getInsetsController(getWindow(), getWindow().getDecorView());
windowInsetsController.setAppearanceLightStatusBars(Themes.getCurrentTheme() == Themes.Theme.LIGHT
@@ -136,8 +142,6 @@ protected void onCreate(Bundle savedInstanceState) {
setDefaultKeyMode(DEFAULT_KEYS_SEARCH_LOCAL);
- settings = App.getSettings();
-
getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) {
@Override
public void handleOnBackPressed() {
@@ -332,6 +336,25 @@ protected void onStart() {
tryToUpdateOnResume = true;
}
+ private void requestLocalNetworkPermissionIfNeeded() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) { // API 37
+ if (checkSelfPermission(Manifest.permission.ACCESS_LOCAL_NETWORK) != PackageManager.PERMISSION_GRANTED) {
+ if (settings != null && settings.isConfigurationOk() && settings.getUrl() != null) {
+ WallabagConnection.checkIsLocalNetworkUrl(settings.getUrl(), isLocal -> {
+ if (isLocal) {
+ if (checkSelfPermission(Manifest.permission.ACCESS_LOCAL_NETWORK) != PackageManager.PERMISSION_GRANTED) {
+ requestPermissions(
+ new String[]{Manifest.permission.ACCESS_LOCAL_NETWORK},
+ REQUEST_LOCAL_NETWORK
+ );
+ }
+ }
+ });
+ }
+ }
+ }
+ }
+
@Override
protected void onResume() {
super.onResume();
@@ -360,6 +383,8 @@ protected void onResume() {
}
}
+ requestLocalNetworkPermissionIfNeeded();
+
if (tryToUpdateOnResume) {
tryToUpdateOnResume = false;
@@ -931,6 +956,7 @@ private void testConfiguration() {
this, new ConfigurationTestHelper.ResultHandler() {
@Override
public void onConfigurationTestSuccess(String url) {
+ requestLocalNetworkPermissionIfNeeded();
updateAllFeedsIfDbIsEmpty();
}
diff --git a/app/src/main/java/fr/gaulupeau/apps/Poche/ui/preferences/ConnectionWizardActivity.java b/app/src/main/java/fr/gaulupeau/apps/Poche/ui/preferences/ConnectionWizardActivity.java
index 78a2b13ee..e53f30669 100644
--- a/app/src/main/java/fr/gaulupeau/apps/Poche/ui/preferences/ConnectionWizardActivity.java
+++ b/app/src/main/java/fr/gaulupeau/apps/Poche/ui/preferences/ConnectionWizardActivity.java
@@ -1,10 +1,13 @@
package fr.gaulupeau.apps.Poche.ui.preferences;
+import android.Manifest;
import android.app.Activity;
import android.content.ActivityNotFoundException;
import android.content.Context;
import android.content.Intent;
+import android.content.pm.PackageManager;
import android.net.Uri;
+import android.os.Build;
import android.os.Bundle;
import android.text.TextUtils;
import android.util.Log;
@@ -44,6 +47,7 @@ public class ConnectionWizardActivity extends BaseActionBarActivity {
private static final String TAG = "ConnectionWizard";
private static final int REQUEST_CODE_QR_CODE = 1;
+ private static final int REQUEST_LOCAL_NETWORK = 1001;
private static final String DATA_PROVIDER = "provider";
private static final String DATA_URL = "url";
@@ -191,6 +195,28 @@ protected void onActivityResult(int requestCode, int resultCode, Intent data) {
}
}
+ private void requestLocalNetworkPermissionIfNeeded(String url) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.BAKLAVA) {
+ return;
+ }
+
+ if (checkSelfPermission(Manifest.permission.ACCESS_LOCAL_NETWORK) == PackageManager.PERMISSION_GRANTED) {
+ return;
+ }
+
+ if (TextUtils.isEmpty(url)) {
+ return;
+ }
+
+ WallabagConnection.checkIsLocalNetworkUrl(url, isLocal -> {
+ if (isLocal
+ && checkSelfPermission(Manifest.permission.ACCESS_LOCAL_NETWORK) != PackageManager.PERMISSION_GRANTED) {
+ requestPermissions(new String[]{Manifest.permission.ACCESS_LOCAL_NETWORK},
+ REQUEST_LOCAL_NETWORK);
+ }
+ });
+ }
+
private ConnectionData parseLoginData(String connectionUri) {
// wallabag://user@server.tld
String prefix = "wallabag://";
@@ -512,6 +538,10 @@ protected void gatherData() {
protected void runTest() {
cancelTest();
+ if (activity != null) {
+ activity.requestLocalNetworkPermissionIfNeeded(url);
+ }
+
configurationTestHelper = new ConfigurationTestHelper(
activity, this, this, url, httpAuthUsername, httpAuthPassword,
username, password, clientID, clientSecret, tryPossibleURLs, false);