HeliBoard/app/src/main/java/helium314/keyboard/latin/ClipboardHistoryManager.kt

244 lines
11 KiB
Kotlin
Raw Normal View History

// SPDX-License-Identifier: GPL-3.0-only
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
import kotlinx.serialization.json.Json
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
import helium314.keyboard.latin.settings.Defaults
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
import helium314.keyboard.latin.utils.prefs
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)
if (historyEntries.isEmpty())
loadPinnedClips()
if (latinIME.prefs().getBoolean(Settings.PREF_ENABLE_CLIPBOARD_HISTORY, Defaults.PREF_ENABLE_CLIPBOARD_HISTORY))
fetchPrimaryClip()
2022-01-13 13:45:48 +01:00
}
fun onDestroy() {
clipboardManager.removePrimaryClipChangedListener(this)
}
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-01-13 13:45:48 +01:00
private fun fetchPrimaryClip() {
val clipData = clipboardManager.primaryClip ?: return
if (clipData.itemCount == 0 || clipData.description?.hasMimeType("text/*") == false) return
clipData.getItemAt(0)?.let { clipItem ->
val timeStamp = ClipboardManagerCompat.getClipTimestamp(clipData) ?: System.currentTimeMillis()
val content = clipItem.coerceToText(latinIME)
if (TextUtils.isEmpty(content)) return
val duplicateEntryIndex = historyEntries.indexOfFirst { it.content == content.toString() }
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
}
if (historyEntries.any { it.content == content.toString() }) return
val entry = ClipboardHistoryEntry(timeStamp, content.toString())
historyEntries.add(entry)
sortHistoryEntries()
val at = historyEntries.indexOf(entry)
onHistoryChangeListener?.onClipboardHistoryEntryAdded(at)
2022-01-13 13:45:48 +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 {
timeStamp = System.currentTimeMillis()
2022-01-13 13:45:48 +01:00
isPinned = !isPinned
}
sortHistoryEntries()
val to = historyEntries.indexOf(historyEntry)
onHistoryChangeListener?.onClipboardHistoryEntryMoved(from, to)
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
}
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()
}
private fun checkClipRetentionElapsed() {
val mins = latinIME.mSettings.current.mClipboardHistoryRetentionTime
if (mins > 120) return // No retention limit, changed from <= 0 because we want it to be larger than all other choices
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]
fun getHistoryEntryContent(timeStamp: Long) = historyEntries.first { it.timeStamp == timeStamp }
2022-01-13 13:45:48 +01:00
fun setHistoryChangeListener(l: OnHistoryChangeListener?) {
onHistoryChangeListener = l
}
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)
}
// pinned clips are stored in default shared preferences, not in device protected preferences!
private fun loadPinnedClips() {
val pinnedClipString = Settings.readPinnedClipString(latinIME)
if (pinnedClipString.isEmpty()) return
val pinnedClips: List<ClipboardHistoryEntry> = Json.decodeFromString(pinnedClipString)
historyEntries.addAll(pinnedClips)
sortHistoryEntries()
if (onHistoryChangeListener != null) {
pinnedClips.forEach {
onHistoryChangeListener?.onClipboardHistoryEntryAdded(historyEntries.indexOf(it))
}
}
2022-01-13 13:45:48 +01:00
}
private fun savePinnedClips() {
val pinnedClips = Json.encodeToString(historyEntries.filter { it.isPinned })
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-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
latinIME.mSettings.getCustomTypeface()?.let { textView.typeface = it }
textView.text = (if (isClipSensitive(inputType)) "*".repeat(content.length) else content)
.take(200) // truncate displayed text for performance reasons
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())
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
}
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)
}
}