Add text clipboard history

This commit is contained in:
Aleksandras Kostarevas 2024-06-15 13:40:47 +03:00
parent b935db017c
commit 657203e733
8 changed files with 466 additions and 49 deletions

View File

@ -5,6 +5,7 @@
<string name="theme_switcher_action_title">Theme Switcher</string>
<string name="emoji_action_title">Emojis</string>
<string name="clipboard_action_title">Paste from Clipboard</string>
<string name="clipboard_manager_action_title">Clipboard Manager</string>
<string name="undo_action_title">Undo</string>
<string name="redo_action_title">Redo</string>
<string name="text_edit_action_title">Text Editor</string>

View File

@ -317,6 +317,8 @@ class LatinIME : InputMethodService(), LifecycleOwner, ViewModelStoreOwner, Save
CrashLoggingApplication.logPreferences(it)
}
}
uixManager.onCreate()
}
override fun onDestroy() {

View File

@ -1,6 +1,7 @@
package org.futo.inputmethod.latin.uix
import android.content.Context
import android.net.Uri
import android.view.View
import android.view.inputmethod.InputConnection
import androidx.annotation.DrawableRes
@ -25,6 +26,7 @@ interface KeyboardManagerForAction {
fun createInputTransaction(applySpaceIfNeeded: Boolean): ActionInputTransaction
fun typeText(v: String)
fun typeUri(uri: Uri, mimeTypes: List<String>): Boolean
fun backspace(amount: Int)
fun closeActionWindow()
@ -68,6 +70,11 @@ interface PersistentActionState {
suspend fun cleanUp()
}
enum class PersistentStateInitialization {
OnActionTrigger,
OnKeyboardLoad
}
data class Action(
@DrawableRes val icon: Int,
@StringRes val name: Int,
@ -77,4 +84,5 @@ data class Action(
val windowImpl: ((KeyboardManagerForAction, PersistentActionState?) -> ActionWindow)?,
val simplePressImpl: ((KeyboardManagerForAction, PersistentActionState?) -> Unit)?,
val persistentState: ((KeyboardManagerForAction) -> PersistentActionState)? = null,
val persistentStateInitialization: PersistentStateInitialization = PersistentStateInitialization.OnActionTrigger
)

View File

@ -1,15 +1,19 @@
package org.futo.inputmethod.latin.uix
import android.app.Activity
import android.content.ClipDescription
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import android.util.Log
import android.view.View
import android.view.WindowManager
import android.view.inputmethod.InlineSuggestionsResponse
import android.view.inputmethod.InputConnection
import android.view.inputmethod.InputContentInfo
import androidx.annotation.RequiresApi
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
@ -60,6 +64,7 @@ import org.futo.inputmethod.latin.common.Constants
import org.futo.inputmethod.latin.inputlogic.InputLogic
import org.futo.inputmethod.latin.suggestions.SuggestionStripView
import org.futo.inputmethod.latin.uix.actions.ActionRegistry
import org.futo.inputmethod.latin.uix.actions.AllActions
import org.futo.inputmethod.latin.uix.actions.EmojiAction
import org.futo.inputmethod.latin.uix.settings.SettingsActivity
import org.futo.inputmethod.latin.uix.theme.ThemeOption
@ -142,6 +147,26 @@ class UixActionKeyboardManager(val uixManager: UixManager, val latinIME: LatinIM
latinIME.latinIMELegacy.onTextInput(v)
}
override fun typeUri(uri: Uri, mimeTypes: List<String>): Boolean {
if(mimeTypes.isEmpty()) {
Log.w("UixManager", "mimeTypes is empty")
return false
}
val description = ClipDescription("Pasted image", mimeTypes.toTypedArray())
val info = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
InputContentInfo(uri, description, null)
} else {
return false
}
return latinIME.currentInputConnection?.commitContent(info, InputConnection.INPUT_CONTENT_GRANT_READ_URI_PERMISSION, null) ?: run {
Log.w("UixManager", "Current input connection is null")
return false
}
}
override fun backspace(amount: Int) {
latinIME.latinIMELegacy.onCodeInput(
Constants.CODE_DELETE,
@ -645,4 +670,12 @@ class UixManager(private val latinIME: LatinIME) {
v!!.vibrate(50)
}
}
fun onCreate() {
AllActions.forEach { action ->
if(action.persistentStateInitialization == PersistentStateInitialization.OnKeyboardLoad) {
persistentStates[action] = action.persistentState?.let { it(keyboardManagerForAction) }
}
}
}
}

View File

@ -0,0 +1,384 @@
package org.futo.inputmethod.latin.uix.actions
import android.content.ClipDescription
import android.content.ClipboardManager
import android.content.Context
import android.net.Uri
import android.widget.Toast
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.lifecycle.LifecycleCoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encodeToString
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.Json
import org.futo.inputmethod.latin.R
import org.futo.inputmethod.latin.uix.Action
import org.futo.inputmethod.latin.uix.ActionWindow
import org.futo.inputmethod.latin.uix.PersistentActionState
import org.futo.inputmethod.latin.uix.PersistentStateInitialization
import org.futo.inputmethod.latin.uix.SettingsKey
import org.futo.inputmethod.latin.uix.getSettingBlocking
import org.futo.inputmethod.latin.uix.settings.ScrollableList
import org.futo.inputmethod.latin.uix.settings.pages.ParagraphText
import org.futo.inputmethod.latin.uix.settings.pages.PaymentSurface
import org.futo.inputmethod.latin.uix.settings.useDataStore
import org.futo.inputmethod.latin.uix.theme.Typography
import java.io.File
val ClipboardHistoryEnabled = SettingsKey(
booleanPreferencesKey("enableClipboardHistory"),
false
)
object UriSerializer : KSerializer<Uri> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Uri", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: Uri) {
encoder.encodeString(value.toString())
}
override fun deserialize(decoder: Decoder): Uri {
return Uri.parse(decoder.decodeString())
}
}
@Serializable
data class ClipboardEntry(
val timestamp: Long,
val pinned: Boolean,
val text: String?,
@Serializable(with = UriSerializer::class)
val uri: Uri?,
val mimeTypes: List<String>
)
@Composable
fun ClipboardEntryView(clipboardEntry: ClipboardEntry, onPaste: (ClipboardEntry) -> Unit, onRemove: (ClipboardEntry) -> Unit, onPin: (ClipboardEntry) -> Unit) {
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
border = BorderStroke(2.dp, MaterialTheme.colorScheme.outlineVariant),
modifier = Modifier.padding(2.dp),
shape = RoundedCornerShape(8.dp),
onClick = { onPaste(clipboardEntry) }
) {
Column {
Row(modifier = Modifier.padding(0.dp)) {
IconButton(onClick = {
onPin(clipboardEntry)
}, modifier = Modifier.size(32.dp)) {
Icon(
painterResource(id = R.drawable.unlock), contentDescription = "Pin",
tint = if (clipboardEntry.pinned) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.25f)
}, modifier = Modifier.size(16.dp)
)
}
Spacer(modifier = Modifier.weight(1.0f))
IconButton(onClick = {
onRemove(clipboardEntry)
}, modifier = Modifier.size(32.dp)) {
Icon(
painterResource(id = R.drawable.close),
contentDescription = "Close",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(16.dp)
)
}
}
Text(clipboardEntry.text ?: "", modifier = Modifier.padding(8.dp, 2.dp), style = Typography.bodySmall)
Spacer(modifier = Modifier.height(8.dp))
}
}
}
@Preview(showBackground = true)
@Composable
fun ClipboardEntryViewPreview() {
val sampleText = listOf("This is an entry", "Copying text a lot", "hunter2", "https://www.example.com/forum/viewpost/1234573193.html?parameter=1234")
LazyVerticalStaggeredGrid(
modifier = Modifier.fillMaxWidth(),
columns = StaggeredGridCells.Adaptive(160.dp),
verticalItemSpacing = 4.dp,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
items(sampleText.size) {
ClipboardEntryView(clipboardEntry = ClipboardEntry(0L, false, sampleText[it], null, listOf()), onPin = {}, onPaste = {}, onRemove = {})
}
}
}
class ClipboardHistoryManager(val context: Context, val coroutineScope: LifecycleCoroutineScope) : PersistentActionState {
private val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clipboardHistory = mutableStateListOf(ClipboardEntry(
timestamp = 0L,
pinned = true,
text = "Clipboard entries will appear here",
uri = null,
mimeTypes = listOf()
))
init {
coroutineScope.launch {
withContext(Dispatchers.IO) {
loadClipboard()
}
withContext(Dispatchers.Main) {
clipboardManager.addPrimaryClipChangedListener {
if(!context.getSettingBlocking(ClipboardHistoryEnabled)) return@addPrimaryClipChangedListener
val clip = clipboardManager.primaryClip
val text = clip?.getItemAt(0)?.coerceToText(context)?.toString()
val uri = clip?.getItemAt(0)?.uri
val timestamp = clip?.description?.timestamp ?: System.currentTimeMillis()
val mimeTypes = List(clip?.description?.mimeTypeCount ?: 0) {
clip?.description?.getMimeType(it)
}.filterNotNull()
val isSensitive = clip?.description?.extras?.getBoolean(
ClipDescription.EXTRA_IS_SENSITIVE, false) ?: false
// TODO: Support images and other non-text media
if (text != null && uri == null && !isSensitive) {
val isAlreadyPinned = clipboardHistory.firstOrNull {
((it.text != null && it.text == text) || (it.uri != null && it.uri == uri)) && it.pinned
}?.pinned ?: false
clipboardHistory.removeAll {
(it.text != null && it.text == text) || (it.uri != null && it.uri == uri)
}
val newEntry = ClipboardEntry(
timestamp = timestamp,
pinned = isAlreadyPinned,
text = text,
uri = uri,
mimeTypes = mimeTypes
)
clipboardHistory.add(newEntry)
saveClipboard()
}
}
}
}
}
fun pruneOldItems() {
val maxDays = 3L
val minimumTimestamp = System.currentTimeMillis() - (maxDays * 24L * 60L * 60L * 1000L)
clipboardHistory.removeAll {
(!it.pinned) && (it.timestamp < minimumTimestamp)
}
val maxItems = 25
val numUnpinnedItems = clipboardHistory.filter { !it.pinned }.size
val numItemsToRemove = numUnpinnedItems - maxItems
if(numItemsToRemove > 0) {
for(i in 0 until numItemsToRemove) {
val idx = clipboardHistory.indexOfFirst { !it.pinned }
if(idx == -1) break
clipboardHistory.removeAt(idx)
}
}
}
private fun saveClipboard() {
coroutineScope.launch {
withContext(Dispatchers.IO) {
pruneOldItems()
val json = Json.encodeToString(clipboardHistory.toList())
val file = File(context.filesDir, "clipboard.json")
file.writeText(json)
}
}
}
private suspend fun loadClipboard() {
try {
val file = File(context.filesDir, "clipboard.json")
if(!context.getSettingBlocking(ClipboardHistoryEnabled)) {
file.delete()
}else if (file.exists()) {
val reader = file.bufferedReader()
val inputString = reader.use { it.readText() }
val data = Json.decodeFromString<List<ClipboardEntry>>(inputString)
clipboardHistory.clear()
clipboardHistory.addAll(data)
pruneOldItems()
}
} catch (e: Exception) {
e.printStackTrace()
}
}
fun onPaste(item: ClipboardEntry) {
clipboardHistory.remove(item)
clipboardHistory.add(
ClipboardEntry(
timestamp = System.currentTimeMillis(),
pinned = item.pinned,
text = item.text,
uri = item.uri,
mimeTypes = item.mimeTypes
)
)
saveClipboard()
}
fun onPin(item: ClipboardEntry) {
clipboardHistory.remove(item)
clipboardHistory.add(
ClipboardEntry(
timestamp = System.currentTimeMillis(),
pinned = !item.pinned,
text = item.text,
uri = item.uri,
mimeTypes = item.mimeTypes
)
)
saveClipboard()
}
fun onRemove(item: ClipboardEntry) {
clipboardHistory.remove(item)
saveClipboard()
}
override suspend fun cleanUp() {
saveClipboard()
}
}
val ClipboardHistoryAction = Action(
icon = R.drawable.clipboard,
name = R.string.clipboard_manager_action_title,
simplePressImpl = null,
canShowKeyboard = true,
persistentState = { manager ->
ClipboardHistoryManager(manager.getContext(), manager.getLifecycleScope())
},
persistentStateInitialization = PersistentStateInitialization.OnKeyboardLoad,
windowImpl = { manager, persistent ->
val clipboardHistoryManager = persistent as ClipboardHistoryManager
clipboardHistoryManager.pruneOldItems()
object : ActionWindow {
@Composable
override fun windowName(): String {
return stringResource(R.string.clipboard_manager_action_title)
}
@Composable
override fun WindowContents(keyboardShown: Boolean) {
val clipboardHistory = useDataStore(ClipboardHistoryEnabled, blocking = true)
if(!clipboardHistory.value) {
ScrollableList {
PaymentSurface(isPrimary = true, title = "Clipboard History Inactive") {
ParagraphText("Clipboard history is not enabled. To save clipboard items, you can enable clipboard history. This will keep up to 25 items for 3 days unless pinned. Passwords and other items marked sensitive are excluded from history.")
Button(onClick = {
clipboardHistory.setValue(true)
}, modifier = Modifier.padding(8.dp).fillMaxWidth()) {
Text("Enable Clipboard History")
}
}
}
} else {
LazyVerticalStaggeredGrid(
modifier = Modifier.fillMaxWidth(),
columns = StaggeredGridCells.Adaptive(180.dp),
verticalItemSpacing = 4.dp,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
items(clipboardHistoryManager.clipboardHistory.size) { r_i ->
val i = clipboardHistoryManager.clipboardHistory.size - r_i - 1
val entry = clipboardHistoryManager.clipboardHistory[i]
ClipboardEntryView(clipboardEntry = entry, onPaste = {
if (it.uri != null) {
if (!manager.typeUri(it.uri, it.mimeTypes)) {
val toast = Toast.makeText(
manager.getContext(),
"App does not support image insertion",
Toast.LENGTH_SHORT
)
toast.show()
}
} else if (it.text != null) {
manager.typeText(it.text)
}
clipboardHistoryManager.onPaste(it)
}, onRemove = {
clipboardHistoryManager.onRemove(it)
}, onPin = {
clipboardHistoryManager.onPin(it)
})
}
}
}
}
override fun close() {
}
}
}
)

