Skip to content

Enforce the LCP copy allowance in the navigators - #826

Draft
mickael-menu wants to merge 7 commits into
developfrom
fix-issue-223
Draft

mickael-menu wants to merge 7 commits into
developfrom
fix-issue-223

Conversation

@mickael-menu

Copy link
Copy Markdown
Member

Fixes #223, and #221.

Protected publications no longer have text selection disabled wholesale (the user-select: none hack). Instead, all navigators enforce the Content Protection's copy allowance themselves, mirroring the Swift toolkit's design:

  • Legacy EPUB navigator — the JS layer intercepts DOM copy events (system selection menu Copy, Ctrl+C) and routes them through a counted native copy. A new stable EpubNavigatorFragment.copySelection(): Try<Unit, CopyError> supports apps with a custom selection ActionMode callback. disableSelectionWhenProtected is deprecated and ignored.
  • New web renditions (reflowable + fixed layout) — same interception via a CopyInterceptor script; SelectionController.copySelection() and a new CopyListener parameter report forbidden copies.
  • PSPDFKit navigator — the built-in Copy popup toolbar item is intercepted to perform a counted copy (verified against PSPDFKit 8.4.1 that the custom click listener suppresses the default copy). Copy and Speak stay available; Share is still stripped; disableCopyPaste() is removed.
  • Shared — a single CopyError type and an internal atomic (Main.immediate + NonCancellable) copyToClipboard helper: the allowance is consumed before writing the clipboard, and a cancelled scope can never strand a spent allowance. Denied copies leave the clipboard untouched on programmatic paths; interception paths clear it as a fail-safe for WebView builds ignoring preventDefault().
  • LCP — fixed inverted comparisons in canCopy(text)/canPrint(pageCount) (with unit tests) and allowance-consumption failures are now always logged.
  • Apps & docs — the test app and navigator demo demonstrate the required pattern (custom Copy item routed through copySelection(), snackbar/toast on denial); CHANGELOG, migration guide and LCP guide document the compliance warning: apps targeting the EDRLab certification MUST remove text-leaking system menu items (Share, Web Search, Translate) and never write the clipboard directly.

⚠️ Remaining manual verification (needs a device): the test matrix in the plan — preventDefault() suppressing the clipboard write across WebView versions, exact allowance decrements, and whether removing disableCopyPaste() re-enables a keyboard copy path in PSPDFKit.

🤖 Generated with Claude Code

mickael-menu and others added 7 commits July 31, 2026 10:13
The allowance checks only allowed over-budget copies/prints. Also log
allowance-consumption failures at error level in all builds, so
transient database errors are diagnosable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ener

Groundwork for counted copy enforcement in the navigators: a single
CopyError type shared by legacy and new navigators, an internal atomic
(non-cancellable, main-thread) helper consuming the copy allowance
before writing the clipboard, and a default no-op
Navigator.Listener.onCopyForbidden() callback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replaces the user-select:none hack on protected publications with real
copy enforcement: DOM copy events (system selection menu Copy, Ctrl+C)
are intercepted in the JS layer and routed through a counted native
copy consuming the Content Protection's copy right. Adds a stable
EpubNavigatorFragment.copySelection() for apps with a custom selection
ActionMode callback, and deprecates disableSelectionWhenProtected,
which is now ignored.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replaces the user-select:none hack with proper copy-allowance
enforcement in the Compose reflowable and fixed-layout renditions: a JS
CopyInterceptor forwards intercepted copy events (system selection menu
Copy, Ctrl+C) to native code performing a counted copy,
SelectionController.copySelection() performs counted programmatic
copies, and the new CopyListener reports forbidden copies to the app.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replaces disableCopyPaste() on protected publications with an
interception of the built-in Copy popup toolbar item, which consumes
the Content Protection's copy right before writing to the clipboard.
Speak and Copy remain available; Share is still stripped. On denial,
Navigator.Listener.onCopyForbidden() is notified.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both apps replace the system selection menu with a custom ActionMode
callback, which loses the system Copy item. They now add their own Copy
item routed through the navigator's copySelection(), which enforces the
copy allowance of protected publications, and surface forbidden copies
with a snackbar (test app) or toast (demo) also used for copies
intercepted by the navigator.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds CHANGELOG entries with the compliance warning, a migration guide
section with the required copySelection() pattern for custom selection
ActionMode callbacks, a Copy protection section in the LCP guide, a
CONTEXT.md domain glossary, and unit tests for the canCopy/canPrint
boundary fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 31, 2026 09:07

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the Kotlin navigators to enforce LCP (and other Content Protection) copy allowances by intercepting copy operations (system menu / Ctrl+C) and routing them through counted native copies, instead of disabling selection wholesale.

