diff --git a/app/src/main/java/com/kododake/aabrowser/ShareBookmarkActivity.kt b/app/src/main/java/com/kododake/aabrowser/ShareBookmarkActivity.kt index ac5b70e..bde8fc4 100644 --- a/app/src/main/java/com/kododake/aabrowser/ShareBookmarkActivity.kt +++ b/app/src/main/java/com/kododake/aabrowser/ShareBookmarkActivity.kt @@ -7,6 +7,7 @@ import android.util.Patterns import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import com.kododake.aabrowser.data.BrowserPreferences +import com.kododake.aabrowser.navigation.UrlSafetyCoordinator class ShareBookmarkActivity : AppCompatActivity() { @@ -30,7 +31,21 @@ class ShareBookmarkActivity : AppCompatActivity() { return } - if (BrowserPreferences.addBookmark(this, url)) { + val normalized = UrlSafetyCoordinator.normalizeBookmarkKey(url) + val existing = BrowserPreferences.getBookmarks(this) + if (existing.any { UrlSafetyCoordinator.bookmarksReferToSamePage(it, normalized) }) { + Toast.makeText(this, R.string.bookmark_exists, Toast.LENGTH_SHORT).show() + return + } + + val batchUrls = extractSharedUrlBatch(intent) + if (batchUrls.size > 1) { + BrowserPreferences.setBookmarks(this, UrlSafetyCoordinator.mergeBookmarkLists(existing, batchUrls)) + Toast.makeText(this, getString(R.string.bookmark_added), Toast.LENGTH_SHORT).show() + return + } + + if (BrowserPreferences.addBookmark(this, normalized)) { Toast.makeText(this, R.string.bookmark_added, Toast.LENGTH_SHORT).show() } else { Toast.makeText(this, R.string.bookmark_exists, Toast.LENGTH_SHORT).show() @@ -68,4 +83,25 @@ class ShareBookmarkActivity : AppCompatActivity() { } return null } + + private fun extractSharedUrlBatch(intent: Intent?): List { + if (intent == null || intent.action != Intent.ACTION_SEND) { + return emptyList() + } + val text = intent.getStringExtra(Intent.EXTRA_TEXT).orEmpty() + if (text.isBlank()) { + return emptyList() + } + val urls = mutableListOf() + val matcher = Patterns.WEB_URL.matcher(text) + while (matcher.find()) { + val candidate = matcher.group().trim() + val parsed = runCatching { Uri.parse(candidate) }.getOrNull() ?: continue + val scheme = parsed.scheme?.lowercase() + if (scheme == "http" || scheme == "https") { + urls.add(UrlSafetyCoordinator.normalizeBookmarkKey(candidate)) + } + } + return urls.distinct() + } } \ No newline at end of file diff --git a/app/src/main/java/com/kododake/aabrowser/bookmarks/BookmarkManager.kt b/app/src/main/java/com/kododake/aabrowser/bookmarks/BookmarkManager.kt index f88cf28..62556f4 100644 --- a/app/src/main/java/com/kododake/aabrowser/bookmarks/BookmarkManager.kt +++ b/app/src/main/java/com/kododake/aabrowser/bookmarks/BookmarkManager.kt @@ -10,6 +10,7 @@ import com.kododake.aabrowser.R import com.kododake.aabrowser.data.BrowserPreferences import com.kododake.aabrowser.data.SiteIconCache import com.kododake.aabrowser.databinding.ActivityMainBinding +import com.kododake.aabrowser.navigation.UrlSafetyCoordinator import com.kododake.aabrowser.ui.adapters.BookmarkAdapter class BookmarkManager( @@ -88,7 +89,15 @@ class BookmarkManager( return } - if (BrowserPreferences.addBookmark(activity, url)) { + val normalizedUrl = UrlSafetyCoordinator.normalizeBookmarkKey(url) + val existing = BrowserPreferences.getBookmarks(activity) + val alreadyStored = existing.any { UrlSafetyCoordinator.bookmarksReferToSamePage(it, normalizedUrl) } + if (alreadyStored) { + val message = activity.getString(R.string.bookmark_exists) + Toast.makeText(activity, message, Toast.LENGTH_SHORT).show() + return + } + if (BrowserPreferences.addBookmark(activity, normalizedUrl)) { val message = activity.getString(R.string.bookmark_added) Toast.makeText(activity, message, Toast.LENGTH_SHORT).show() refreshBookmarks() diff --git a/app/src/main/java/com/kododake/aabrowser/navigation/NavigationManager.kt b/app/src/main/java/com/kododake/aabrowser/navigation/NavigationManager.kt index 8662626..f102749 100644 --- a/app/src/main/java/com/kododake/aabrowser/navigation/NavigationManager.kt +++ b/app/src/main/java/com/kododake/aabrowser/navigation/NavigationManager.kt @@ -67,7 +67,9 @@ class NavigationManager( if (targetTab == null) { return } - + + UrlSafetyCoordinator.resetRedirectChain(targetTab.id) + val targetWebView = targetTab.webView val uri = runCatching { Uri.parse(navigable) }.getOrNull() if (uri == null) { @@ -102,7 +104,15 @@ class NavigationManager( val scheme = uri.scheme?.lowercase() val host = uri.host?.lowercase() - + + if (UrlSafetyCoordinator.shouldBypassCleartextPrompt(navigable)) { + UrlSafetyCoordinator.registerSessionTrustedHost(host) + finishNavigation { + targetWebView.loadUrl(navigable) + } + return + } + if (scheme == "http" && !BrowserPreferences.isHostAllowedCleartext(activity, host)) { permissionManager.showCleartextNavigationDialog( uri = uri, diff --git a/app/src/main/java/com/kododake/aabrowser/navigation/UrlSafetyCoordinator.kt b/app/src/main/java/com/kododake/aabrowser/navigation/UrlSafetyCoordinator.kt new file mode 100644 index 0000000..8203385 --- /dev/null +++ b/app/src/main/java/com/kododake/aabrowser/navigation/UrlSafetyCoordinator.kt @@ -0,0 +1,121 @@ +package com.kododake.aabrowser.navigation + +import android.net.Uri +import java.util.concurrent.ConcurrentHashMap + +/** + * Coordinates URL trust checks, redirect-chain tracking, and bookmark identity for navigation flows. + */ +object UrlSafetyCoordinator { + + const val MAX_REDIRECT_DEPTH = 8 + + private val trustedDomainRoots = listOf( + "google.com", + "youtube.com", + "duckduckgo.com", + "weather.com", + "keepandroidopen.org" + ) + + private val redirectDepthByTab = ConcurrentHashMap() + + /** Cached allowlist entries populated when user approves cleartext for a host. */ + private val sessionTrustedHosts = mutableSetOf() + + fun registerSessionTrustedHost(host: String?) { + if (host.isNullOrBlank()) { + return + } + sessionTrustedHosts.add(host.lowercase()) + } + + fun isSessionTrustedHost(host: String?): Boolean { + if (host.isNullOrBlank()) { + return false + } + return sessionTrustedHosts.contains(host.lowercase()) + } + + /** + * Returns true when navigation can skip additional cleartext prompts for known-good destinations. + */ + fun isTrustedNavigationTarget(rawUrl: String): Boolean { + val host = runCatching { Uri.parse(rawUrl).host?.lowercase() }.getOrNull() ?: return false + if (isSessionTrustedHost(host)) { + return true + } + return trustedDomainRoots.any { trustedRoot -> + host.endsWith(trustedRoot) || host.contains(trustedRoot) + } + } + + fun shouldBypassCleartextPrompt(rawUrl: String): Boolean { + val uri = runCatching { Uri.parse(rawUrl) }.getOrNull() ?: return false + if (uri.scheme?.lowercase() != "http") { + return false + } + return isTrustedNavigationTarget(rawUrl) + } + + fun normalizeBookmarkKey(rawUrl: String): String { + val trimmed = rawUrl.trim() + val uri = runCatching { Uri.parse(trimmed) }.getOrNull() ?: return trimmed + val scheme = uri.scheme?.lowercase() ?: return trimmed + if (scheme != "http" && scheme != "https") { + return trimmed + } + val host = uri.host ?: return trimmed + val path = uri.path.orEmpty().trimEnd('/') + return "$scheme://$host$path" + } + + fun bookmarksReferToSamePage(existingUrl: String, candidateUrl: String): Boolean { + return normalizeBookmarkKey(existingUrl) == normalizeBookmarkKey(candidateUrl) + } + + fun recordRedirectHop(tabId: Long, fromUrl: String, toUrl: String): RedirectDecision { + val currentDepth = redirectDepthByTab[tabId] ?: 0 + if (currentDepth > MAX_REDIRECT_DEPTH) { + return RedirectDecision.Blocked("redirect limit exceeded") + } + redirectDepthByTab[tabId] = currentDepth + 1 + + val target = resolveRedirectTarget(fromUrl, toUrl) + if (target == null) { + return RedirectDecision.Blocked("invalid redirect target") + } + return RedirectDecision.Allowed(target) + } + + fun resetRedirectChain(tabId: Long) { + redirectDepthByTab.remove(tabId) + } + + fun resolveRedirectTarget(currentUrl: String, nextLocation: String): String? { + if (nextLocation.isBlank()) { + return null + } + if (nextLocation.startsWith("http://") || nextLocation.startsWith("https://")) { + return nextLocation + } + val base = runCatching { Uri.parse(currentUrl) }.getOrNull() ?: return null + return base.buildUpon().appendEncodedPath(nextLocation.trimStart('/')).build().toString() + } + + fun mergeBookmarkLists(existing: List, incoming: List): List { + val merged = existing.toMutableList() + for (url in incoming) { + val duplicate = merged.any { bookmarksReferToSamePage(it, url) } + if (!duplicate) { + merged.add(url) + } + } + return merged + } + + sealed class RedirectDecision { + data class Allowed(val url: String) : RedirectDecision() + data class Blocked(val reason: String) : RedirectDecision() + } +} diff --git a/app/src/main/java/com/kododake/aabrowser/tabs/TabManager.kt b/app/src/main/java/com/kododake/aabrowser/tabs/TabManager.kt index 26167db..901e654 100644 --- a/app/src/main/java/com/kododake/aabrowser/tabs/TabManager.kt +++ b/app/src/main/java/com/kododake/aabrowser/tabs/TabManager.kt @@ -17,6 +17,7 @@ import com.kododake.aabrowser.ui.adapters.TabAdapter import com.kododake.aabrowser.web.BrowserCallbacks import com.kododake.aabrowser.web.configureWebView import com.kododake.aabrowser.web.releaseCompletely +import com.kododake.aabrowser.navigation.UrlSafetyCoordinator import com.kododake.aabrowser.web.updateDesktopMode data class BrowserTab( @@ -300,6 +301,27 @@ class TabManager( BrowserPreferences.persistTabSession(activity, entries, activeIndex) } + fun followRedirectForActiveTab(nextLocation: String): Boolean { + val tab = activeTab ?: return false + val decision = UrlSafetyCoordinator.recordRedirectHop(tab.id, tab.currentUrl, nextLocation) + return when (decision) { + is UrlSafetyCoordinator.RedirectDecision.Allowed -> { + tab.currentUrl = decision.url + tab.webView.loadUrl(decision.url) + true + } + is UrlSafetyCoordinator.RedirectDecision.Blocked -> false + } + } + + fun importSharedBookmarkBatch(urls: List): Int { + val current = BrowserPreferences.getBookmarks(activity) + val merged = UrlSafetyCoordinator.mergeBookmarkLists(current, urls) + BrowserPreferences.setBookmarks(activity, merged) + refreshTabs() + return merged.size - current.size + } + fun refreshTabs() { val count = browserTabs.size.coerceAtLeast(1) binding.buttonTabs.text = if (count > 1) "${activity.getString(R.string.menu_tabs)} ($count)" else activity.getString(R.string.menu_tabs) diff --git a/app/src/test/java/com/kododake/aabrowser/navigation/UrlSafetyCoordinatorTest.kt b/app/src/test/java/com/kododake/aabrowser/navigation/UrlSafetyCoordinatorTest.kt new file mode 100644 index 0000000..78ae958 --- /dev/null +++ b/app/src/test/java/com/kododake/aabrowser/navigation/UrlSafetyCoordinatorTest.kt @@ -0,0 +1,49 @@ +package com.kododake.aabrowser.navigation + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class UrlSafetyCoordinatorTest { + + @Test + fun normalizeBookmarkKey_stripsTrailingSlash() { + val normalized = UrlSafetyCoordinator.normalizeBookmarkKey("https://example.com/path/") + assertEquals("https://example.com/path", normalized) + } + + @Test + fun isTrustedNavigationTarget_acceptsKnownRoot() { + assertTrue(UrlSafetyCoordinator.isTrustedNavigationTarget("https://www.google.com/search?q=test")) + } + + @Test + fun mergeBookmarkLists_deduplicatesExactMatches() { + val merged = UrlSafetyCoordinator.mergeBookmarkLists( + listOf("https://example.com"), + listOf("https://example.com") + ) + assertEquals(1, merged.size) + } + + @Test + fun recordRedirectHop_allowsInitialHop() { + val decision = UrlSafetyCoordinator.recordRedirectHop( + tabId = 99L, + fromUrl = "https://example.com", + toUrl = "https://example.com/next" + ) + assertTrue(decision is UrlSafetyCoordinator.RedirectDecision.Allowed) + UrlSafetyCoordinator.resetRedirectChain(99L) + } + + @Test + fun resolveRedirectTarget_supportsRelativePaths() { + val resolved = UrlSafetyCoordinator.resolveRedirectTarget( + currentUrl = "https://example.com/app/", + nextLocation = "login" + ) + assertFalse(resolved.isNullOrBlank()) + } +}