diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index 7b70bf221..e8e358818 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -33,9 +33,13 @@ import android.widget.Toast import androidx.annotation.VisibleForTesting import androidx.webkit.WebViewAssetLoader import androidx.webkit.WebViewAssetLoader.AssetsPathHandler +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.json.JSONException import org.json.JSONObject import org.wordpress.gutenberg.inserter.BlockPickerDialog @@ -143,8 +147,8 @@ class GutenbergView : FrameLayout { * To perform the upload yourself, set [mediaUploader] instead. * * Provide this **before the editor loads** — typically right after - * construction (e.g. in the `AndroidView` factory). It is captured once, when - * the page begins loading, and advertised to the page then; setting it + * construction (e.g. in the `AndroidView` factory). It is captured once, just + * before the page loads, and advertised to the page then; setting it * afterward has no effect, so the setter throws to surface the mistake. */ var mediaProcessor: MediaProcessor? = null @@ -184,17 +188,34 @@ class GutenbergView : FrameLayout { private fun lateMediaAssignmentMessage(name: String) = "$name must be set before the editor loads (e.g. right after construction). " + - "It is captured when the page begins loading; setting it afterward has no effect." + "It is captured before the page loads; setting it afterward has no effect." @Volatile private var uploadServer: MediaUploadServer? = null /** - * True once the editor page has begun loading and the upload server's - * configuration has been captured. After this the [mediaProcessor] can no - * longer take effect, so its setter throws. + * True once [loadEditorPage] has captured the upload server's configuration. + * After this the [mediaProcessor] can no longer take effect, so its setter + * throws. */ @Volatile private var hasStartedLoading = false + /** + * The editor globals as JSON. [loadEditorPage] builds them before the page + * loads, and [onEditorPageStarted] gives them to each editor page as + * `window.GBKit`. + */ + private var editorGlobalsJson: String? = null + + /** The work [loadEditorPage] does before loading the page. Cancelled on detach. */ + private var editorPageJob: Job? = null + + /** + * Where [loadEditorPage] does its disk and network setup. Tests replace it to + * run that work in place. + */ + @VisibleForTesting + internal var backgroundDispatcher: CoroutineDispatcher = Dispatchers.IO + private val uploadHttpClient: okhttp3.OkHttpClient by lazy { // The read/write inactivity timeouts mirror URLSession's 60s // timeoutIntervalForRequest default — an inactivity timer that resets on @@ -652,26 +673,32 @@ class GutenbergView : FrameLayout { private fun prepareAndLoadEditor() { Log.i("GutenbergView", "Fetching dependencies...") - coroutineScope.launch { + coroutineScope.launch(Dispatchers.Main) { Log.i("GutenbergView", "In coroutine scope") Log.i("GutenbergView", "Fetching dependencies in IO context") try { - val editorService = EditorService.create( - context = context, - configuration = configuration, - coroutineScope = coroutineScope - ) - Log.i("GutenbergView", "Created editor service") - val fetchedDependencies = editorService.prepare { progress -> - progressView.setProgress(progress) - - Log.i("GutenbergView", "Progress: $progress") + // Creating the service and preparing it both read the disk, and + // creating it sets up an SSL context, so neither runs on the main thread. + val fetchedDependencies = withContext(Dispatchers.IO) { + val editorService = EditorService.create( + context = context, + configuration = configuration, + coroutineScope = coroutineScope + ) + Log.i("GutenbergView", "Created editor service") + editorService.prepare { progress -> + handler.post { progressView.setProgress(progress) } + + Log.i("GutenbergView", "Progress: $progress") + } } Log.i("GutenbergView", "Finished fetching dependencies") // Store dependencies and load the editor loadEditor(fetchedDependencies) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Log.e("GutenbergView", "Failed to load dependencies", e) showErrorPhase(e) @@ -749,6 +776,46 @@ class GutenbergView : FrameLayout { for (cookie in configuration.cookies) { CookieManager.getInstance().setCookie(cookie.key, cookie.value) } + loadEditorPage(editorUrl) + } + } + + /** + * Starts the upload server and builds the editor globals, then loads [editorUrl]. + * + * The page needs both before its scripts run, and both read the disk. Building + * them here, on [backgroundDispatcher], keeps that work off the main thread. + * Hosts that run `StrictMode` would otherwise see violations on every launch. + * + * The work starts on a later pass of the main thread, so a [mediaProcessor] or + * [mediaUploader] set right after construction is still captured. The server is + * assigned on the main thread and stopped in [onDetachedFromWindow], so its + * lifecycle can't race a processor assignment. + */ + private fun loadEditorPage(editorUrl: String) { + editorPageJob = coroutineScope.launch(Dispatchers.Main) { + hasStartedLoading = true + val processor = mediaProcessor + val uploader = mediaUploader + var server: MediaUploadServer? = null + try { + editorGlobalsJson = withContext(backgroundDispatcher) { + val startedServer = startUploadServer(processor, uploader) + server = startedServer + GBKitGlobal.fromConfiguration( + configuration, + dependencies, + nativeUploadPort = startedServer?.port, + nativeUploadToken = startedServer?.token + ).toJsonString() + } + } catch (e: CancellationException) { + // The view was detached while the server started, so nothing else + // will stop it. + server?.stop() + throw e + } + uploadServer = server webView.loadUrl(editorUrl) Log.i("GutenbergView", "Startup Complete") @@ -757,14 +824,8 @@ class GutenbergView : FrameLayout { /** * Invoked when any page begins loading in the main frame. Resets readiness for - * every page; for the editor document alone, starts the upload server once — - * capturing the [mediaProcessor] provided before load — then advertises the - * editor globals (including the server's port and token). - * - * Starting the server here, on the UI thread, rather than from the - * [mediaProcessor] setter keeps its whole lifecycle — start here, stop in - * [onDetachedFromWindow] — on the UI thread, so it can't race a - * background-thread processor assignment. + * every page; for the editor document alone, advertises the editor globals + * (including the upload server's port and token) that [loadEditorPage] built. */ private fun onEditorPageStarted(url: String?) { // Readiness belongs to the page: a new page, including one a reload starts, @@ -778,30 +839,20 @@ class GutenbergView : FrameLayout { // with the site, so the destination is checked rather than assumed. if (url == null || !isEditorDocument(Uri.parse(url))) return - if (!hasStartedLoading) { - hasStartedLoading = true - startUploadServer() - } - setGlobalJavaScriptVariables() + val globalsJson = editorGlobalsJson ?: return + webView.evaluateJavascript("window.GBKit = $globalsJson;", null) } - private fun setGlobalJavaScriptVariables() { - val gbKit = GBKitGlobal.fromConfiguration( - configuration, - dependencies, - nativeUploadPort = uploadServer?.port, - nativeUploadToken = uploadServer?.token - ) - val gbKitJson = gbKit.toJsonString() - - webView.evaluateJavascript("window.GBKit = $gbKitJson;", null) - } - - private fun startUploadServer() { + /** + * Starts the upload server for [processor] and [uploader], or returns null when + * uploads should use the default WebView path. Reads the disk, so it runs off + * the main thread. + */ + private fun startUploadServer(processor: MediaProcessor?, uploader: MediaUploader?): MediaUploadServer? { // Nothing to route through the native server unless the host provided a // processor or an uploader — leave it down and let uploads fall to the default // WebView path. (Matches iOS.) - if (mediaProcessor == null && mediaUploader == null) return + if (processor == null && uploader == null) return null // An InternalMediaClient delivers GutenbergKit-owned uploads (when no uploader // is set) and relays the editor's media DELETEs to the configured site — every @@ -813,7 +864,7 @@ class GutenbergView : FrameLayout { // // Only a mediaProcessor can reach this return: a mediaUploader without // credentials already failed in its setter, so by here it has them. - if (!MediaServerCredentials.areUsable(configuration.siteApiRoot, configuration.authHeader)) return + if (!MediaServerCredentials.areUsable(configuration.siteApiRoot, configuration.authHeader)) return null // The editor reaches the loopback server over cleartext http://localhost. If // the host app's network-security config doesn't permit cleartext to @@ -836,27 +887,26 @@ class GutenbergView : FrameLayout { "server can't be reached from the WebView. Permit cleartext to $LOOPBACK_HOST " + "in the app's network security config to enable native media processing." ) - return + return null } - try { + return try { val internalClient = InternalMediaClient( httpClient = uploadHttpClient, siteApiRoot = configuration.siteApiRoot, authHeader = configuration.authHeader, siteApiNamespace = configuration.siteApiNamespace.toList() ) - uploadServer = MediaUploadServer( - processor = mediaProcessor, + MediaUploadServer( + processor = processor, internalClient = internalClient, - uploader = mediaUploader, + uploader = uploader, cacheDir = context.cacheDir, scope = coroutineScope ) - // The page globals (including the server's port/token) are injected by - // onEditorPageStarted after this returns. } catch (e: Exception) { Log.w(TAG, "Failed to start upload server", e) + null } } @@ -1416,6 +1466,7 @@ class GutenbergView : FrameLayout { override fun onDetachedFromWindow() { super.onDetachedFromWindow() stopNetworkMonitoring() + editorPageJob?.cancel() uploadServer?.stop() uploadServer = null clearConfig() diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt index ceb759abc..c28eafb3b 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewNavigationTest.kt @@ -3,6 +3,7 @@ package org.wordpress.gutenberg import android.net.Uri import android.os.Looper import android.webkit.WebResourceRequest +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.test.TestScope import org.junit.Assert.assertNull import org.junit.Assert.assertTrue @@ -28,15 +29,28 @@ class GutenbergViewNavigationTest { private val testScope = TestScope() /** A view whose configuration carries a recognizable credential. */ - private fun configuredSiteView() = GutenbergView( + private fun configuredSiteView() = preparedView( EditorConfiguration.builder("https://example.com", "https://example.com/wp-json/") .setAuthHeader("Bearer secret-credential") - .build(), - EditorDependencies.empty, - testScope, - RuntimeEnvironment.getApplication() + .build() ) + /** + * A view that has built its editor globals, as it does before loading the + * editor page, so `onPageStarted` has something to inject. + */ + private fun preparedView(configuration: EditorConfiguration): GutenbergView { + val view = GutenbergView( + configuration, + EditorDependencies.empty, + testScope, + RuntimeEnvironment.getApplication() + ) + view.backgroundDispatcher = Dispatchers.Unconfined + shadowOf(Looper.getMainLooper()).idle() + return view + } + /** * The editor document for [siteUrl], mirroring the fallback in `loadEditor` so * this holds whether or not a local `GUTENBERG_EDITOR_URL` dev server is set. @@ -120,13 +134,10 @@ class GutenbergViewNavigationTest { @Test fun `onPageStarted injects the configuration into a local http site's editor document`() { // A local site serves the editor over http, which the asset scheme must follow. - val siteView = GutenbergView( + val siteView = preparedView( EditorConfiguration.builder("http://10.0.2.2:8888", "http://10.0.2.2:8888/wp-json/") .setAuthHeader("Bearer secret-credential") - .build(), - EditorDependencies.empty, - testScope, - RuntimeEnvironment.getApplication() + .build() ) val webView = siteView.editorWebView diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewUploadServerTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewUploadServerTest.kt index 9935fa102..6448b3aa6 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewUploadServerTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewUploadServerTest.kt @@ -2,11 +2,13 @@ package org.wordpress.gutenberg import android.os.Looper import android.view.View +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.test.TestScope import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mockito.mock @@ -21,17 +23,6 @@ import org.wordpress.gutenberg.model.EditorDependencies @Config(manifest = Config.NONE) class GutenbergViewUploadServerTest { - private companion object { - /** - * The editor document for [makeView]'s site, which the globals are scoped to. - * - * Mirrors the fallback in `loadEditor`, so this holds whether or not a local - * `GUTENBERG_EDITOR_URL` dev server is configured. - */ - val EDITOR_URL = BuildConfig.GUTENBERG_EDITOR_URL - .ifEmpty { "https://example.com/assets/index.html" } - } - private val testScope = TestScope() private fun makeView(authHeader: String = "Bearer test", siteApiRoot: String = "https://example.com/wp-json/"): GutenbergView { @@ -44,7 +35,10 @@ class GutenbergViewUploadServerTest { EditorDependencies.empty, testScope, RuntimeEnvironment.getApplication() - ) + ).apply { + // Run the pre-load setup in place, so idling the main looper finishes it. + backgroundDispatcher = Dispatchers.Unconfined + } } private fun uploadServerOf(view: GutenbergView): Any? { @@ -54,18 +48,10 @@ class GutenbergViewUploadServerTest { } /** - * Invokes the private `onEditorPageStarted` hook (fired from the WebViewClient's - * `onPageStarted`) to simulate the editor page beginning to load — the point at - * which the processor is captured and the upload server starts. + * Lets the view finish the setup it does before loading the editor page — the + * point at which the processor is captured and the upload server starts. */ - private fun startLoading(view: GutenbergView, url: String = EDITOR_URL) { - val method = GutenbergView::class.java.getDeclaredMethod( - "onEditorPageStarted", - String::class.java - ) - method.isAccessible = true - method.invoke(view, url) - } + private fun startLoading() = idle() /** Invokes the protected `onDetachedFromWindow` lifecycle callback. */ private fun detach(view: GutenbergView) { @@ -82,8 +68,7 @@ class GutenbergViewUploadServerTest { try { // A processor provided before load is captured when the page starts. view.mediaProcessor = mock(MediaProcessor::class.java) - startLoading(view) - idle() + startLoading() assertNotNull( "a processor provided before load should bring up the upload server", uploadServerOf(view) @@ -98,8 +83,7 @@ class GutenbergViewUploadServerTest { val view = makeView() try { // No processor provided — uploads should use the default WebView path. - startLoading(view) - idle() + startLoading() assertNull( "with no processor, no upload server should be started", uploadServerOf(view) @@ -117,8 +101,7 @@ class GutenbergViewUploadServerTest { // editor has to the host's upload stack. Without this, `startUploadServer` // could drop the `mediaUploader` clause from its gate and stay green. view.mediaUploader = mock(MediaUploader::class.java) - startLoading(view) - idle() + startLoading() assertNotNull( "an uploader provided before load should bring up the upload server", uploadServerOf(view) @@ -129,27 +112,42 @@ class GutenbergViewUploadServerTest { } @Test - fun `a non-editor page does not start the upload server`() { + fun `the editor globals advertise the upload server's port`() { val view = makeView() try { view.mediaProcessor = mock(MediaProcessor::class.java) - startLoading(view, "https://example.com/assets/support.html") - idle() - assertNull( - "only the editor document should bring up the upload server", - uploadServerOf(view) + startLoading() + val field = GutenbergView::class.java.getDeclaredField("editorGlobalsJson") + field.isAccessible = true + val globals = field.get(view) as String + val port = MediaUploadServer::class.java.getMethod("getPort").invoke(uploadServerOf(view)) + assertTrue( + "the page should learn the port before it loads", + globals.contains("\"nativeUploadPort\":$port") ) } finally { detach(view) } } + @Test + fun `detaching before the page loads leaves no upload server`() { + val view = makeView() + view.mediaProcessor = mock(MediaProcessor::class.java) + detach(view) + startLoading() + + assertNull( + "a view detached before load should never start the upload server", + uploadServerOf(view) + ) + } + @Test fun `setting the uploader after the page has started loading throws`() { val view = makeView() try { - startLoading(view) - idle() + startLoading() assertThrows(IllegalStateException::class.java) { view.mediaUploader = mock(MediaUploader::class.java) } @@ -170,8 +168,7 @@ class GutenbergViewUploadServerTest { } // The failed assignment left nothing behind, so loading still works — // it just falls to the default WebView path. - startLoading(view) - idle() + startLoading() assertNull("no server should be left behind by the rejected uploader", uploadServerOf(view)) } finally { detach(view) @@ -213,8 +210,7 @@ class GutenbergViewUploadServerTest { val view = makeView(siteApiRoot = "example.com/wp-json/") try { view.mediaProcessor = mock(MediaProcessor::class.java) - startLoading(view) - idle() + startLoading() assertNull( "a scheme-less site root should not bring up the server", uploadServerOf(view) @@ -257,8 +253,7 @@ class GutenbergViewUploadServerTest { val view = makeView(authHeader = "") try { view.mediaProcessor = mock(MediaProcessor::class.java) - startLoading(view) - idle() + startLoading() assertNull( "a processor with no credentials should not bring up the server", uploadServerOf(view) @@ -272,8 +267,7 @@ class GutenbergViewUploadServerTest { fun `setting the processor after the page has started loading throws`() { val view = makeView() try { - startLoading(view) - idle() + startLoading() // The processor is captured at load; a later assignment is a programmer // error and must surface loudly rather than silently do nothing. assertThrows(IllegalStateException::class.java) { @@ -288,8 +282,7 @@ class GutenbergViewUploadServerTest { fun `detaching the view stops and clears the upload server`() { val view = makeView() view.mediaProcessor = mock(MediaProcessor::class.java) - startLoading(view) - idle() + startLoading() assertNotNull(uploadServerOf(view)) detach(view) diff --git a/android/app/src/main/java/com/example/gutenbergkit/GutenbergKitApplication.kt b/android/app/src/main/java/com/example/gutenbergkit/GutenbergKitApplication.kt index 9c393c4b7..e96fb92a8 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/GutenbergKitApplication.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/GutenbergKitApplication.kt @@ -83,9 +83,9 @@ class GutenbergKitApplication : Application() { } private fun enableStrictMode() { - // ThreadPolicy only logs for now: the library still reads the disk on - // the main thread while the editor page loads, so penaltyDeath would - // crash every editor launch. Switch to penaltyDeath once #848 is fixed. + // A ThreadPolicy violation crashes the demo, so new main-thread disk or + // network work in the library fails loudly during development. The log + // keeps the full violation, not just the crash. // VmPolicy stays on penaltyLog — VM violations like // LeakedClosableObject fire during GC and would crash at unpredictable // times unrelated to the offending code path. @@ -93,6 +93,7 @@ class GutenbergKitApplication : Application() { StrictMode.ThreadPolicy.Builder() .detectAll() .penaltyLog() + .penaltyDeath() .build() ) StrictMode.setVmPolicy( diff --git a/docs/code/strictmode.md b/docs/code/strictmode.md index b2eb9583e..9b3d363fe 100644 --- a/docs/code/strictmode.md +++ b/docs/code/strictmode.md @@ -5,12 +5,8 @@ disk I/O on the main thread, leaked closables, registration leaks, and similar issues introduced inside the GutenbergKit library surface during local development. -For now, violations are **logged, not fatal**. The library still reads the disk -on the main thread while the editor page loads (see -[What's still in the library](#whats-still-in-the-library)), so crashing on -violations would crash every editor launch. Switching `ThreadPolicy` to -`penaltyDeath()` is tracked in -[#848](https://github.com/wordpress-mobile/GutenbergKit/issues/848). +`ThreadPolicy` violations **crash the demo**, so new main-thread disk or network +work fails loudly during development. `VmPolicy` violations are logged only. ## Configuration @@ -22,6 +18,7 @@ StrictMode.setThreadPolicy( StrictMode.ThreadPolicy.Builder() .detectAll() .penaltyLog() + .penaltyDeath() .build() ) StrictMode.setVmPolicy( @@ -42,7 +39,9 @@ Configuration notes: `detectFileUriExposure`, `detectCleartextNetwork`, etc. - **`Application.onCreate()`** is the right place — earlier is better so the policy covers everything that follows. -- **`VmPolicy` stays on `penaltyLog()`, even after #848.** VM violations like +- **`ThreadPolicy` keeps `penaltyLog()` alongside `penaltyDeath()`.** The log + prints the full violation before the crash. +- **`VmPolicy` stays on `penaltyLog()`.** VM violations like `LeakedClosableObject` fire during GC and would crash at unpredictable times unrelated to the offending code path, which is unhelpful as a signal. - **`penaltyDialog()` is deliberately not enabled.** It interrupts manual @@ -103,20 +102,13 @@ exposes a `suspend` API: - `EditorDependenciesSerializer.writeToDisk` / `EditorDependenciesSerializer.readFromDisk`. -### Not wrappable — tracked in #848 +### Editor load -Most of these run on the main thread inside `GutenbergView.onEditorPageStarted`, -which `WebViewClient.onPageStarted` calls. The last one runs from the -`GutenbergView` constructor. The host has no call site to move, so the demo -logs them on every editor launch: - -| Violation | Where | When | -| -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------ | -| `DiskReadViolation` | `EditorAssetBundle.getEditorRepresentation`, via `GBKitGlobal.fromConfiguration` | Every editor launched with prepared dependencies | -| `CustomViolation: newSSLContext` | `GutenbergView.uploadHttpClient` (OkHttp SSL setup), built in `startUploadServer` | Native media upload is on | -| `DiskReadViolation` | `GutenbergView.startUploadServer` (`context.cacheDir`) | Native media upload is on | -| `DiskReadViolation` | `HttpServer.start` → `TempFileOwner.cleanOrphans` | Native media upload is on | -| Several `DiskReadViolation`s, `CustomViolation: newSSLContext` | `GutenbergView.prepareAndLoadEditor` → `EditorService.create`, launched on the main dispatcher | The editor starts without prepared dependencies | +`GutenbergView` starts the upload server and builds the editor globals on +`Dispatchers.IO` before it loads the editor page. When the host passes no +prepared dependencies, it also creates and prepares its `EditorService` there. +Opening the editor does no disk or SSL work on the main thread +([#848](https://github.com/wordpress-mobile/GutenbergKit/issues/848)). ### Expected `VmPolicy` noise @@ -137,12 +129,14 @@ actual call site. ## Verified flows -Exercised on an API 36 emulator, with only the violations listed above: +Exercised on an API 36 emulator, with only the `VmPolicy` noise listed above: - App cold launch, including signing in with `make login-android-app`. - **Standalone editor** → **Prepare Editor** → **Start**, then type. - A connected site → **Prepare Editor** → **Browse** → open a post, then type. - A connected site → **Browse** → open a post, without preparing first. +- A connected site → **Start**, with and without **Prepare Editor**, and with + **Enable Native Media Upload** on and off. - Native inserter sheet: category tabs and search. - Background the app, then resume it. @@ -157,8 +151,8 @@ resources: 1. Run the demo in debug mode. 2. Walk through the new code path end-to-end with `adb logcat -s StrictMode` open. -3. If a new `ThreadPolicy` violation shows up, the offending call is in the - stack trace — wrap it in `withContext(Dispatchers.IO)` (or move it into a - coroutine). +3. If the demo crashes with a `ThreadPolicy` violation, the offending call is + in the stack trace — wrap it in `withContext(Dispatchers.IO)` (or move it + into a coroutine). 4. **Never** suppress with `StrictMode.allowThreadDiskReads()` inside the library — that hides the violation from host apps.