Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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() {

Expand All @@ -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()
Expand Down Expand Up @@ -68,4 +83,25 @@ class ShareBookmarkActivity : AppCompatActivity() {
}
return null
}

private fun extractSharedUrlBatch(intent: Intent?): List<String> {
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<String>()
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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Long, Int>()

/** Cached allowlist entries populated when user approves cleartext for a host. */
private val sessionTrustedHosts = mutableSetOf<String>()

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<String>, incoming: List<String>): List<String> {
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()
}
}
22 changes: 22 additions & 0 deletions app/src/main/java/com/kododake/aabrowser/tabs/TabManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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<String>): 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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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())
}
}