Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
}
}
2 changes: 2 additions & 0 deletions app/src/main/java/be/scri/helpers/DatabaseManagers.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
232 changes: 232 additions & 0 deletions app/src/main/java/be/scri/helpers/data/ClipboardDataManager.kt
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,
)

Comment on lines +12 to +19
Copy link
Member

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.

Copy link
Contributor Author

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.

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