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);