Skip to content
CleverKeys Wiki
implemented v1.4.0 User guide

Clipboard History Technical Specification

Overview

The clipboard history system maintains a persistent store of copied content — text, images, videos, PDFs, and other media — with support for independent pinned and todo tables, search, auto-expiry, pagination, media thumbnails, and privacy protection.

Key Components

ComponentFilePurpose
ClipboardDatabaseClipboardDatabase.ktSQLite storage, CRUD, migration, export/import
ClipboardHistoryServiceClipboardHistoryService.ktSystem clipboard listener, IO dispatch, media capture
ClipboardHistoryViewClipboardHistoryView.ktUI panel, adapter, thumbnail rendering, paste
ClipboardMediaManagerClipboardMediaManager.ktMedia file storage, thumbnails, cleanup
ClipboardManagerClipboardManager.ktClipboard pane lifecycle, tab wiring, pagination
ClipboardEntryClipboardEntry.ktData model for clipboard items
PinnedEntryPinnedEntry.ktData model for pinned items (COPY semantics)
TodoEntryTodoEntry.ktData model for todo items (COPY semantics)
ConfigConfig.ktClipboard preferences and toggles

Size Filtering, Selection and Bulk Actions

The active tab’s query combines text, date/tag/type filters, and minimum/maximum payload bytes before pagination. Size bounds remain active when switching tabs; clearing all filters resets them. The native dialog disables Apply for an inverted range and preserves previous bounds on Cancel.

Batch work goes through a persistent selection (2026-10-07). Select enters selection mode: rows show a checkbox, tapping (or long-pressing) a row toggles it, and per-row actions are hidden. The selection bar has five actions:

ActionEffect
Select/deselect all matchingTri-state; adds every row matching the current search and filters on all pages, or removes them when all are selected
Clear selectionDeselects everything (including rows the current search hides); stays in selection mode
More actions (⋮)Add to Pinned (not on Pinned), Add to Todos (not on Todos), Merge, Clean; disabled tabs are never offered as targets
Delete selectedConfirmation, then a transactional delete
Exit selectionEnds selection mode and forgets the selection

The selection holds row ids plus a 64-bit payload version per row, never content. It is not a filter: it survives search, regex, size/date/private/tag/status filter changes and paging. It also survives closing and reopening the pane, switching to the emoji/GIF pane and back, hiding the keyboard, rotation, switching fields or apps, and the theme rebuild; the pane reopens on the selection’s tab. It ends only on Exit selection, a completed bulk action (Delete, Add to Pinned, Add to Todos, Merge, Clean), or its tab being disabled in Settings. It lives in memory in ClipboardSelectionHolder, so it is lost if Android kills the keyboard process. A selection belongs to the tab it started in; the other tab icons are hidden until Exit. Every completed load of that tab drops rows that vanished or whose payload changed.

Each action freezes the selected, unchanged rows into a snapshot when its dialog opens. Delete selected re-checks each row by identity in one transaction, preserves new or changed rows and copies in other tabs, and removes media only when no tab references it. Add to Pinned/Todos copy rows (text and media) in one transaction; rows already present are counted, not duplicated. Merge creates one new History clipping from the selected text rows, oldest first, joined by newlines; media is skipped, the result must fit clipboard_max_item_size_kb, and it is private if any source is private. Clean edits text rows in place (trailing whitespace removed, soft line breaks inside paragraphs joined) through the inline-edit path. A failed action keeps the selection. Reported payload bytes are not a disk-reclamation estimate. The clear_clipboard command clears only Android’s system clipboard; it does not invoke database deletion.