Changes:

  • Introduces a shared CopyError model and a copyToClipboard() helper which consumes allowance before writing to the clipboard (with optional clearing on denial for interception paths).
  • Adds navigator-level copy interception for legacy EPUB WebView, new Compose web renditions (reflowable + fixed layout), and PSPDFKit; adds app/demo wiring to handle “copy forbidden” feedback.
  • Fixes inverted LCP allowance comparisons in canCopy() / canPrint() and adds unit tests; updates docs/migration guidance and changelog.

Reviewed changes

Copilot reviewed 43 out of 49 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
test-app/src/main/res/values/strings.xml Adds UI strings for Copy action and “copy forbidden” feedback.
test-app/src/main/res/menu/menu_action_mode.xml Adds a Copy item to the selection ActionMode menu.
test-app/src/main/java/org/readium/r2/testapp/reader/VisualReaderFragment.kt Routes custom ActionMode Copy through EpubNavigatorFragment.copySelection().
test-app/src/main/java/org/readium/r2/testapp/reader/ReaderViewModel.kt Emits a CopyForbidden command for UI feedback.
test-app/src/main/java/org/readium/r2/testapp/reader/ReaderActivity.kt Shows a Snackbar when copy is forbidden.
readium/shared/src/main/java/org/readium/r2/shared/publication/services/CopyError.kt Adds CopyError and UserRights.copyToClipboard() helper.
readium/navigators/web/reflowable/src/main/kotlin/org/readium/navigator/web/reflowable/resource/ReflowableResource.kt Wires WebView JS copy interception into reflowable resources.
readium/navigators/web/reflowable/src/main/kotlin/org/readium/navigator/web/reflowable/ReflowableWebRenditionState.kt Adds protection state + counted-copy plumbing for reflowable web rendition.
readium/navigators/web/reflowable/src/main/kotlin/org/readium/navigator/web/reflowable/ReflowableWebRenditionFactory.kt Passes rights/isProtected into reflowable rendition state.
readium/navigators/web/reflowable/src/main/kotlin/org/readium/navigator/web/reflowable/ReflowableWebRendition.kt Adds CopyListener and dispatches forbidden-copy notifications for intercepted copies.
readium/navigators/web/reflowable/src/main/kotlin/org/readium/navigator/web/reflowable/injection/ReflowableHtmlInjector.kt Removes the “disable selection” CSS injection hack.
readium/navigators/web/internals/src/main/kotlin/org/readium/navigator/web/internals/webapi/CopyApi.kt Adds native JS-bridge API to gate/intercept copy events.
readium/navigators/web/internals/src/main/kotlin/org/readium/navigator/web/internals/util/HtmlInjector.kt Removes the legacy disableSelectionInjectable constant.
readium/navigators/web/internals/src/main/assets/readium/navigator/web/internals/generated/fixed-injectable-script.js Updates generated fixed-layout injectable script (now includes copy interception).
readium/navigators/web/internals/scripts/src/index-reflowable-injectable.ts Installs CopyInterceptor + SelectionReporter in reflowable injectable script.
readium/navigators/web/internals/scripts/src/index-fixed-injectable.ts Installs CopyInterceptor + SelectionReporter in fixed-layout injectable script.
readium/navigators/web/internals/scripts/src/common/copy.ts Adds JS-side CopyInterceptor and listener bridge types.
readium/navigators/web/fixedlayout/src/main/kotlin/org/readium/navigator/web/fixedlayout/spread/SingleViewportSpread.kt Wires copy interception into the single-viewport fixed-layout WebView.
readium/navigators/web/fixedlayout/src/main/kotlin/org/readium/navigator/web/fixedlayout/spread/DoubleViewportSpread.kt Wires copy interception into the double-viewport fixed-layout WebView.
readium/navigators/web/fixedlayout/src/main/kotlin/org/readium/navigator/web/fixedlayout/injection/FixedHtmlInjector.kt Removes the “disable selection” CSS injection hack for fixed layout.
readium/navigators/web/fixedlayout/src/main/kotlin/org/readium/navigator/web/fixedlayout/FixedWebRenditionState.kt Adds rights/protection + counted-copy plumbing for fixed-layout web rendition.
readium/navigators/web/fixedlayout/src/main/kotlin/org/readium/navigator/web/fixedlayout/FixedWebRenditionFactory.kt Passes rights/isProtected into fixed-layout rendition state.
readium/navigators/web/fixedlayout/src/main/kotlin/org/readium/navigator/web/fixedlayout/FixedWebRendition.kt Adds CopyListener and dispatches forbidden-copy notifications for intercepted copies.
readium/navigators/common/src/main/java/org/readium/navigator/common/SelectionController.kt Adds copySelection() API for counted programmatic copies.
readium/navigators/common/src/main/java/org/readium/navigator/common/CopyListener.kt Adds CopyListener and NullCopyListener for intercepted-copy denial notifications.
readium/navigator/src/main/java/org/readium/r2/navigator/R2BasicWebView.kt Adds JS-bridge gate + callback for copy interception.
readium/navigator/src/main/java/org/readium/r2/navigator/Navigator.kt Adds Navigator.Listener.onCopyForbidden() callback.
readium/navigator/src/main/java/org/readium/r2/navigator/epub/WebViewServer.kt Removes disableSelectionWhenProtected plumbing.
readium/navigator/src/main/java/org/readium/r2/navigator/epub/HtmlInjector.kt Removes “disable selection” injection in legacy EPUB navigator.
readium/navigator/src/main/java/org/readium/r2/navigator/epub/EpubNavigatorViewModel.kt Adds counted-copy helpers for intercepted and programmatic copy paths.
readium/navigator/src/main/java/org/readium/r2/navigator/epub/EpubNavigatorFragment.kt Adds copySelection() and hooks copy interception to listener callbacks.
readium/navigator/src/main/assets/_scripts/src/selection.js Intercepts DOM copy event and forwards selected text to native.
readium/lcp/src/test/java/org/readium/r2/lcp/license/LicenseTest.kt Adds unit tests validating canCopy() / canPrint() boundaries.
readium/lcp/src/test/java/org/readium/r2/lcp/fakes/FakeLcpDao.kt Makes FakeLcpDao open for test customization.
readium/lcp/src/main/java/org/readium/r2/lcp/license/License.kt Fixes inverted comparisons and ensures allowance-consumption failures are logged.
readium/adapters/pspdfkit/navigator/src/main/java/org/readium/adapter/pspdfkit/navigator/PsPdfKitEngineProvider.kt Wires onCopyForbidden() through PSPDFKit engine callbacks.
readium/adapters/pspdfkit/navigator/src/main/java/org/readium/adapter/pspdfkit/navigator/PsPdfKitDocumentFragment.kt Intercepts PSPDFKit Copy item to perform counted copy; removes disableCopyPaste().
docs/migration-guide.md Documents the new copy enforcement model and required app changes.
docs/guides/lcp.md Documents copy allowance behavior and onCopyForbidden() guidance.
demos/navigator/src/main/res/menu/menu_action_mode.xml Adds Copy item to the demo ActionMode menu.
demos/navigator/src/main/java/org/readium/demo/navigator/reader/Rendition.kt Wires CopyListener and shows user feedback on forbidden copies.
demos/navigator/src/main/java/org/readium/demo/navigator/reader/ActionMode.kt Routes custom ActionMode Copy through SelectionController.copySelection().
CONTEXT.md Adds domain glossary for copy protection concepts.
CHANGELOG.md Adds changelog entries for copy enforcement + LCP fixes.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +121 to 136
var copyListenerApi by remember(webViewState.webView) {
mutableStateOf<CopyListenerApi?>(null)
}

val onCopyInterceptedRef by rememberUpdatedRef(onCopyIntercepted)

LaunchedEffect(webViewState.webView) {
webViewState.webView?.let { webView ->
gesturesApi = GesturesApi(webView)
documentStateApi = DocumentStateApi(webView)
selectionListenerApi = SelectionListenerApi(webView)
copyListenerApi = CopyListenerApi(webView, interceptEnabled = interceptCopy) {
onCopyInterceptedRef(it)
}
}
}
Comment on lines +62 to +71
@android.webkit.JavascriptInterface
public fun shouldInterceptCopy(): Boolean =
interceptEnabled && isSelecting

@android.webkit.JavascriptInterface
public fun onCopyIntercepted(text: String) {
coroutineScope.launch {
listener?.onCopyIntercepted(text)
}
}
Comment on lines +466 to +473
@android.webkit.JavascriptInterface
fun shouldInterceptCopy(): Boolean =
(listener?.shouldInterceptCopy == true) && isSelecting

@android.webkit.JavascriptInterface
fun onCopyIntercepted(text: String) {
listener?.onCopyIntercepted(text)
}
@mickael-menu
mickael-menu marked this pull request as draft July 31, 2026 09:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

LCP: manage copy / share constraints

2 participants