2023-10-17 13:44:01 +02:00
|
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
|
2024-01-31 18:32:43 +01:00
|
|
|
package helium314.keyboard.latin
|
2022-01-13 13:45:48 +01:00
|
|
|
|
|
|
|
import android.content.ClipboardManager
|
|
|
|
import android.content.Context
|
2024-07-06 00:14:54 +03:00
|
|
|
import android.text.InputType
|
2022-01-13 13:45:48 +01:00
|
|
|
import android.text.TextUtils
|
2024-07-06 00:14:54 +03:00
|
|
|
import android.view.LayoutInflater
|
|
|
|
import android.view.View
|
|
|
|
import android.view.ViewGroup
|
|
|
|
import android.view.inputmethod.EditorInfo
|
|
|
|
import androidx.core.view.isGone
|
2023-11-04 13:38:24 +01:00
|
|
|
import kotlinx.serialization.json.Json
|
2024-01-31 18:32:43 +01:00
|
|
|
import helium314.keyboard.compat.ClipboardManagerCompat
|
2024-07-06 00:14:54 +03:00
|
|
|
import helium314.keyboard.keyboard.internal.keyboard_parser.floris.KeyCode
|
|
|
|
import helium314.keyboard.latin.common.ColorType
|
|
|
|
import helium314.keyboard.latin.common.isValidNumber
|
|
|
|
import helium314.keyboard.latin.databinding.ClipboardSuggestionBinding
|
2025-02-09 19:01:57 +01:00
|
|
|
import helium314.keyboard.latin.settings.Defaults
|
2024-01-31 18:32:43 +01:00
|
|
|
import helium314.keyboard.latin.settings.Settings
|
2024-07-06 00:14:54 +03:00
|
|
|
import helium314.keyboard.latin.utils.InputTypeUtils
|
|
|
|
import helium314.keyboard.latin.utils.ToolbarKey
|
2025-02-09 13:52:59 +01:00
|
|
|
import helium314.keyboard.latin.utils.prefs
|
2023-11-04 13:38:24 +01:00
|
|
|
import kotlin.collections.ArrayList
|
2022-01-13 13:45:48 +01:00
|
|
|
|
|
|
|
class ClipboardHistoryManager(
|
|
|
|
private val latinIME: LatinIME
|
|
|
|
) : ClipboardManager.OnPrimaryClipChangedListener {
|
|
|
|
|
|
|
|
private lateinit var clipboardManager: ClipboardManager
|
|
|
|
private var onHistoryChangeListener: OnHistoryChangeListener? = null
|
2024-07-06 00:14:54 +03:00
|
|
|
private var clipboardSuggestionView: View? = null
|
2022-01-13 13:45:48 +01:00
|
|
|
|
|
|
|
fun onCreate() {
|
|
|
|
clipboardManager = latinIME.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
|
|
|
clipboardManager.addPrimaryClipChangedListener(this)
|
2024-04-15 23:17:15 +03:00
|
|
|
if (historyEntries.isEmpty())
|
|
|
|
loadPinnedClips()
|
2025-02-09 19:01:57 +01:00
|
|
|
if (latinIME.prefs().getBoolean(Settings.PREF_ENABLE_CLIPBOARD_HISTORY, Defaults.PREF_ENABLE_CLIPBOARD_HISTORY))
|
2024-04-15 23:17:15 +03:00
|
|
|
fetchPrimaryClip()
|
2022-01-13 13:45:48 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
fun onDestroy() {
|
|
|
|
clipboardManager.removePrimaryClipChangedListener(this)
|
|
|
|
}
|
|
|
|
|
2022-02-18 21:08:29 +01:00
|
|
|
override fun onPrimaryClipChanged() {
|
|
|
|
// Make sure we read clipboard content only if history settings is set
|
|
|
|
if (latinIME.mSettings.current?.mClipboardHistoryEnabled == true) {
|
|
|
|
fetchPrimaryClip()
|
2024-07-06 00:14:54 +03:00
|
|
|
dontShowCurrentSuggestion = false
|
2022-02-18 21:08:29 +01:00
|
|
|
}
|
|
|
|
}
|
2022-01-13 13:45:48 +01:00
|
|
|
|
|
|
|
private fun fetchPrimaryClip() {
|
2022-01-19 16:44:45 +01:00
|
|
|
val clipData = clipboardManager.primaryClip ?: return
|
2024-04-16 20:51:16 +03:00
|
|
|
if (clipData.itemCount == 0 || clipData.description?.hasMimeType("text/*") == false) return
|
2022-01-19 16:44:45 +01:00
|
|
|
clipData.getItemAt(0)?.let { clipItem ->
|
2024-04-12 17:35:57 +02:00
|
|
|
val timeStamp = ClipboardManagerCompat.getClipTimestamp(clipData) ?: System.currentTimeMillis()
|
2022-01-19 16:44:45 +01:00
|
|
|
val content = clipItem.coerceToText(latinIME)
|
|
|
|
if (TextUtils.isEmpty(content)) return
|
2024-04-12 17:35:57 +02:00
|
|
|
|
2025-03-22 19:34:27 +01:00
|
|
|
val duplicateEntryIndex = historyEntries.indexOfFirst { it.content == content.toString() }
|
2024-04-12 17:35:57 +02:00
|
|
|
if (duplicateEntryIndex != -1) {
|
|
|
|
val existingEntry = historyEntries[duplicateEntryIndex]
|
|
|
|
if (existingEntry.timeStamp == timeStamp) return // nothing to change (may occur frequently starting with API 30)
|
|
|
|
// older entry with the same text already exists, update the timestamp and re-sort the list
|
|
|
|
existingEntry.timeStamp = timeStamp
|
|
|
|
historyEntries.removeAt(duplicateEntryIndex)
|
|
|
|
historyEntries.add(0, existingEntry)
|
|
|
|
sortHistoryEntries()
|
|
|
|
val newIndex = historyEntries.indexOf(existingEntry)
|
|
|
|
onHistoryChangeListener?.onClipboardHistoryEntryMoved(duplicateEntryIndex, newIndex)
|
|
|
|
return
|
|
|
|
}
|
2025-03-22 19:34:27 +01:00
|
|
|
if (historyEntries.any { it.content == content.toString() }) return
|
2022-01-19 16:44:45 +01:00
|
|
|
|
2025-03-22 19:34:27 +01:00
|
|
|
val entry = ClipboardHistoryEntry(timeStamp, content.toString())
|
2022-01-19 16:44:45 +01:00
|
|
|
historyEntries.add(entry)
|
|
|
|
sortHistoryEntries()
|
|
|
|
val at = historyEntries.indexOf(entry)
|
|
|
|
onHistoryChangeListener?.onClipboardHistoryEntryAdded(at)
|
2022-01-13 13:45:48 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-02-18 21:08:29 +01:00
|
|
|
fun toggleClipPinned(ts: Long) {
|
|
|
|
val from = historyEntries.indexOfFirst { it.timeStamp == ts }
|
2022-01-13 13:45:48 +01:00
|
|
|
val historyEntry = historyEntries[from].apply {
|
2022-02-18 21:08:29 +01:00
|
|
|
timeStamp = System.currentTimeMillis()
|
2022-01-13 13:45:48 +01:00
|
|
|
isPinned = !isPinned
|
|
|
|
}
|
|
|
|
sortHistoryEntries()
|
|
|
|
val to = historyEntries.indexOf(historyEntry)
|
|
|
|
onHistoryChangeListener?.onClipboardHistoryEntryMoved(from, to)
|
2023-11-04 13:38:24 +01:00
|
|
|
savePinnedClips()
|
2022-01-13 13:45:48 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
fun clearHistory() {
|
|
|
|
ClipboardManagerCompat.clearPrimaryClip(clipboardManager)
|
|
|
|
val pos = historyEntries.indexOfFirst { !it.isPinned }
|
|
|
|
val count = historyEntries.count { !it.isPinned }
|
|
|
|
historyEntries.removeAll { !it.isPinned }
|
|
|
|
if (onHistoryChangeListener != null) {
|
|
|
|
onHistoryChangeListener?.onClipboardHistoryEntriesRemoved(pos, count)
|
|
|
|
}
|
2024-07-06 00:14:54 +03:00
|
|
|
removeClipboardSuggestion()
|
2022-01-13 13:45:48 +01:00
|
|
|
}
|
2023-12-20 23:00:41 +01:00
|
|
|
|
|
|
|
fun canRemove(index: Int) = historyEntries.getOrNull(index)?.isPinned != true
|
|
|
|
|
|
|
|
fun removeEntry(index: Int) {
|
|
|
|
if (canRemove(index))
|
|
|
|
historyEntries.removeAt(index)
|
|
|
|
}
|
2022-01-13 13:45:48 +01:00
|
|
|
|
|
|
|
private fun sortHistoryEntries() {
|
|
|
|
historyEntries.sort()
|
|
|
|
}
|
|
|
|
|
2022-02-18 21:08:29 +01:00
|
|
|
private fun checkClipRetentionElapsed() {
|
|
|
|
val mins = latinIME.mSettings.current.mClipboardHistoryRetentionTime
|
2025-03-22 19:05:24 +01:00
|
|
|
if (mins > 120) return // No retention limit, changed from <= 0 because we want it to be larger than all other choices
|
2022-02-18 21:08:29 +01:00
|
|
|
val maxClipRetentionTime = mins * 60 * 1000L
|
|
|
|
val now = System.currentTimeMillis()
|
|
|
|
historyEntries.removeAll { !it.isPinned && (now - it.timeStamp) > maxClipRetentionTime }
|
|
|
|
}
|
|
|
|
|
|
|
|
// We do not want to update history while user is visualizing it, so we check retention only
|
|
|
|
// when history is about to be shown
|
|
|
|
fun prepareClipboardHistory() = checkClipRetentionElapsed()
|
|
|
|
|
2022-01-13 13:45:48 +01:00
|
|
|
fun getHistorySize() = historyEntries.size
|
|
|
|
|
|
|
|
fun getHistoryEntry(position: Int) = historyEntries[position]
|
|
|
|
|
2022-02-18 21:08:29 +01:00
|
|
|
fun getHistoryEntryContent(timeStamp: Long) = historyEntries.first { it.timeStamp == timeStamp }
|
2022-01-13 13:45:48 +01:00
|
|
|
|
|
|
|
fun setHistoryChangeListener(l: OnHistoryChangeListener?) {
|
|
|
|
onHistoryChangeListener = l
|
|
|
|
}
|
|
|
|
|
2022-02-18 21:08:29 +01:00
|
|
|
fun retrieveClipboardContent(): CharSequence {
|
|
|
|
val clipData = clipboardManager.primaryClip ?: return ""
|
|
|
|
if (clipData.itemCount == 0) return ""
|
|
|
|
return clipData.getItemAt(0)?.coerceToText(latinIME) ?: ""
|
|
|
|
}
|
|
|
|
|
2024-07-06 00:14:54 +03:00
|
|
|
private fun isClipSensitive(inputType: Int): Boolean {
|
|
|
|
ClipboardManagerCompat.getClipSensitivity(clipboardManager.primaryClip?.description)?.let { return it }
|
|
|
|
return InputTypeUtils.isPasswordInputType(inputType)
|
|
|
|
}
|
|
|
|
|
2024-01-28 18:05:38 +01:00
|
|
|
// pinned clips are stored in default shared preferences, not in device protected preferences!
|
2023-11-04 13:38:24 +01:00
|
|
|
private fun loadPinnedClips() {
|
2024-02-13 08:23:07 +01:00
|
|
|
val pinnedClipString = Settings.readPinnedClipString(latinIME)
|
2023-11-04 13:38:24 +01:00
|
|
|
if (pinnedClipString.isEmpty()) return
|
|
|
|
val pinnedClips: List<ClipboardHistoryEntry> = Json.decodeFromString(pinnedClipString)
|
2024-04-20 23:24:14 +02:00
|
|
|
historyEntries.addAll(pinnedClips)
|
|
|
|
sortHistoryEntries()
|
|
|
|
if (onHistoryChangeListener != null) {
|
|
|
|
pinnedClips.forEach {
|
|
|
|
onHistoryChangeListener?.onClipboardHistoryEntryAdded(historyEntries.indexOf(it))
|
|
|
|
}
|
|
|
|
}
|
2022-01-13 13:45:48 +01:00
|
|
|
}
|
|
|
|
|
2023-11-04 13:38:24 +01:00
|
|
|
private fun savePinnedClips() {
|
|
|
|
val pinnedClips = Json.encodeToString(historyEntries.filter { it.isPinned })
|
2024-02-13 08:23:07 +01:00
|
|
|
Settings.writePinnedClipString(latinIME, pinnedClips)
|
2022-01-13 13:45:48 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
interface OnHistoryChangeListener {
|
|
|
|
fun onClipboardHistoryEntryAdded(at: Int)
|
|
|
|
fun onClipboardHistoryEntriesRemoved(pos: Int, count: Int)
|
|
|
|
fun onClipboardHistoryEntryMoved(from: Int, to: Int)
|
|
|
|
}
|
2024-03-10 19:08:18 +01:00
|
|
|
|
2024-07-06 00:14:54 +03:00
|
|
|
fun getClipboardSuggestionView(editorInfo: EditorInfo?, parent: ViewGroup?): View? {
|
|
|
|
// maybe no need to create a new view
|
|
|
|
// but a cache has to consider a few possible changes, so better don't implement without need
|
|
|
|
clipboardSuggestionView = null
|
|
|
|
|
|
|
|
// get the content, or return null
|
|
|
|
if (!latinIME.mSettings.current.mSuggestClipboardContent) return null
|
|
|
|
if (dontShowCurrentSuggestion) return null
|
|
|
|
if (parent == null) return null
|
|
|
|
val clipData = clipboardManager.primaryClip ?: return null
|
|
|
|
if (clipData.itemCount == 0 || clipData.description?.hasMimeType("text/*") == false) return null
|
|
|
|
val clipItem = clipData.getItemAt(0) ?: return null
|
|
|
|
val timeStamp = ClipboardManagerCompat.getClipTimestamp(clipData) ?: System.currentTimeMillis()
|
|
|
|
if (System.currentTimeMillis() - timeStamp > RECENT_TIME_MILLIS) return null
|
|
|
|
val content = clipItem.coerceToText(latinIME)
|
|
|
|
if (TextUtils.isEmpty(content)) return null
|
|
|
|
val inputType = editorInfo?.inputType ?: InputType.TYPE_NULL
|
|
|
|
if (InputTypeUtils.isNumberInputType(inputType) && !content.isValidNumber()) return null
|
|
|
|
|
|
|
|
// create the view
|
|
|
|
val binding = ClipboardSuggestionBinding.inflate(LayoutInflater.from(latinIME), parent, false)
|
|
|
|
val textView = binding.clipboardSuggestionText
|
2025-01-18 12:45:26 +01:00
|
|
|
latinIME.mSettings.getCustomTypeface()?.let { textView.typeface = it }
|
2025-01-03 18:09:51 +01:00
|
|
|
textView.text = (if (isClipSensitive(inputType)) "*".repeat(content.length) else content)
|
|
|
|
.take(200) // truncate displayed text for performance reasons
|
2024-07-06 01:41:19 -06:00
|
|
|
val clipIcon = latinIME.mKeyboardSwitcher.keyboard.mIconsSet.getIconDrawable(ToolbarKey.PASTE.name.lowercase())
|
2024-07-06 00:14:54 +03:00
|
|
|
textView.setCompoundDrawablesRelativeWithIntrinsicBounds(clipIcon, null, null, null)
|
|
|
|
textView.setOnClickListener {
|
|
|
|
dontShowCurrentSuggestion = true
|
|
|
|
latinIME.onTextInput(content.toString())
|
2024-07-06 01:41:19 -06:00
|
|
|
AudioAndHapticFeedbackManager.getInstance().performHapticAndAudioFeedback(KeyCode.NOT_SPECIFIED, it)
|
2024-07-06 00:14:54 +03:00
|
|
|
binding.root.isGone = true
|
|
|
|
}
|
|
|
|
val closeButton = binding.clipboardSuggestionClose
|
|
|
|
closeButton.setImageDrawable(latinIME.mKeyboardSwitcher.keyboard.mIconsSet.getIconDrawable(ToolbarKey.CLOSE_HISTORY.name.lowercase()))
|
|
|
|
closeButton.setOnClickListener { removeClipboardSuggestion() }
|
|
|
|
|
|
|
|
val colors = latinIME.mSettings.current.mColors
|
|
|
|
textView.setTextColor(colors.get(ColorType.KEY_TEXT))
|
|
|
|
clipIcon?.let { colors.setColor(it, ColorType.KEY_ICON) }
|
|
|
|
colors.setColor(closeButton, ColorType.REMOVE_SUGGESTION_ICON)
|
|
|
|
colors.setBackground(binding.root, ColorType.CLIPBOARD_SUGGESTION_BACKGROUND)
|
|
|
|
|
|
|
|
clipboardSuggestionView = binding.root
|
|
|
|
return clipboardSuggestionView
|
|
|
|
}
|
|
|
|
|
|
|
|
private fun removeClipboardSuggestion() {
|
|
|
|
dontShowCurrentSuggestion = true
|
|
|
|
val csv = clipboardSuggestionView ?: return
|
|
|
|
if (csv.parent != null && !csv.isGone) {
|
|
|
|
// clipboard view is shown ->
|
|
|
|
latinIME.setNeutralSuggestionStrip()
|
|
|
|
latinIME.mHandler.postResumeSuggestions(false)
|
|
|
|
}
|
|
|
|
csv.isGone = true
|
|
|
|
}
|
|
|
|
|
2024-03-10 19:08:18 +01:00
|
|
|
companion object {
|
|
|
|
// store pinned clips in companion object so they survive a keyboard switch (which destroys the current instance)
|
|
|
|
private val historyEntries: MutableList<ClipboardHistoryEntry> = ArrayList()
|
2024-07-06 00:14:54 +03:00
|
|
|
private var dontShowCurrentSuggestion: Boolean = false
|
|
|
|
const val RECENT_TIME_MILLIS = 3 * 60 * 1000L // 3 minutes (for clipboard suggestions)
|
2024-03-10 19:08:18 +01:00
|
|
|
}
|
2023-11-04 13:38:24 +01:00
|
|
|
}
|