Swipe to delete a row (#175 part 2, 2026-10-21)

In normal mode (not selecting, not editing, not in the tag panel, no bulk action running) a horizontal swipe on a row deletes it from the current tab only, through the same per-tab path as the row’s delete button (removeFromTab: history remove / unpin / un-todo, media removed when unreferenced). Classification (ClipboardSwipePolicy): nothing inside the touch slop; past it horizontal only if |dx| > 2·|dy|, else the ScrollView keeps the drag. A claimed row follows the finger; release beyond 35 % of its width or a ≥ 800 dp/s same-direction fling commits, otherwise it springs back. The row is hidden at once and “Deleted” + Undo show in the result row for 5 s (ClipboardPendingDeletion, held by the service-scoped ClipboardManager); Undo writes nothing. The delete runs at the end of the window, or at once on pane close/rebuild, keyboard hide, tab switch, search or filter change, selection, edit, tag panel, a bulk action, or the next swipe. Process death in the window keeps the entry. TalkBack: a “Delete” custom action per row takes the same path. Setting clipboard_swipe_delete_enabled (default on) turns the gesture off.

Keyboard dialogs. Confirmation, filter and size-chooser dialogs shown from the keyboard are non-focusable (ImeDialogWindowPolicy: FLAG_NOT_FOCUSABLE plus FLAG_WATCH_OUTSIDE_TOUCH), so the app’s editor keeps focus and the host app does not hide the keyboard when a dialog button is tapped; a touch outside cancels the dialog.

ClipboardFilterDialogTest covers the actual size dialog and a 205-result batch, including Cancel, new/edit races, and pinned/todo copy preservation. Internal architecture and validation documents the database predicates and snapshot transaction. Full-run results are recorded in testing strategy.

Capture While the Keyboard Is Closed (2026-10-08)

As the default input method, CleverKeys may read the clipboard and receive change callbacks regardless of focus or keyboard visibility (AOSP 13/14 ClipboardService), so a copy made while the keyboard is merely hidden is captured normally. Copies are missed only while no listener is registered: another keyboard is selected, the CleverKeys process is dead or restarting, or registration failed at onCreate.

onStartInputView calls ClipboardHistoryService.on_keyboard_shown(): an unregistered listener is registered again (which reads the current clip); otherwise, with history enabled, the current clip is read once and recorded only if it is a set event not already observed (ClipboardCatchUp, keyed by ClipDescription timestamp; content hash on API 24–25). Every read marks the clip observed before filtering, so skipped (IS_SENSITIVE, password manager, size) and user-deleted clips are not re-added. Only the clip that is current at that moment can be recovered; earlier copies made during the gap are lost. Tests: src/test/kotlin/tribixbite/cleverkeys/clipboard/ClipboardCatchUpTest.kt.

Data Models

ClipboardEntry (v4)

// ClipboardEntry.kt
class ClipboardEntry(
    @JvmField val content: String,
    @JvmField val timestamp: Long,
    @JvmField val mimeType: String = MIME_TEXT_PLAIN,
    @JvmField val thumbnailBlob: ByteArray? = null,
    @JvmField val mediaPath: String? = null
) {
    val isMedia: Boolean get() = mimeType != MIME_TEXT_PLAIN
    val isImage: Boolean get() = mimeType.startsWith("image/")
    val isVideo: Boolean get() = mimeType.startsWith("video/")
    val isPdf: Boolean get() = mimeType == "application/pdf"
    val hasThumbnail: Boolean get() = thumbnailBlob != null

    companion object {
        const val MIME_TEXT_PLAIN = "text/plain"
    }
}

PinnedEntry (v4)

// PinnedEntry.kt — independent table, COPY semantics from history
data class PinnedEntry(
    val content: String,
    val contentHash: String,
    val createdTimestamp: Long,
    val pinnedTimestamp: Long,
    val position: Double,       // REAL for drag-and-drop midpoint insertion
    val tags: List<String>,     // JSON array in TEXT column
    val mimeType: String = ClipboardEntry.MIME_TEXT_PLAIN,
    val thumbnailBlob: ByteArray? = null,
    val mediaPath: String? = null
)

TodoEntry (v4)

// TodoEntry.kt — independent table, COPY semantics from history
data class TodoEntry(
    val content: String,
    val contentHash: String,
    val createdTimestamp: Long,
    val addedTimestamp: Long,
    val position: Double,
    val status: String,         // 'active' | 'planned' | 'completed'
    val tags: List<String>,
    val mimeType: String = ClipboardEntry.MIME_TEXT_PLAIN,
    val thumbnailBlob: ByteArray? = null,
    val mediaPath: String? = null
)

Storage Schema (v4)

clipboard_entries — history only

CREATE TABLE clipboard_entries (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    content TEXT NOT NULL,
    timestamp INTEGER NOT NULL,
    expiry_timestamp INTEGER NOT NULL,
    content_hash TEXT NOT NULL,
    mime_type TEXT DEFAULT 'text/plain',     -- v4
    thumbnail_blob BLOB,                     -- v4: ≤10KB WebP thumbnail
    media_path TEXT                           -- v4: internal file path
);
CREATE INDEX idx_content_hash ON clipboard_entries(content_hash);
CREATE INDEX idx_timestamp ON clipboard_entries(timestamp DESC);
CREATE INDEX idx_expiry ON clipboard_entries(expiry_timestamp);

pinned_entries — independent from history

CREATE TABLE pinned_entries (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    content TEXT NOT NULL,
    content_hash TEXT NOT NULL,
    created_timestamp INTEGER NOT NULL,
    pinned_timestamp INTEGER NOT NULL,
    position REAL NOT NULL,                  -- drag-and-drop midpoint insertion
    tags TEXT DEFAULT '[]',                  -- JSON array
    mime_type TEXT DEFAULT 'text/plain',     -- v4
    thumbnail_blob BLOB,                     -- v4
    media_path TEXT                           -- v4
);
CREATE INDEX idx_pinned_hash ON pinned_entries(content_hash);
CREATE INDEX idx_pinned_pos ON pinned_entries(position ASC);

todo_entries — independent from history

CREATE TABLE todo_entries (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    content TEXT NOT NULL,
    content_hash TEXT NOT NULL,
    created_timestamp INTEGER NOT NULL,
    added_timestamp INTEGER NOT NULL,
    position REAL NOT NULL,
    status TEXT DEFAULT 'active',            -- 'active' | 'planned' | 'completed'
    tags TEXT DEFAULT '[]',
    mime_type TEXT DEFAULT 'text/plain',     -- v4
    thumbnail_blob BLOB,                     -- v4
    media_path TEXT                           -- v4
);
CREATE INDEX idx_todo_hash ON todo_entries(content_hash);
CREATE INDEX idx_todo_pos ON todo_entries(position ASC);
CREATE INDEX idx_todo_status ON todo_entries(status);

Migration Chain

VersionChangeStrategy
v1→v2Add is_todo columnALTER TABLE ADD COLUMN
v2→v3Independent pinned/todo tablesCREATE-COPY-DROP-RENAME (pre-API 34 compat)
v3→v4Media columns (mime_type, thumbnail_blob, media_path)ALTER TABLE ADD COLUMN × 9

All migrations are non-destructive. v2→v3 uses COPY semantics: pinned/todo entries are copied to new tables AND remain in history.

Clipboard Monitoring

System Clipboard Listener

// ClipboardHistoryService.kt
private fun addCurrentClip() {
    // 1. Check clipboard_history_enabled
    // 2. Check password manager exclusion (best-effort foreground app detection)
    // 3. Check Android 13+ IS_SENSITIVE flag
    // 4. For each ClipData item:
    //    - If item.text != null: addClip(text) on main thread
    //    - If item.uri != null: dispatch to Dispatchers.IO → processClipUri(uri)
}

Duplicate Handling — Move to Top (#108)

Copying text that is already in history does NOT create a second row and does NOT get silently ignored — the existing entry is moved to the top by refreshing its timestamps:

// ClipboardDatabase.kt:186-199 (inside addClipboardEntry)
db.rawQuery(duplicateQuery, arrayOf(contentHash, trimmedContent, currentTime.toString())).use { cursor ->
    if (cursor.moveToFirst()) {
        // #108: Move duplicate to top by updating its timestamp instead of ignoring
        val existingId = cursor.getLong(0)
        val updateValues = ContentValues().apply {
            put(COLUMN_TIMESTAMP, currentTime)
            put(COLUMN_EXPIRY_TIMESTAMP, expiryTimestamp)
        }
        db.update(TABLE_CLIPBOARD, updateValues, "$COLUMN_ID = ?", arrayOf(existingId.toString()))
        return true
    }
}

Duplicates are detected by content_hash + exact content match against non-expired entries. Media entries get the same treatment via their SHA-256 content hash (ClipboardDatabase.kt:245-252, “Duplicate media moved to top”). Pinned and todo tables behave differently: adding an already-pinned/already-todo item is rejected (return false) with no timestamp refresh — their ordering is user-managed (drag positions), not recency-based.

Content URI Processing (IO thread)

// ClipboardHistoryService.kt — runs on Dispatchers.IO
private fun processClipUri(uri: Uri) {
    val mimeType = contentResolver.getType(uri) ?: "application/octet-stream"
    if (mimeType.startsWith("text/")) {
        // Stream text via ContentResolver (bypasses Binder ~1MB limit)
        val text = readTextFromUri(uri)
        if (text != null) addClip(text)
    } else {
        // Media: check text-only and media-enabled toggles
        if (cfg.clipboard_text_only || !cfg.clipboard_media_enabled) return
        val result = mediaManager.saveMedia(uri, mimeType, maxMediaBytes)
        addMediaClip(result.displayName, result.mimeType, result.thumbnailBlob, ...)
    }
}

Media Settings Gating

Check PointSettingBehavior When Disabled
Captureclipboard_media_enabledMedia URIs silently dropped
Captureclipboard_text_onlyMedia URIs silently dropped
Displayclipboard_text_onlyMedia entries filtered from all tabs
Paste(always allowed)Falls back to text if media paste unsupported

Media Storage Architecture

ClipboardMediaManager

filesDir/
└── clipboard_media/
    ├── 000/          ← partition 0 (IDs 0-999)
    │   ├── a1b2c3.jpg
    │   └── d4e5f6.webp
    ├── 001/          ← partition 1 (IDs 1000-1999)
    └── ...
  • Partitioned storage: {id / 1000}/{sha256_hash}.{ext} — avoids >1000 files per directory
  • Max media file: configurable clipboard_max_media_size_mb (default 10MB, max 50MB)
  • External URIs copied immediately: content:// URIs expire when clipboard changes

Thumbnail Generation by MIME Type

MIME TypeMethodOutput
image/* (static)BitmapFactory + inSampleSize80×80 WebP ≤10KB
image/gif, image/webp (animated)BitmapFactory first frame80×80 WebP ≤10KB
video/*MediaMetadataRetriever.getFrameAtTime(0)80×80 WebP ≤10KB
application/pdfPdfRenderer first page80×80 WebP ≤10KB
UnknownNo thumbnailUI shows MIME-type icon
  • Thumbnails ≤10KB stored as BLOB in SQLite (CursorWindow is 2MB; 10KB × 200 rows = safe)
  • Animated detection: RIFF header VP8X chunk for WebP, NETSCAPE2.0 block for GIF

Orphan Cleanup

cleanupOrphans() runs on service startup and after import:

  1. Query all media_path values from all 3 tables
  2. Scan clipboard_media/ directories
  3. Delete files not referenced by any table
  4. Remove empty partition directories

Tab System

TabEnumData SourceQuery
HistoryClipboardTab.HISTORYclipboard_entriesclearExpiredAndGetHistory()
PinnedClipboardTab.PINNEDpinned_entriesgetPinnedEntries() ORDER BY position
TodosClipboardTab.TODOStodo_entriesgetTodoEntries() ORDER BY position

Pin/Todo Semantics (COPY, not MOVE)

  • Pin from History: COPIES content to pinned_entries. History entry stays (subject to expiry).
  • Todo from History: COPIES content to todo_entries. History entry stays.
  • Re-copying same text does NOT affect pinned/todo copies.
  • Deleting from history does NOT affect pinned/todo copies.
  • Media fields (mimeType, thumbnailBlob, mediaPath) are carried on pin/todo.

Tab Data Loading

// ClipboardHistoryView.kt — async off UI thread
private fun loadDataAsync() {
    loadJob?.cancel()
    loadJob = viewScope?.launch {
        val entries = withContext(Dispatchers.IO) {
            when (currentTab) {
                ClipboardTab.HISTORY -> service?.clearExpiredAndGetHistory() ?: emptyList()
                ClipboardTab.PINNED -> database.getPinnedEntries()
                ClipboardTab.TODOS -> database.getTodoEntries()
            }
        }
        // Filter out media entries when text-only mode active
        if (Config.globalConfig().clipboard_text_only) {
            entries = entries.filter { !it.isMedia }
        }
        history = entries
        applyFilter()
    }
}

Pagination

  • 100 items per page (ITEMS_PER_PAGE = 100)
  • Search filters ALL items before pagination
  • Pagination bar shows currentPage / totalPages with ◀ ▶ navigation

View Rendering

Adapter (ClipboardEntriesAdapter)

override fun getView(pos: Int, v: View?, parent: ViewGroup): View {
    val entry = paginatedHistory[pos]
    if (entry.isMedia) {
        // Show thumbnail container (48×48dp)
        if (entry.hasThumbnail) {
            // Decode BLOB to bitmap — no file I/O on UI thread
            val bitmap = BitmapFactory.decodeByteArray(entry.thumbnailBlob, 0, ...)
            thumbnailView.setImageBitmap(bitmap)
        } else {
            // Fallback: MIME-type icon (ic_media_image, ic_media_video, ic_media_pdf, ic_media_file)
            thumbnailView.setImageResource(getMimeTypeIcon(entry.mimeType))
        }
        // Animated badge for GIF/animated WebP
        playBadge.visibility = if (isAnimated) VISIBLE else GONE
    }
    // Text entry: show formatted text with relative timestamp ("2h ago")
}

Paste

fun paste_entry(pos: Int) {
    val entry = paginatedHistory[pos]
    if (entry.isMedia && entry.mediaPath != null) {
        // Media paste via commitContent (API 25+) — uses FileProvider URI
        val success = ClipboardHistoryService.pasteMedia(entry.mimeType, entry.mediaPath)
        if (!success) Toast("Cannot paste media here")
    } else {
        ClipboardHistoryService.paste(entry.content)  // Text paste
    }
}

Export/Import (v4 dual format)

JSON Export (text-only)

{
    "export_version": 4,
    "export_date": "2026-03-26 12:00:00",
    "active_entries": [
        { "content": "text here", "timestamp": 1711468800000, "expiry_timestamp": ...,
          "mime_type": "text/plain" }
    ],
    "pinned_entries": [
        { "content": "pinned text", "content_hash": "...", "created_timestamp": ...,
          "pinned_timestamp": ..., "position": 1.0, "tags": "[]", "mime_type": "text/plain" }
    ],
    "todo_entries": [
        { "content": "todo text", "content_hash": "...", "created_timestamp": ...,
          "added_timestamp": ..., "position": 1.0, "status": "active", "tags": "[]",
          "mime_type": "text/plain" }
    ]
}

ZIP Export (full backup with media)

clipboard_backup.zip
├── clipboard_data.json    ← Same JSON structure with media metadata
└── clipboard_media/       ← Raw media files by content hash
    ├── a1b2c3.jpg
    └── d4e5f6.mp4

Import Compatibility

Source FormatTarget SchemaBehavior
v2 JSONv4Entries get mime_type='text/plain', no media fields
v3 JSONv4Entries get mime_type='text/plain', no media fields
v4 JSONv4Text entries imported, media entries skipped (no files)
v4 ZIPv4Media extracted, thumbnails regenerated, full restore

Full Backup ZIP

Clipboard data is also included in the new one-click Full Backup ZIP (cleverkeys_full_backup_<date>.zip), alongside settings and dictionaries. The clipboard section uses the same clipboard_data.json + clipboard_media/ layout described above and the same ClipboardDatabase.exportToJSON / importFromJSON round-trip — duplicates are skipped on import. See the Backup & Restore user guide for the ZIP layout, manifest format, and forward-compat rules.

Configuration

SettingKeyDefaultRange
Enable Historyclipboard_history_enabledtruebool
History Limitclipboard_history_limit50count or size-based
History Durationclipboard_history_duration-1 (never)minutes, -1=never
Max Item Size (text)clipboard_max_item_size_kb25664-1024 KB
Text-Only Modeclipboard_text_onlyfalsebool — hides all media
Media Clipboardclipboard_media_enabledtruebool — enables media capture
Max Media Sizeclipboard_max_media_size_mb101-50 MB
Pinned Tabclipboard_pinned_enabledtruebool — show/hide tab
Todo Tabclipboard_todo_enabledtruebool — show/hide tab
Swipe to Deleteclipboard_swipe_delete_enabledtruebool — swipe a row to delete it (5 s Undo)
Exclude PWMsclipboard_exclude_password_managerstruebool
Sensitive Flagclipboard_respect_sensitive_flagtruebool (Android 13+)
Pane Heightclipboard_pane_height_percent4520-80%

Privacy and access control

Privacy controls — password-manager exclusion, the Android 13+ IS_SENSITIVE flag, and media gating — are documented in Clipboard Privacy. Summary:

  • Clipboard media excluded from Android Auto Backup (backup_rules.xml, data_extraction_rules.xml)
  • Android 13+ IS_SENSITIVE flag respected — the reliable password-manager protection
  • Best-effort password-manager exclusion via foreground app detection (normally inert: usage access is never requested)
  • No INTERNET permission — all processing is local
  • Media files stored in app-private filesDir (not accessible to other apps)

See Clipboard Privacy for the full exclusion list, foreground-app detection code path, and media-gating logic.