Skip to content
CleverKeys Wiki
implemented v1.3.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

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 (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)
}

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
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)
  • Password manager exclusion via foreground app detection
  • Android 13+ IS_SENSITIVE flag respected
  • 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.