diff --git a/app/src/androidTest/kotlin/be/scri/helpers/data/ClipboardDataManagerTest.kt b/app/src/androidTest/kotlin/be/scri/helpers/data/ClipboardDataManagerTest.kt new file mode 100644 index 00000000..542e6978 --- /dev/null +++ b/app/src/androidTest/kotlin/be/scri/helpers/data/ClipboardDataManagerTest.kt @@ -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) + } +} diff --git a/app/src/main/java/be/scri/helpers/DatabaseManagers.kt b/app/src/main/java/be/scri/helpers/DatabaseManagers.kt index f2cc61fa..8aa00580 100644 --- a/app/src/main/java/be/scri/helpers/DatabaseManagers.kt +++ b/app/src/main/java/be/scri/helpers/DatabaseManagers.kt @@ -5,6 +5,7 @@ import DataContract import android.content.Context import be.scri.helpers.data.AutoSuggestionDataManager import be.scri.helpers.data.AutocompletionDataManager +import be.scri.helpers.data.ClipboardDataManager import be.scri.helpers.data.ConjugateDataManager import be.scri.helpers.data.ContractDataLoader import be.scri.helpers.data.EmojiDataManager @@ -28,6 +29,7 @@ class DatabaseManagers( // Specialized data managers, ready for use. val emojiManager = EmojiDataManager(fileManager) + val clipboardManager = ClipboardDataManager(fileManager, context) val genderManager = GenderDataManager(fileManager) val pluralManager = PluralFormsManager(fileManager) val prepositionManager = PrepositionDataManager(fileManager) diff --git a/app/src/main/java/be/scri/helpers/data/ClipboardDataManager.kt b/app/src/main/java/be/scri/helpers/data/ClipboardDataManager.kt new file mode 100644 index 00000000..c3d83b30 --- /dev/null +++ b/app/src/main/java/be/scri/helpers/data/ClipboardDataManager.kt @@ -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 { + 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() + 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 { + 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() + 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() + } + } +}