-
Notifications
You must be signed in to change notification settings - Fork 104
feat: Add ClipboardDataManager for clipboard history storage #510
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
shrimpnaur
wants to merge
3
commits into
scribe-org:main
Choose a base branch
from
shrimpnaur:store-text-in-clipboard
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
95 changes: 95 additions & 0 deletions
95
app/src/androidTest/kotlin/be/scri/helpers/data/ClipboardDataManagerTest.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| // SPDX-License-Identifier: GPL-3.0-or-later | ||
| package be.scri.helpers.data | ||
|
|
||
| import android.content.Context | ||
| import androidx.test.ext.junit.runners.AndroidJUnit4 | ||
| import androidx.test.platform.app.InstrumentationRegistry | ||
| import be.scri.helpers.DatabaseFileManager | ||
| import io.mockk.mockk | ||
| import org.junit.Assert.assertEquals | ||
| import org.junit.Assert.assertTrue | ||
| import org.junit.Before | ||
| import org.junit.Test | ||
| import org.junit.runner.RunWith | ||
|
|
||
| @RunWith(AndroidJUnit4::class) | ||
| class ClipboardDataManagerTest { | ||
| private lateinit var clipboardManager: ClipboardDataManager | ||
| private lateinit var mockFileManager: DatabaseFileManager | ||
| private lateinit var context: Context | ||
|
|
||
| @Before | ||
| fun setUp() { | ||
| context = InstrumentationRegistry.getInstrumentation().targetContext | ||
| mockFileManager = mockk(relaxed = true) | ||
| clipboardManager = ClipboardDataManager(mockFileManager, context) | ||
|
|
||
| // Ensure a clean DB state before each test | ||
| clipboardManager.clearAll() | ||
| } | ||
|
|
||
| @Test | ||
| fun insertClip_WithValidText_InsertsSuccessfully() { | ||
| // Arrange | ||
| val testText = "Hello from test" | ||
|
|
||
| // Act | ||
| val id = clipboardManager.insertClip(testText) | ||
| val items = clipboardManager.getLatest(5) | ||
|
|
||
| // Assert | ||
| assertTrue("insert returned id should be > 0", id > 0) | ||
| assertTrue("latest should contain inserted text", items.any { it.text == testText }) | ||
| } | ||
|
|
||
| @Test | ||
| fun insertClip_WithDuplicateText_DoesNotInsertDuplicate() { | ||
| // Arrange | ||
| val testText = "duplicate text" | ||
|
|
||
| // Act | ||
| val id1 = clipboardManager.insertClip(testText) | ||
| val id2 = clipboardManager.insertClip(testText) // should be prevented as consecutive duplicate | ||
| val items = clipboardManager.getLatest(10) | ||
|
|
||
| // Assert | ||
| assertTrue("first insert should succeed", id1 > 0) | ||
| assertEquals("second insert (duplicate) should return -1", -1L, id2) | ||
| assertEquals("there should be exactly one item with that text", 1, items.count { it.text == testText }) | ||
| } | ||
|
|
||
| @Test | ||
| fun pinToggle_TogglesItemPinStatus() { | ||
| // Arrange | ||
| val id = clipboardManager.insertClip("test item") | ||
| val items = clipboardManager.getLatest(1) | ||
| val itemId = items.first().id | ||
|
|
||
| // Act | ||
| clipboardManager.pinToggle(itemId, true) | ||
| val pinnedItems = clipboardManager.getPinned() | ||
|
|
||
| // Assert | ||
| assertTrue("pinned list should contain the item", pinnedItems.any { it.id == itemId && it.isPinned }) | ||
| } | ||
|
|
||
| @Test | ||
| fun deleteOlderThan_RemovesExpiredItems() { | ||
| // Arrange | ||
| clipboardManager.clearAll() | ||
| clipboardManager.insertClip("old item") | ||
| // ensure a measurable timestamp gap | ||
| Thread.sleep(60) | ||
| val cutoffTime = System.currentTimeMillis() | ||
| Thread.sleep(60) | ||
| clipboardManager.insertClip("new item") | ||
|
|
||
| // Act | ||
| clipboardManager.deleteOlderThan(cutoffTime) | ||
| val items = clipboardManager.getLatest(10) | ||
|
|
||
| // Assert | ||
| assertEquals("only one recent item should remain", 1, items.size) | ||
| assertEquals("remaining item should be 'new item'", "new item", items.first().text) | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
232 changes: 232 additions & 0 deletions
232
app/src/main/java/be/scri/helpers/data/ClipboardDataManager.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,232 @@ | ||
| // SPDX-License-Identifier: GPL-3.0-or-later | ||
|
|
||
| package be.scri.helpers.data | ||
|
|
||
| import android.content.ContentValues | ||
| import android.content.Context | ||
| import android.database.sqlite.SQLiteDatabase | ||
| import be.scri.helpers.DatabaseFileManager | ||
|
|
||
| /** | ||
| * Data class representing a clipboard entry | ||
| */ | ||
| data class ClipboardItem( | ||
| val id: Long, | ||
| val text: String, | ||
| val timestampMs: Long, | ||
| val isPinned: Boolean, | ||
| ) | ||
|
|
||
| @Suppress("TooManyFunctions") | ||
| class ClipboardDataManager( | ||
| @Suppress("UnusedPrivateProperty") | ||
| private val fileManager: DatabaseFileManager, | ||
| private val context: Context, | ||
| ) { | ||
| companion object { | ||
| private const val DB_FILENAME = "clipboard_history.db" | ||
| private const val TABLE = "clipboard_items" | ||
|
|
||
| private const val COL_ID = "id" | ||
| private const val COL_TEXT = "text" | ||
| private const val COL_TIMESTAMP = "timestamp_ms" | ||
| private const val COL_PINNED = "is_pinned" | ||
|
|
||
| private const val MAX_CLIP_LENGTH = 4096 | ||
| } | ||
|
|
||
| /** Open or create writable DB. */ | ||
| private fun openDb(): SQLiteDatabase { | ||
| val dbFile = context.getDatabasePath(DB_FILENAME) | ||
| if (!dbFile.exists()) dbFile.parentFile?.mkdirs() | ||
| return SQLiteDatabase.openOrCreateDatabase(dbFile, null) | ||
| } | ||
|
|
||
| /** Check Table exists or not. */ | ||
| private fun ensureTableExists(db: SQLiteDatabase) { | ||
| db.execSQL( | ||
| """ | ||
| CREATE TABLE IF NOT EXISTS $TABLE ( | ||
| $COL_ID INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| $COL_TEXT TEXT NOT NULL, | ||
| $COL_TIMESTAMP INTEGER NOT NULL, | ||
| $COL_PINNED INTEGER NOT NULL DEFAULT 0 | ||
| ); | ||
| """.trimIndent(), | ||
| ) | ||
| } | ||
|
|
||
| /** Get last copied text to avoid duplicates. */ | ||
| private fun getMostRecentText(): String? { | ||
| val db = openDb() | ||
| try { | ||
| ensureTableExists(db) | ||
| val cursor = | ||
| db.rawQuery( | ||
| "SELECT $COL_TEXT FROM $TABLE ORDER BY $COL_TIMESTAMP DESC LIMIT 1", | ||
| null, | ||
| ) | ||
| return cursor.use { if (it.moveToFirst()) it.getString(0) else null } | ||
| } finally { | ||
| db.close() | ||
| } | ||
| } | ||
|
|
||
| /** Insert new clipboard item and also skip the duplicates. */ | ||
| fun insertClip( | ||
| rawText: String, | ||
| pinned: Boolean = false, | ||
| ): Long { | ||
| val text = rawText.take(MAX_CLIP_LENGTH) | ||
|
|
||
| // prevent consecutive duplicates | ||
| if (getMostRecentText() == text) return -1L | ||
|
|
||
| val db = openDb() | ||
| try { | ||
| ensureTableExists(db) | ||
| val cv = | ||
| ContentValues().apply { | ||
| put(COL_TEXT, text) | ||
| put(COL_TIMESTAMP, System.currentTimeMillis()) | ||
| put(COL_PINNED, if (pinned) 1 else 0) | ||
| } | ||
| return db.insert(TABLE, null, cv) | ||
| } finally { | ||
| db.close() | ||
| } | ||
| } | ||
|
|
||
| /** Get the latest N clipboard entries */ | ||
| fun getLatest(limit: Int = 50): List<ClipboardItem> { | ||
| val db = openDb() | ||
| try { | ||
| ensureTableExists(db) | ||
| val cursor = | ||
| db.rawQuery( | ||
| "SELECT $COL_ID, $COL_TEXT, $COL_TIMESTAMP, $COL_PINNED FROM $TABLE " + | ||
| "ORDER BY $COL_TIMESTAMP DESC LIMIT ?", | ||
| arrayOf(limit.toString()), | ||
| ) | ||
| return cursor.use { | ||
| val items = mutableListOf<ClipboardItem>() | ||
| while (it.moveToNext()) { | ||
| items.add( | ||
| ClipboardItem( | ||
| id = it.getLong(0), | ||
| text = it.getString(1), | ||
| timestampMs = it.getLong(2), | ||
| isPinned = it.getInt(3) == 1, | ||
| ), | ||
| ) | ||
| } | ||
| items | ||
| } | ||
| } finally { | ||
| db.close() | ||
| } | ||
| } | ||
|
|
||
| /** Get only pinned items */ | ||
| fun getPinned(): List<ClipboardItem> { | ||
| val db = openDb() | ||
| try { | ||
| ensureTableExists(db) | ||
| val cursor = | ||
| db.rawQuery( | ||
| "SELECT $COL_ID, $COL_TEXT, $COL_TIMESTAMP, $COL_PINNED FROM $TABLE " + | ||
| "WHERE $COL_PINNED = 1 ORDER BY $COL_TIMESTAMP DESC", | ||
| null, | ||
| ) | ||
| return cursor.use { | ||
| val out = mutableListOf<ClipboardItem>() | ||
| while (it.moveToNext()) { | ||
| out.add( | ||
| ClipboardItem( | ||
| id = it.getLong(0), | ||
| text = it.getString(1), | ||
| timestampMs = it.getLong(2), | ||
| isPinned = it.getInt(3) == 1, | ||
| ), | ||
| ) | ||
| } | ||
| out | ||
| } | ||
| } finally { | ||
| db.close() | ||
| } | ||
| } | ||
|
|
||
| /** Toggle pin state to pin or unpin */ | ||
| fun pinToggle( | ||
| id: Long, | ||
| pinned: Boolean, | ||
| ) { | ||
| val db = openDb() | ||
| try { | ||
| ensureTableExists(db) | ||
| val cv = ContentValues().apply { put(COL_PINNED, if (pinned) 1 else 0) } | ||
| db.update(TABLE, cv, "$COL_ID = ?", arrayOf(id.toString())) | ||
| } finally { | ||
| db.close() | ||
| } | ||
| } | ||
|
|
||
| /** Deleting a specific clip. */ | ||
| fun deleteClip(id: Long) { | ||
| val db = openDb() | ||
| try { | ||
| ensureTableExists(db) | ||
| db.delete(TABLE, "$COL_ID = ?", arrayOf(id.toString())) | ||
| } finally { | ||
| db.close() | ||
| } | ||
| } | ||
|
|
||
| /** Clear all the things. */ | ||
| fun clearAll() { | ||
| val db = openDb() | ||
| try { | ||
| ensureTableExists(db) | ||
| db.execSQL("DELETE FROM $TABLE") | ||
| } finally { | ||
| db.close() | ||
| } | ||
| } | ||
|
|
||
| /** Keep the latest N non-pinned items */ | ||
| fun trimToKeep(keep: Int = 50) { | ||
| val db = openDb() | ||
| try { | ||
| ensureTableExists(db) | ||
| db.execSQL( | ||
| """ | ||
| DELETE FROM $TABLE | ||
| WHERE $COL_ID IN ( | ||
| SELECT $COL_ID FROM $TABLE | ||
| WHERE $COL_PINNED = 0 | ||
| ORDER BY $COL_TIMESTAMP DESC | ||
| LIMIT -1 OFFSET $keep | ||
| ); | ||
| """.trimIndent(), | ||
| ) | ||
| } finally { | ||
| db.close() | ||
| } | ||
| } | ||
|
|
||
| /** Delete unpinned items older than the cutoff time */ | ||
| fun deleteOlderThan(cutoffMillis: Long) { | ||
| val db = openDb() | ||
| try { | ||
| ensureTableExists(db) | ||
| db.delete( | ||
| TABLE, | ||
| "$COL_PINNED = 0 AND $COL_TIMESTAMP < ?", | ||
| arrayOf(cutoffMillis.toString()), | ||
| ) | ||
| } finally { | ||
| db.close() | ||
| } | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This could be made into a separate file rather than having it here itself. Import and use it here instead.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Will make the changes according to the suggestions given.