Skip to content
CleverKeys Wiki
implemented 1.5.0 User guide

Private Copy Technical Specification

Overview

Private copy (#156) adds a copy path that stores selected text directly into CleverKeys’ clipboard database with an is_private marker, never invoking Android’s ClipboardManager. It has two entry points that feed one storage primitive, ClipboardHistoryService.addPrivateClip, and surfaces private entries in the existing panel with a lock badge, provenance, an export-exclusion policy, and a confirm gate before any private entry can be pushed to the OS clipboard.

The baseline gap this fills: paste from the panel was already private (KeyEventHandler.paste_from_clipboard_pane commits text directly through the InputConnection, no ClipboardManager), but every copy path went through the OS clipboard. Private copy is the missing acquisition half — get selected text into ClipboardDatabase without the OS clipboard ever holding it.

Design source: docs/audit/remediation-plans/156-private-copy-paste.md.

Key Components

ComponentFilePurpose
PROCESS_TEXT activity (entry point B)src/main/kotlin/tribixbite/cleverkeys/PrivateCopyProcessTextActivity.kt:35Exported selection-toolbar receiver; entire lifecycle in onCreatehandle()finish()
Intent parser (pure JVM)src/main/kotlin/tribixbite/cleverkeys/clipboard/PrivateCopyIntentParser.kt:17Only intent-reading code; validates action + single EXTRA_PROCESS_TEXT, trims, UTF-8 byte cap
Rate limiter (pure JVM)src/main/kotlin/tribixbite/cleverkeys/clipboard/PrivateCopyRateLimiter.kt:17Sliding-window 10/caller/min + 30/min global; injectable clock; process-lifetime state
Merge rule (pure JVM object)ClipboardDatabase.kt:68 (object PrivateClipMergeRule)Sticky-privacy dedup: mergeIsPrivate (OR), mergeSourcePackage (most-recent-non-null)
Write pathClipboardHistoryService.kt:288 (addPrivateClip), :301 (storeClip), :923 (privateCopy)Shared core with addClip; private path passes rewriteOsClipboard = false
V5 migration + schemaClipboardDatabase.kt:244 (onUpgrade branch), :1802 (DATABASE_VERSION = 5)ALTER TABLE ADD COLUMN on all three tables
Export exclusionClipboardDatabase.kt:1397 (exportToJSON(textOnly, includePrivate))Option B: exclude from plaintext, include in encrypted, marker round-trips
Confirm gate (read path)ClipboardHistoryView.kt:761 (copyEntryToSystemClipboard)Confirm dialog before pushing a private entry to the OS clipboard
Panel badge + provenanceClipboardHistoryView.kt:967, :1060 (privateBadge)Lock badge visible for entry.isPrivate
Editing key (entry point A)KeyValue.kt:103 (Editing.COPY_PRIVATE), :719 ("copy_private"🔒⎘)Named key + glyph, FLAG_SMALLER_FONT
Editing-key dispatchKeyEventHandler.kt:645 (handleEditingKey), Keyboard2View.kt:763 (executeEditingCommand)getSelectedText(0)privateCopy; edit-mode no-op
Settings toggle + component flipClipboardSection.kt:238 (toggle), :351 (setPrivateCopyToolbarComponentEnabled)Flips the manifest-disabled component via PackageManager

Architecture

Entry point A (in-IME key)                Entry point B (PROCESS_TEXT toolbar)
──────────────────────────                ────────────────────────────────────
KeyEventHandler.handleEditingKey          PrivateCopyProcessTextActivity.onCreate
  / Keyboard2View.executeEditingCommand     ├─ Config.initGlobalConfig if null
  COPY_PRIVATE branch                       ├─ Guard: isUserUnlocked()
  ├─ getSelectedText(0)                      ├─ Guard: PREF_TOOLBAR_ENABLED
  ├─ null/empty → "No text selected"         ├─ PrivateCopyIntentParser.parse(...)
  └─ ClipboardHistoryService.privateCopy     ├─ PrivateCopyRateLimiter.tryAcquire(caller)
       (ctx, text, EditorInfo.packageName)   └─ ClipboardHistoryService.privateCopy
                                                  (ctx, text, callingPackage ?: "direct-launch")
                    │                                        │
                    └───────────────┬────────────────────────┘

                 ClipboardHistoryService.addPrivateClip(text, sourcePackage)
                    → storeClip(rewriteOsClipboard = false, isPrivate = true, sourcePackage)
                        ├─ size cap  (identical to addClip)
                        ├─ URL sanitizer (identical — store hygiene)
                        ├─ systemClipboardRewrite  ← SKIPPED (rewriteOsClipboard = false)
                        └─ addClipboardEntry(content, expiry, isPrivate = true, sourcePackage)
                             → sticky-privacy dedup (PrivateClipMergeRule)
                             → INSERT is_private=1, source_package


                 Panel: lock badge + provenance; paste stays private (panel-paste path);
                 copyEntryToSystemClipboard() confirms before any OS-clipboard push.

Key Code Patterns

The single load-bearing difference: the private path never rewrites the OS clipboard

addClip and addPrivateClip share storeClip; they differ only in the rewriteOsClipboard flag, which gates the one systemClipboardRewrite call. From ClipboardHistoryService.kt:275, :288:

storeClip(clip, rewriteOsClipboard = true, isPrivate = false, sourcePackage = null)
fun addPrivateClip(text: String?, sourcePackage: String?) {
    storeClip(text, rewriteOsClipboard = false, isPrivate = true, sourcePackage = sourcePackage)

And the guarded call inside storeClip (ClipboardHistoryService.kt:344):

if (rewriteOsClipboard) {
    systemClipboardRewrite(

Pure-JVM intent parsing (structurally closes the confused-deputy class)

PrivateCopyIntentParser.parse takes only primitives + a CharSequence — no Intent, so it cannot receive clipData, EXTRA_STREAM, or a URI (PrivateCopyIntentParser.kt:49):

fun parse(action: String?, text: CharSequence?, maxBytes: Int): Result {
    if (action != ACTION_PROCESS_TEXT) return Result.Reject(Reason.WRONG_ACTION)
    if (text == null) return Result.Reject(Reason.NO_EXTRA)
    val str = text.toString()
    val trimmed = str.trim()
    if (trimmed.isEmpty()) return Result.Reject(Reason.BLANK)
    if (maxBytes > 0) {
        val bytes = trimmed.toByteArray(StandardCharsets.UTF_8).size
        if (bytes > maxBytes) return Result.Reject(Reason.OVER_CAP)
    }
    return Result.Accept(trimmed)
}

Config-init trap in the standalone activity

The activity is a valid cold-start process entry point, and Config.globalConfig() throws when the IME hasn’t initialized it. It follows the SwipeCalibrationActivity precedent (PrivateCopyProcessTextActivity.kt:55):

if (Config.globalConfigOrNull() == null) {
    val prefs = DirectBootAwarePreferences.get_shared_preferences(this)
    Config.initGlobalConfig(prefs, resources, null, false)
}

Schema V5 (clipboard DB)

DATABASE_VERSION = 5 (ClipboardDatabase.kt:1802). The onUpgrade V4→V5 branch runs an O(1), non-destructive ALTER TABLE ADD COLUMN on all three tables (clipboard_entries, pinned_entries, todo_entries — pin/todo use COPY semantics, so the marker must travel). From ClipboardDatabase.kt:253:

db.execSQL("ALTER TABLE $table ADD COLUMN $COLUMN_IS_PRIVATE INTEGER NOT NULL DEFAULT 0")
db.execSQL("ALTER TABLE $table ADD COLUMN $COLUMN_SOURCE_PACKAGE TEXT")
ColumnTypeSemantics
is_private (COLUMN_IS_PRIVATE, :1830)INTEGER NOT NULL DEFAULT 01 = private. Existing/normal rows default to 0.
source_package (COLUMN_SOURCE_PACKAGE, :1831)TEXT (nullable)Provenance. Entry A: EditorInfo.packageName; entry B: getCallingPackage() or "direct-launch"; NULL otherwise.

Sticky-privacy dedup

addClipboardEntry dedups by content_hash + content and moves a duplicate to the top. On merge it applies PrivateClipMergeRule (ClipboardDatabase.kt:288):

// #156: sticky-privacy merge — is_private ORs; source_package most-recent-non-null.
val mergedPrivate = PrivateClipMergeRule.mergeIsPrivate(cursor.getInt(1) != 0, isPrivate)
  • is_private := old OR new — privately copying text that already exists as a normal entry upgrades the row to private (henceforth export-excluded and push-gated). A later normal copy of existing private content keeps is_private = 1 (that normal copy already put the text on the OS clipboard; stickiness preserves policy, it does not un-expose).
  • source_package: most-recent-non-null wins.

COPY-propagation through pin / todo

pinEntry (ClipboardDatabase.kt:549) and addTodoEntry (:776) read the source row’s is_private/source_package, sticky-merge with any explicit args, and write the merged values into the copy — so a pinned private entry stays private forever (pins don’t expire).

Export Exclusion (Option B)

exportToJSON(textOnly = false, includePrivate = true) (ClipboardDatabase.kt:1397) gates private rows across all three tables:

if (isPrivate && !includePrivate) { privateSkipped++; continue }
  • Plaintext export passes includePrivate = false: private entries are excluded and counted; the export summary appends " (N private excluded)" (:1528). A plaintext JSON on a shared drive never contains private entries.
  • Encrypted (CKENC) export passes includePrivate = true: private rows are written with their is_private/source_package fields, so the marker round-trips through backup/restore. Absent fields default to non-private for old backups.

Threat Model — the exported PROCESS_TEXT activity

The activity is exported by design but ships android:enabled="false" in the manifest (AndroidManifest.xml:136); the settings toggle flips the component. While disabled it is invisible to resolvers — zero exported surface for users who never opt in, preserving the prior de-export hardening posture.

PropertyMitigation
Default-offandroid:enabled="false"; setPrivateCopyToolbarComponentEnabled flips via setComponentEnabledSetting(..., DONT_KILL_APP), using STATE_DISABLED (not _DEFAULT) on the off-path (ClipboardSection.kt:351).
ConfidentialityNo read path exists. The activity never calls setResult — default RESULT_CANCELED, no result extras, so a caller learns nothing and the host app’s text/selection is never mutated.
Confused deputyThe pure-JVM parser is the only intent-reading code and cannot receive clipData/URIs — no ContentResolver, no file paths, no granted permissions.
Rate / spamPrivateCopyRateLimiter: 10 accepts/caller/min + 30/min global, excess dropped silently with Log.w (no toast — no UI channel for a flooder). Layered on top of platform background-activity-launch restrictions.
ProvenancegetCallingPackage() (kernel-attested, unspoofable) recorded as source_package; a forResult-less launch (which the real toolbar never does) is recorded as "direct-launch" — an injection tell.
Direct BootdirectBootAware="false" + an isUserUnlocked() guard — the DB lives in Credential-Encrypted storage.
WindowlessTheme.NoDisplay; finish() runs inside onCreate’s finally, satisfying the “must finish before onResume” contract.

Configuration

SettingKeyDefaultSource
Private copy in other apps’ selection menusclipboard_private_copy_toolbar_enabledfalsePrivateCopyProcessTextActivity.kt:119 (PREF_TOOLBAR_ENABLED), toggle at ClipboardSection.kt:238, classified in backup/SettingsDefaults.kt:231

The pref is the single source of truth; the component-enabled state is derived from it (no second source of truth). The SettingsDefaultsDriftTest tripwire requires the key to be classified in SETTINGS_DEFAULTS.

Test Coverage

SuiteFileCases
Pure JVMsrc/test/kotlin/tribixbite/cleverkeys/clipboard/PrivateCopyIntentParserTest.kt14
Pure JVMsrc/test/kotlin/tribixbite/cleverkeys/clipboard/PrivateCopyRateLimiterTest.kt7
Pure JVMsrc/test/kotlin/tribixbite/cleverkeys/clipboard/PrivateClipMergeRuleTest.kt9
MockKsrc/test/kotlin/tribixbite/cleverkeys/PrivateCopyServiceTest.kt7
Instrumentedsrc/androidTest/kotlin/tribixbite/cleverkeys/PrivateCopyProcessTextActivityTest.kt5
Instrumentedsrc/androidTest/kotlin/tribixbite/cleverkeys/ClipboardDatabaseV5MigrationTest.kt8
Instrumentedsrc/androidTest/kotlin/tribixbite/cleverkeys/PrivateCopyEditingKeyTest.kt2
Instrumentedsrc/androidTest/kotlin/tribixbite/cleverkeys/ClipboardPanelPrivateBadgeTest.kt4

PrivateCopyServiceTest pins the load-bearing regression: verify(exactly = 0) { cm.setPrimaryClip(any()) } across the whole private path (it lives in src/test/ but uses MockK, so it runs under runMockTests). A shared PrivateCopyClipboardTestHelper.kt supports the instrumented suites.