diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/FAQActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/FAQActivity.kt index dc3ac5d914..086a7fd7ee 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/FAQActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/FAQActivity.kt @@ -28,6 +28,7 @@ import com.itsaky.androidide.R import com.itsaky.androidide.app.EdgeToEdgeIDEActivity import com.itsaky.androidide.databinding.ActivityFaqBinding import com.itsaky.androidide.documentation.DocumentationRequestInterceptor +import com.itsaky.androidide.documentation.OffDeviceRequests import org.adfa.constants.CONTENT_KEY class FAQActivity : EdgeToEdgeIDEActivity() { @@ -66,6 +67,7 @@ class FAQActivity : EdgeToEdgeIDEActivity() { // } // Enable JavaScript if required webView.settings.javaScriptEnabled = true + OffDeviceRequests.keepOnDevice(webView.settings) // Set WebViewClient to handle page navigation within the WebView. ADFA-5176: it answers // documentation from the database in-process, falling through to the local web server @@ -84,7 +86,13 @@ class FAQActivity : EdgeToEdgeIDEActivity() { request: WebResourceRequest, ): WebResourceResponse? = DocumentationRequestInterceptor.shared?.intercept(request) + ?: OffDeviceRequests.block(request) ?: super.shouldInterceptRequest(view, request) + + override fun shouldOverrideUrlLoading( + view: WebView, + request: WebResourceRequest, + ): Boolean = OffDeviceRequests.openWithConsent(this@FAQActivity, request.url.toString()) } // Load the HTML file from the assets folder diff --git a/app/src/main/java/com/itsaky/androidide/fragments/IDETooltipWebViewFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/IDETooltipWebViewFragment.kt index f55f943328..8bc1f13e8b 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/IDETooltipWebViewFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/IDETooltipWebViewFragment.kt @@ -33,6 +33,7 @@ import androidx.core.view.isVisible import androidx.fragment.app.Fragment import com.itsaky.androidide.R import com.itsaky.androidide.documentation.DocumentationRequestInterceptor +import com.itsaky.androidide.documentation.OffDeviceRequests class IDETooltipWebviewFragment : Fragment() { private lateinit var webView: WebView @@ -102,10 +103,14 @@ class IDETooltipWebviewFragment : Fragment() { override fun shouldInterceptRequest( view: WebView, request: WebResourceRequest, - ): WebResourceResponse? = documentation?.intercept(request) ?: super.shouldInterceptRequest(view, request) + ): WebResourceResponse? = + documentation?.intercept(request) + ?: OffDeviceRequests.block(request) + ?: super.shouldInterceptRequest(view, request) /** - * Loads Android asset URLs explicitly and delegates other requests to the default handler. + * Loads Android asset URLs explicitly, asks before opening an off-device link outside the + * app, and delegates other requests to the default handler. * * @param view The WebView requesting the URL. * @param request The resource request to handle. @@ -115,6 +120,12 @@ class IDETooltipWebviewFragment : Fragment() { view: WebView, request: WebResourceRequest, ): Boolean { + // The fragment's view is inflated from the application context, so the consent + // dialog needs the activity itself. + val activity = activity + if (activity != null && OffDeviceRequests.openWithConsent(activity, request.url.toString())) { + return true + } // Allow loading of local assets files if (request.url.toString().startsWith("file:///android_asset/")) { view.loadUrl(request.url.toString()) @@ -131,6 +142,7 @@ class IDETooltipWebviewFragment : Fragment() { // Enable JavaScript if needed webView.settings.javaScriptEnabled = true + OffDeviceRequests.keepOnDevice(webView.settings) // Load the HTML file from the assets folder webView.loadUrl(website) diff --git a/common/src/main/java/com/itsaky/androidide/activities/editor/HelpActivity.kt b/common/src/main/java/com/itsaky/androidide/activities/editor/HelpActivity.kt index 00fff79ace..c44e0b89ae 100644 --- a/common/src/main/java/com/itsaky/androidide/activities/editor/HelpActivity.kt +++ b/common/src/main/java/com/itsaky/androidide/activities/editor/HelpActivity.kt @@ -32,6 +32,7 @@ import androidx.core.view.WindowCompat import com.itsaky.androidide.app.BaseIDEActivity import com.itsaky.androidide.common.databinding.ActivityHelpBinding import com.itsaky.androidide.documentation.DocumentationRequestInterceptor +import com.itsaky.androidide.documentation.OffDeviceRequests import com.itsaky.androidide.resources.R import com.itsaky.androidide.utils.DeviceFormFactorUtils import com.itsaky.androidide.utils.UrlManager @@ -143,6 +144,7 @@ class HelpActivity : BaseIDEActivity() { webView.settings.domStorageEnabled = true webView.settings.databaseEnabled = true webView.settings.mixedContentMode = android.webkit.WebSettings.MIXED_CONTENT_ALWAYS_ALLOW + OffDeviceRequests.keepOnDevice(webView.settings) // Set WebViewClient to handle page navigation within the WebView webView.webViewClient = @@ -152,7 +154,7 @@ class HelpActivity : BaseIDEActivity() { request: android.webkit.WebResourceRequest, ): android.webkit.WebResourceResponse? { val intercepted = documentation?.intercept(request) - return intercepted ?: super.shouldInterceptRequest(view, request) + return intercepted ?: OffDeviceRequests.block(request) ?: super.shouldInterceptRequest(view, request) } override fun onPageStarted( @@ -268,12 +270,17 @@ class HelpActivity : BaseIDEActivity() { supportActionBar?.title = pageTitle ?: getString(R.string.help) currentIntent.getStringExtra(CONTENT_KEY)?.let { url -> - binding.webView.loadUrl(url) + // Nothing passes an off-device URL here today (ToolTipManager asks first itself); this keeps + // a future caller from loading one. + if (!OffDeviceRequests.openWithConsent(this, url)) { + binding.webView.loadUrl(url) + } } } /** - * Handles external links and localhost documentation URLs for the help WebView. + * Handles external links and localhost documentation URLs for the help WebView. An off-device web + * link goes through [OffDeviceRequests.openWithConsent], which asks before opening it outside the app. * * @param view The WebView used to reload localhost documentation URLs. * @param url The URL to handle. @@ -296,7 +303,7 @@ class HelpActivity : BaseIDEActivity() { } else -> { - false + OffDeviceRequests.openWithConsent(this, url) } } } diff --git a/common/src/main/java/com/itsaky/androidide/documentation/OffDeviceRequests.kt b/common/src/main/java/com/itsaky/androidide/documentation/OffDeviceRequests.kt new file mode 100644 index 0000000000..938432757c --- /dev/null +++ b/common/src/main/java/com/itsaky/androidide/documentation/OffDeviceRequests.kt @@ -0,0 +1,162 @@ +/* + * This file is part of Code on the Go. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.documentation + +import android.app.Activity +import android.content.Context +import android.webkit.ServiceWorkerClient +import android.webkit.ServiceWorkerController +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebSettings +import com.itsaky.androidide.utils.UrlManager +import com.itsaky.androidide.utils.findActivity +import org.slf4j.LoggerFactory +import java.io.ByteArrayInputStream +import java.net.URI +import java.net.URISyntaxException + +/** + * Keeps the documentation WebViews on the device (ADFA-6589). Documentation pages reference YouTube + * players and images on the internet, and a WebView fetches anything its client does not answer. + * So each documentation WebView answers off-device requests with [block], sends tapped off-device + * links through [openWithConsent], and calls [keepOnDevice] before loading anything. + * + * [block] sees only what reaches `shouldInterceptRequest`. A page can still reach the network + * without it: `` and `dns-prefetch` (or the same in a `Link:` header), + * WebSockets, WebRTC, and each hop of a redirect. No documentation page uses any of them, and + * neither WebServer nor DocumentationRequestInterceptor sends a redirect or a `Link:` header. + * + * Not `WebSettings.blockNetworkLoads`: that would also stop requests to the local web server on + * localhost:6174, which the `/pr/` endpoints and the `CodeOnTheGo.nointercept` fallback rely on. + */ +object OffDeviceRequests { + private val log = LoggerFactory.getLogger(OffDeviceRequests::class.java) + + private val LOOPBACK_V4 = Regex("""127\.\d{1,3}\.\d{1,3}\.\d{1,3}""") + + // Chromium ends an http(s) authority at / \ ? or #, so http://evil.com\@localhost/ is evil.com. + private val AUTHORITY = Regex("""^([A-Za-z][A-Za-z0-9+.-]*)://([^/\\?#]*)""") + + /** + * Whether [url] is an http(s) URL whose host is not this device. Fails closed: an http(s) URL + * that cannot be parsed, or has no host, counts as off-device. Other schemes (data:, file:, + * about:, blob:) never leave the device. + */ + fun isOffDevice(url: String): Boolean = offDeviceHost(url) != null + + /** + * An empty local response for an off-device request. + * + * @return The response, or `null` for a request that stays on the device, so the caller goes on + * as before: to the documentation interceptor, then the local web server. + */ + fun block(request: WebResourceRequest): WebResourceResponse? { + val host = offDeviceHost(request.url.toString()) ?: return null + log.info("Blocked an off-device request to {}", host) + return WebResourceResponse("text/plain", "utf-8", 204, "No Content", emptyMap(), ByteArrayInputStream(ByteArray(0))) + } + + /** + * Sends an off-device [url] through [UrlManager]'s consent dialog, which opens it outside the + * app. An [Activity], because [UrlManager.openUrl] asks only when given one: with any other + * context it opens the link without asking. + * + * @return `true` if [url] was off-device and so handled here. + */ + fun openWithConsent( + activity: Activity, + url: String, + ): Boolean { + if (!isOffDevice(url)) return false + UrlManager.openUrl(url, context = activity) + return true + } + + /** + * The first of [contexts] that is, or wraps, an [Activity], for [openWithConsent], which needs + * one to ask. A tooltip popup's own context may not be one, while the view it is anchored to + * belongs to an Activity. + */ + fun activityFor(vararg contexts: Context?): Activity? = contexts.firstNotNullOfOrNull { it?.findActivity() } + + /** + * Closes two ways a WebView reaches the network that [block] does not see. Call it before the + * WebView loads anything. + * + * Safe Browsing has Google Play services send Google hash prefixes of the addresses a WebView + * loads, charged to this app, even when [block] answers the request locally: on a Pixel 6 Pro, + * two documentation pages cost 15 KB of lookups with it on and nothing with it off. + * + * Service-worker fetches go to the ServiceWorkerClient rather than a WebView's client, so that one + * gets [block] too. It is process-wide and has no undo: from the first documentation WebView on, + * it covers every WebView in the app, plugins' included. A plugin that sets its own replaces it + * only until the next documentation WebView or tooltip popup sets this one again. No + * documentation page registers a service worker today. + */ + fun keepOnDevice(settings: WebSettings) { + settings.safeBrowsingEnabled = false + ServiceWorkerController.getInstance().setServiceWorkerClient(serviceWorkerClient) + } + + private val serviceWorkerClient = + object : ServiceWorkerClient() { + override fun shouldInterceptRequest(request: WebResourceRequest): WebResourceResponse? = block(request) + } + + // The host [url] would connect to (the URL itself when it has no host to read), or null if it + // stays on the device. The URLs a WebView hands its client are already canonical (lowercase + // host, no backslashes), so where java.net.URI parses one it finds the same host, userinfo + // included: http://localhost@example.com is example.com. Leading and trailing spaces and control + // characters, and tabs and line breaks anywhere, are dropped first, as the URL Standard has + // Chromium drop them: "http\n://evil.com/" loads evil.com. + private fun offDeviceHost(rawUrl: String): String? { + val url = rawUrl.trim { it <= ' ' }.filterNot { it == '\t' || it == '\n' || it == '\r' } + val uri = + try { + URI(url) + } catch (e: URISyntaxException) { + return offDeviceHostOfUnparsed(url) + } + val scheme = uri.scheme?.lowercase() + if (scheme != "http" && scheme != "https") return null + val host = uri.host?.lowercase()?.removeSurrounding("[", "]") ?: return url + return if (isThisDevice(host)) null else host + } + + // java.net.URI rejects some characters a canonical URL keeps (a | in a query, a stray % in a + // path), so read only scheme://authority. A URL even that cannot read stays off-device. + private fun offDeviceHostOfUnparsed(url: String): String? { + val match = AUTHORITY.find(url) + if (match == null) { + return if (url.startsWith("http:", ignoreCase = true) || url.startsWith("https:", ignoreCase = true)) url else null + } + val scheme = match.groupValues[1].lowercase() + if (scheme != "http" && scheme != "https") return null + val authority = match.groupValues[2].substringAfterLast('@') + val host = + if (authority.startsWith("[")) { + authority.substringBefore(']').removePrefix("[") + } else { + authority.substringBefore(':') + }.lowercase() + return if (isThisDevice(host)) null else host.ifEmpty { url } + } + + private fun isThisDevice(host: String) = host == "localhost" || host == "::1" || LOOPBACK_V4.matches(host) +} diff --git a/common/src/test/java/com/itsaky/androidide/documentation/OffDeviceRequestsTest.kt b/common/src/test/java/com/itsaky/androidide/documentation/OffDeviceRequestsTest.kt new file mode 100644 index 0000000000..c3e7cf2ca7 --- /dev/null +++ b/common/src/test/java/com/itsaky/androidide/documentation/OffDeviceRequestsTest.kt @@ -0,0 +1,200 @@ +package com.itsaky.androidide.documentation + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.net.Uri +import android.webkit.ServiceWorkerClient +import android.webkit.ServiceWorkerController +import android.webkit.WebResourceRequest +import android.webkit.WebSettings +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import com.itsaky.androidide.utils.UrlManager +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.runs +import io.mockk.slot +import io.mockk.unmockkAll +import io.mockk.verify +import org.junit.After +import org.junit.Test + +/** + * Which URLs [OffDeviceRequests] keeps from leaving the device (ADFA-6589). A documentation page + * that reaches the internet is a critical bug, so the cases below lean on the shapes that could + * slip past a host check: userinfo, look-alike hosts, case, and URLs that do not parse. + */ +class OffDeviceRequestsTest { + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `the device itself is on the device`() { + for (url in listOf( + "http://localhost:6174/i/index.html", + "http://localhost/", + "HTTP://LOCALHOST:6174/i/index.html", + "https://localhost:8443/x", + "http://127.0.0.1:6174/x", + "http://127.10.20.30/x", + "http://[::1]:6174/x", + )) { + assertWithMessage(url).that(OffDeviceRequests.isOffDevice(url)).isFalse() + } + } + + @Test + fun `every other host is off the device`() { + for (url in listOf( + "https://fonts.googleapis.com/css?family=Roboto:400,700", + "https://www.youtube.com/embed/abc", + "https://developer.android.com/static/images/x.png", + "HTTPS://KOTL.IN/x", + "http://192.168.1.10:6174/x", + "http://10.0.2.2:6174/x", + "http://[2001:db8::1]/x", + )) { + assertWithMessage(url).that(OffDeviceRequests.isOffDevice(url)).isTrue() + } + } + + @Test + fun `a host that only mentions localhost is off the device`() { + for (url in listOf( + "http://localhost.example.com/", + "http://example-localhost/", + "http://localhost@example.com/", + "http://localhost:6174@example.com/", + "http://example.com/?next=http://localhost:6174/", + "http://example.com/#localhost", + "http://127.0.0.1.example.com/", + // Chromium ends the authority at a backslash, so the host is evil.com. + "http://evil.com\\@localhost/", + )) { + assertWithMessage(url).that(OffDeviceRequests.isOffDevice(url)).isTrue() + } + } + + @Test + fun `an http URL that cannot be read is treated as off the device`() { + for (url in listOf("http://exa mple.com/", "HTTP://exa mple.com/", "https://example.com/a b", "http:///no-host", "https:")) { + assertWithMessage(url).that(OffDeviceRequests.isOffDevice(url)).isTrue() + } + } + + @Test + fun `spaces around a URL do not hide its host`() { + // Chromium drops them before parsing, and tabs and line breaks anywhere, so these load from + // the host inside. + assertThat(OffDeviceRequests.isOffDevice(" http://evil.com/x|y")).isTrue() + assertThat(OffDeviceRequests.isOffDevice("\thttps://evil.com/\n")).isTrue() + assertThat(OffDeviceRequests.isOffDevice("http\n://evil.com/x")).isTrue() + assertThat(OffDeviceRequests.isOffDevice("ht\ttp://evil.com/x")).isTrue() + assertThat(OffDeviceRequests.isOffDevice("https\r\n://evil.com/x")).isTrue() + assertThat(OffDeviceRequests.isOffDevice(" http://localhost:6174/x|y ")).isFalse() + } + + @Test + fun `a local URL that java_net_URI rejects is still on the device`() { + // Chromium leaves these characters as they are in a canonical URL; java.net.URI refuses them. + for (url in listOf( + "http://localhost:6174/i/x.html?a=1|2", + "http://localhost:6174/i/%zz.html", + "http://127.0.0.1:6174/p.html#b|c", + "http://[::1]:6174/q.html?{y}", + )) { + assertWithMessage(url).that(OffDeviceRequests.isOffDevice(url)).isFalse() + } + assertThat(OffDeviceRequests.isOffDevice("http://example.com/i/x.html?a=1|2")).isTrue() + assertThat(OffDeviceRequests.isOffDevice("http://localhost@example.com/%zz")).isTrue() + assertThat(OffDeviceRequests.isOffDevice("http://[2001:db8::1]/x|y")).isTrue() + // The @ is in the query, not the authority: the host is evil.com. + assertThat(OffDeviceRequests.isOffDevice("http://evil.com/x|y?u=a@localhost:1")).isTrue() + } + + @Test + fun `other schemes never leave the device`() { + for (url in listOf( + "file:///android_asset/x.html", + "data:text/html,

hi

", + "about:blank", + "blob:http://localhost:6174/1234", + "javascript:void(0)", + "mailto:someone@example.com", + )) { + assertWithMessage(url).that(OffDeviceRequests.isOffDevice(url)).isFalse() + } + } + + @Test + fun `an off-device request gets a local response, not the network`() { + assertThat(OffDeviceRequests.block(request("https://fonts.googleapis.com/css?family=Roboto"))).isNotNull() + assertThat(OffDeviceRequests.block(request("http://localhost@example.com/x.png"))).isNotNull() + } + + @Test + fun `a request that stays on the device goes on to the interceptor and the server`() { + assertThat(OffDeviceRequests.block(request("http://localhost:6174/pr/db"))).isNull() + assertThat(OffDeviceRequests.block(request("data:text/css,"))).isNull() + } + + @Test + fun `only an off-device link is sent through the consent dialog`() { + mockkObject(UrlManager) + every { UrlManager.openUrl(any(), any(), any()) } just runs + val activity = mockk() + + assertThat(OffDeviceRequests.openWithConsent(activity, "https://kotl.in/x")).isTrue() + verify(exactly = 1) { UrlManager.openUrl("https://kotl.in/x", null, activity) } + + assertThat(OffDeviceRequests.openWithConsent(activity, "http://localhost:6174/i/index.html")).isFalse() + assertThat(OffDeviceRequests.openWithConsent(activity, "file:///android_asset/x.html")).isFalse() + verify(exactly = 1) { UrlManager.openUrl(any(), any(), any()) } + } + + @Test + fun `the consent dialog uses the first context that is or wraps an Activity`() { + val activity = mockk() + val plain = mockk() + val wrapsActivity = mockk { every { baseContext } returns activity } + val wrapsWrapper = mockk { every { baseContext } returns wrapsActivity } + val wrapsPlain = mockk { every { baseContext } returns plain } + + assertThat(OffDeviceRequests.activityFor(activity, plain)).isSameInstanceAs(activity) + assertThat(OffDeviceRequests.activityFor(plain, wrapsActivity)).isSameInstanceAs(activity) + assertThat(OffDeviceRequests.activityFor(wrapsPlain, wrapsWrapper)).isSameInstanceAs(activity) + assertThat(OffDeviceRequests.activityFor(null, wrapsActivity)).isSameInstanceAs(activity) + assertThat(OffDeviceRequests.activityFor(plain, wrapsPlain)).isNull() + assertThat(OffDeviceRequests.activityFor()).isNull() + } + + @Test + fun `keepOnDevice turns Safe Browsing off and blocks service-worker fetches`() { + val settings = mockk(relaxed = true) + val controller = mockk(relaxed = true) + val client = slot() + mockkStatic(ServiceWorkerController::class) + every { ServiceWorkerController.getInstance() } returns controller + every { controller.setServiceWorkerClient(capture(client)) } returns Unit + + OffDeviceRequests.keepOnDevice(settings) + + verify(exactly = 1) { settings.safeBrowsingEnabled = false } + verify(exactly = 0) { settings.safeBrowsingEnabled = true } + assertThat(client.captured.shouldInterceptRequest(request("https://example.com/sw.js"))).isNotNull() + assertThat(client.captured.shouldInterceptRequest(request("http://localhost:6174/sw.js"))).isNull() + } + + private fun request(url: String): WebResourceRequest { + // Uri is a framework class, so stand in for the one call the guard makes. + val uri = mockk() + every { uri.toString() } returns url + return mockk { every { this@mockk.url } returns uri } + } +} diff --git a/docs/documentation-database.md b/docs/documentation-database.md index e01b28ebe3..30abe80c55 100644 --- a/docs/documentation-database.md +++ b/docs/documentation-database.md @@ -79,6 +79,8 @@ Of the five sites below, only `DocumentationContentSource` and `ToolTipManager` - **`idetooltips/.../ToolTipManager.kt`** — serves Tier 1/2. Looks up `Tooltips` joined to `TooltipCategories` by `(category, tag)`, then `TooltipButtons` for the Tier 3 links shown at the bottom. - **`plugin-manager/.../documentation/PluginDocumentationManager.kt`** (with `Tier3AssetWalker.kt`, and the `DocumentationExtension` contract in `plugin-api`) — lets plugins contribute their own help content into the same lookup paths, under the reserved `plugin//...` prefix. Brotli assets are compressed against the same `CompressionDictionary` the rest of the table uses, read from the database being written. When that dictionary can't be read the install is abandoned rather than written plain, and retried on the plugin's next activation. +Not a database reader, but beside them: **`common/.../documentation/OffDeviceRequests.kt`** keeps the documentation WebViews (`HelpActivity`, the tooltip fragment, `FAQActivity`) and the tooltip popup on the device (ADFA-6589). Each one's `shouldInterceptRequest` answers an http(s) request to any host but the device itself with an empty `204`, after the interceptor and before the network, so requests to `localhost` still reach `WebServer`. Tapped off-device links, and an off-device URL passed to `HelpActivity`, go through `UrlManager.openUrl`'s consent dialog to the browser. `keepOnDevice` turns Safe Browsing off, which would otherwise have Google Play services send Google hash prefixes of the addresses, charged to the app, even for requests the block answers locally; and it gives service-worker fetches the same block. That service-worker client is process-wide and stays set: from the first documentation WebView on, it covers every WebView in the app, plugins' included, and a plugin that sets its own replaces it only until the next documentation WebView or tooltip popup sets this one again. What the block cannot see: `` and `dns-prefetch` (or a `Link:` header), WebSockets, WebRTC, and the hops of a redirect. No documentation page uses them, and neither `WebServer` nor the interceptor redirects or sends `Link:`. It is not `WebSettings.blockNetworkLoads`, which would also block `WebServer`'s `/pr/` endpoints and the `CodeOnTheGo.nointercept` fallback. + ## Editing the database Schema changes and data edits happen **outside this repo**, in `OfflineDocumentationTools/docdb-studio` (a Flet GUI over this same `documentation.db`). Conventions enforced there that matter if you're reasoning about data correctness here: @@ -98,6 +100,6 @@ Some tickets (e.g. ADFA-5088) ship a one-off `.sql` script under `docs/docdb/` f ## Known rough edges -- A Tier 3 link that points off-device is a bug in the *content*, not the code — the web server and webview will happily follow it. If you see one while working in this area, it's a data problem to report upstream, not a `WebServer` bug to fix here. +- A Tier 3 page that references something off-device is a bug in the *content*, not the code. Since ADFA-6589 the documentation WebViews answer such a request with an empty local response, so the font, image or player is simply missing, and a tapped off-device link asks before opening in the browser (`OffDeviceRequests`). If you see one while working in this area, it's a data problem to report upstream, not a `WebServer` bug to fix here. - `Content`'s `UNIQUE(path)` constraint (rather than `UNIQUE(path, languageID)`) means a second language for an existing path can't currently be added without a schema change upstream — multi-language content isn't fully wired yet even though the `Languages` dimension anticipates it. - `TooltipButtonNumbers` is a whole table whose only job is pinning a manual sort order; a lighter-weight mechanism (e.g. an ordering column directly on `TooltipButtons`) would remove a table. diff --git a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/ToolTipManager.kt b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/ToolTipManager.kt index 6e2e4de7b3..2a64e9b5c3 100644 --- a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/ToolTipManager.kt +++ b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/ToolTipManager.kt @@ -16,11 +16,14 @@ import android.view.View import android.view.ViewGroup import android.view.WindowManager import android.view.animation.AnimationUtils +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse import android.webkit.WebView import android.webkit.WebViewClient import android.widget.ImageButton import android.widget.PopupWindow import android.widget.TextView +import android.widget.Toast import android.os.Handler import android.os.Looper import android.view.InputDevice @@ -28,6 +31,7 @@ import android.view.MotionEvent import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat.getColor import com.itsaky.androidide.activities.editor.HelpActivity +import com.itsaky.androidide.documentation.OffDeviceRequests import com.itsaky.androidide.utils.DatabaseVersionResolver import com.itsaky.androidide.utils.Environment import com.itsaky.androidide.utils.FeedbackManager @@ -362,8 +366,25 @@ object TooltipManager { context.getString(R.string.tooltip_html_template, bodyColorHex, tooltipHtmlContent, linkColorHex) webView.webViewClient = object : WebViewClient() { + // The tooltip's text is HTML from the database: it must not fetch anything off the device. + override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? = + OffDeviceRequests.block(request) ?: super.shouldInterceptRequest(view, request) + override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean { url?.let { clickedUrl -> + // A link in the tooltip's own text can name any site: ask, then open it outside + // the app, rather than load it in Help. The question needs an Activity; with + // none, keep the popup open and say so rather than drop the tap. + if (OffDeviceRequests.isOffDevice(clickedUrl)) { + val activity = OffDeviceRequests.activityFor(context, anchorView.context) + if (activity == null) { + Toast.makeText(context, ResR.string.tooltip_link_cannot_open, Toast.LENGTH_LONG).show() + } else { + popupWindow.dismiss() + OffDeviceRequests.openWithConsent(activity, clickedUrl) + } + return true + } popupWindow.dismiss() // Find the button label for this URL to use as title val buttonLabel = tooltipItem.buttons.find { it.second == clickedUrl }?.first @@ -375,6 +396,7 @@ object TooltipManager { } webView.settings.javaScriptEnabled = true + OffDeviceRequests.keepOnDevice(webView.settings) webView.setBackgroundColor(Color.TRANSPARENT) webView.loadDataWithBaseURL(null, styledHtml, "text/html", "UTF-8", null) @@ -451,13 +473,15 @@ object TooltipManager { setColorFilter(iconTintColor) } + // The consent dialog needs an Activity: without one (the debugger overlay runs in a Service), + // UrlManager would open the link unasked, so the button is not offered there. + val sponsorActivity = OffDeviceRequests.activityFor(context, anchorView.context) val sponsorButton = popupView.findViewById(R.id.sponsor_button) sponsorButton.apply { - visibility = if (level >= 1) View.VISIBLE else View.GONE + visibility = if (level >= 1 && sponsorActivity != null) View.VISIBLE else View.GONE setColorFilter(iconTintColor) setOnClickListener { - val sponsorUrl = context.getString(ResR.string.github_sponsors_url) - UrlManager.openUrl(sponsorUrl, null, context) + sponsorActivity?.let { UrlManager.openUrl(context.getString(ResR.string.github_sponsors_url), null, it) } } } diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index a25299926e..4d9ebea983 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1563,6 +1563,7 @@ Cancel Proceed Don\'t ask again + This link can\'t be opened from here. http://localhost:6174/i/layout-top.html