View File

@ -20,7 +20,8 @@ val AllActions = listOf(
RedoAction,
VoiceInputAction,
SystemVoiceInputAction,
SwitchLanguageAction
SwitchLanguageAction,
ClipboardHistoryAction
)
@ -72,7 +73,8 @@ val DefaultActions = listOf(
SettingsAction,
ThemeAction,
MemoryDebugAction,
SwitchLanguageAction
SwitchLanguageAction,
ClipboardHistoryAction
)
val DefaultActionsString = ActionRegistry.actionsToString(DefaultActions)

View File

@ -18,6 +18,7 @@ import org.futo.inputmethod.latin.settings.Settings
import org.futo.inputmethod.latin.settings.Settings.PREF_VIBRATION_DURATION_SETTINGS
import org.futo.inputmethod.latin.uix.SHOW_EMOJI_SUGGESTIONS
import org.futo.inputmethod.latin.uix.SettingsKey
import org.futo.inputmethod.latin.uix.actions.ClipboardHistoryEnabled
import org.futo.inputmethod.latin.uix.settings.ScreenTitle
import org.futo.inputmethod.latin.uix.settings.ScrollableList
import org.futo.inputmethod.latin.uix.settings.SettingSlider
@ -67,6 +68,12 @@ fun TypingScreen(navController: NavHostController = rememberNavController()) {
key = Settings.PREF_ENABLE_NUMBER_ROW,
default = false
)
SettingToggleDataStore(
title = "Clipboard History",
setting = ClipboardHistoryEnabled
)
SettingToggleSharedPrefs(
title = "Emoji key",
subtitle = "Show the emoji key on the bottom row",

View File

@ -6,20 +6,15 @@ import android.content.Intent
import android.net.Uri
import android.widget.Toast
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowForward
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@ -39,8 +34,8 @@ import org.futo.inputmethod.latin.uix.getSetting
import org.futo.inputmethod.latin.uix.setSetting
import org.futo.inputmethod.latin.uix.settings.SettingItem
import org.futo.inputmethod.latin.uix.settings.pages.ParagraphText
import org.futo.inputmethod.latin.uix.settings.pages.PaymentSurface
import org.futo.inputmethod.latin.uix.settings.useDataStore
import org.futo.inputmethod.latin.uix.theme.Typography
val LAST_UPDATE_CHECK_RESULT = stringPreferencesKey("last_update_check_result")
val LAST_UPDATE_CHECK_FAILED = booleanPreferencesKey("last_update_check_failed")
@ -142,55 +137,40 @@ val dismissedMigrateUpdateNotice = SettingsKey(
@Preview
fun ConditionalMigrateUpdateNotice() {
val context = LocalContext.current
val value = useDataStore(dismissedMigrateUpdateNotice)
val value = useDataStore(dismissedMigrateUpdateNotice, blocking = true)
if(!value.value) {
Surface(
color = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier
.fillMaxWidth()
.padding(24.dp, 8.dp), shape = RoundedCornerShape(24.dp)
) {
Column(modifier = Modifier.padding(8.dp, 0.dp)) {
Spacer(modifier = Modifier.height(8.dp))
Text(
"Use F-Droid or Obtainium",
modifier = Modifier.padding(8.dp),
style = Typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground
)
PaymentSurface(isPrimary = true, title = "Use F-Droid or Obtainium") {
ParagraphText("The standalone APK has been updated to remove the network permission.")
ParagraphText("The standalone APK has been updated to remove the network permission.")
ParagraphText("As a consequence, it can no longer offer automatic updates.")
ParagraphText("As a consequence, it can no longer offer automatic updates.")
ParagraphText("If you are still using the apk, we recommend downloading the app from F-Droid, Obtainium or Play Store so that you receive updates.")
ParagraphText("If you are still using the apk, we recommend downloading the app from F-Droid, Obtainium or Play Store so that you receive updates.")
ParagraphText("Visit keyboard.futo.org for download options.")
ParagraphText("Visit keyboard.futo.org for download options.")
Row(
modifier = Modifier
.padding(8.dp)
.fillMaxWidth()
) {
Row(
modifier = Modifier
.padding(8.dp)
.align(Alignment.CenterHorizontally)
) {
Box(modifier = Modifier.weight(1.0f)) {
Button(onClick = {
context.openURI("https://keyboard.futo.org/#downloads")
}, modifier = Modifier.align(Alignment.Center)) {
Text("Visit")
}
Box(modifier = Modifier.weight(1.0f)) {
Button(onClick = {
context.openURI("https://keyboard.futo.org/#downloads")
}, modifier = Modifier.align(Alignment.Center)) {
Text("Visit")
}
Box(modifier = Modifier.weight(1.0f)) {
Button(
onClick = { value.setValue(true) },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondary,
contentColor = MaterialTheme.colorScheme.onSecondary
), modifier = Modifier.align(Alignment.Center)
) {
Text("Dismiss")
}
}
Box(modifier = Modifier.weight(1.0f)) {
Button(
onClick = { value.setValue(true) },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondary,
contentColor = MaterialTheme.colorScheme.onSecondary
), modifier = Modifier.align(Alignment.Center)
) {
Text("Dismiss")
}
}
}
}