Compare commits

..

36 Commits

Author SHA1 Message Date
Patrick Goldinger
7eb7f0ef80 Release v0.3.10-beta02 2021-03-19 19:42:08 +01:00
Patrick Goldinger
78e5e417ce Update README.md to include new beta track 2021-03-19 18:49:40 +01:00
Patrick Goldinger
ffbf7f8ea7 Merge pull request #454 from X-yl/clipboard-stuff
Added support for private clipboard and clipboard history
2021-03-19 17:49:16 +01:00
Patrick Goldinger
27cc4897c3 Merge pull request #479 from florisboard/fix-import-theme-crash
Fix import theme crash for big files
2021-03-19 17:18:49 +01:00
Patrick Goldinger
e5111a8efe Fix import theme crash for big files (#465) 2021-03-19 17:04:48 +01:00
Patrick Goldinger
80fd5ca84a Add beta metadata 2021-03-19 00:57:11 +01:00
x-yl
e8f2c6ce74 fix bug when history size is reduced 2021-03-18 23:21:50 +04:00
x-yl
5676cbf18e Stupid telegram, not using ContentResolver... smh 2021-03-18 17:50:48 +04:00
x-yl
2bdaea6189 revoke URI permissions, support API <25 2021-03-18 17:10:28 +04:00
Patrick Goldinger
da2287a739 Fix symbols layouts applying the caps state once again (#298) 2021-03-17 23:29:58 +01:00
x-yl
86042bb1e1 make popup buttons extend to the edge of popup 2021-03-17 15:30:22 +04:00
x-yl
c99673ff1d mime type fixes, remove from history after pressing delete 2021-03-17 15:20:12 +04:00
x-yl
8b89b27fb0 Misc. fixes 2021-03-17 10:57:56 +04:00
x-yl
b56c976fa0 code cleanup 2021-03-17 10:26:22 +04:00
x-yl
08889fdc60 docs 2021-03-17 10:20:21 +04:00
x-yl
e8d657e81c free storage after images leave clipboard 2021-03-17 09:38:03 +04:00
x-yl
bfcea8b718 Make pins persistent 2021-03-16 18:58:14 +04:00
x-yl
7f07686b6c added proper mime type support to content provider 2021-03-16 16:38:01 +04:00
x-yl
e4ecc63b9d Added an abstraction around ClipData 2021-03-15 15:12:30 +04:00
x-yl
aacb33bd5d fixed issue when floris clipboard is disabled 2021-03-13 20:46:27 +04:00
x-yl
a0aa446988 Change back button 2021-03-13 18:06:10 +04:00
x-yl
fe086ed6d8 removed some debug logging 2021-03-13 17:39:11 +04:00
x-yl
64ddd0f421 fixed a stupid bug somehow 2021-03-13 17:35:35 +04:00
x-yl
40fe72e33c fix a few bugs 2021-03-13 14:55:58 +04:00
x-yl
b229970ec3 cleanup and documentation 2021-03-13 13:04:34 +04:00
x-yl
ec32c211f1 added delete and paste. pretty much feature complete now. 2021-03-12 23:39:23 +04:00
x-yl
e66b8a052a Pin/unpin support 2021-03-12 22:18:40 +04:00
x-yl
4a22c2698c added more ways to open clipboard context, fixed popups, refactored some code 2021-03-12 21:50:24 +04:00
x-yl
ae95bbd7c4 Added a mock popup 2021-03-11 18:03:08 +04:00
x-yl
0bdeeaa340 VERY work in progress 2021-03-11 10:24:40 +04:00
x-yl
92a885a34c Little bit of preference stuff 2021-03-11 10:24:35 +04:00
x-yl
bc2f03a920 light refactoring, some theme stuff 2021-03-11 10:24:26 +04:00
x-yl
f60827b634 small theme fix 2021-03-11 10:23:59 +04:00
x-yl
dcf81b27a0 Fixed animations, added image support, some documentation 2021-03-11 10:23:59 +04:00
x-yl
0d8601cb15 Text-only clipboard history implemented 2021-03-11 10:23:59 +04:00
x-yl
ecf3c6bf27 All clipboard actions now use FlorisClipboardManager. Added support for commiting non-text content. Added simple clipboard history layout. 2021-03-11 10:23:46 +04:00
63 changed files with 2288 additions and 82 deletions

View File

@@ -1,14 +1,15 @@
<img align="left" width="80" height="80"
src="fastlane/metadata/android/en-US/images/icon.png" alt="App icon">
# FlorisBoard [![Release](https://img.shields.io/github/v/release/florisboard/florisboard)](https://github.com/florisboard/florisboard/releases) [![Crowdin](https://badges.crowdin.net/florisboard/localized.svg)](https://crowdin.florisboard.patrickgold.dev) ![FlorisBoard CI](https://github.com/florisboard/florisboard/workflows/FlorisBoard%20CI/badge.svg?event=push)
# FlorisBoard [![Crowdin](https://badges.crowdin.net/florisboard/localized.svg)](https://crowdin.florisboard.patrickgold.dev) ![FlorisBoard CI](https://github.com/florisboard/florisboard/workflows/FlorisBoard%20CI/badge.svg?event=push)
**FlorisBoard** is a free and open-source keyboard for Android 6.0+
devices. It aims at being modern, user-friendly and customizable while
fully respecting your privacy. Currently in alpha/early-beta state.
fully respecting your privacy. Currently in early-beta state.
## Public Alpha Test Programme
Wanna try it out on your device? Use one of the following options:
### Stable [![Latest stable release](https://img.shields.io/github/v/release/florisboard/florisboard)](https://github.com/florisboard/florisboard/releases/latest)
Releases on this track are in general stable and ready for everyday use, except for features marked as experimental. Use one of the following options to receive FlorisBoard's stable releases:
_A. Get it on F-Droid_:
@@ -36,6 +37,16 @@ for and download FlorisBoard without prior joining the alpha group.
_C. Use the APK provided in the release section of this repo_
### Beta [![Latest beta release](https://img.shields.io/github/v/release/florisboard/florisboard?include_prereleases)](https://github.com/florisboard/florisboard/releases)
Releases on this track are also in general stable and should be ready for everyday use, though crashes and bugs are more likely to occur. Use releases from this track if you want to get new features faster and give feedback for brand-new stuff. Options to get beta releases:
_A. IzzySoft's repo for F-Droid_:
[<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png" height="64" alt="IzzySoft repo badge">](https://apt.izzysoft.de/fdroid/index/apk/dev.patrickgold.florisboard.beta)
_B. Use the APK provided in the release section of this repo_
### Giving feedback
If you want to give feedback to FlorisBoard, there are several ways to
do so, as listed [here](CONTRIBUTING.md#giving-general-feedback).
@@ -86,6 +97,7 @@ milestones, please refer to the [Feature roadmap](#feature-roadmap).
### Other useful features
* [x] One-handed mode
* [x] Clipboard/cursor tools
* [x] Clipboard manager/history
* [x] Integrated number row / symbols in character layouts
* [x] Gesture support
* [x] Full integration in IME service list of Android (xml/method)

View File

@@ -1,6 +1,8 @@
plugins {
id("com.android.application") version "4.1.2"
kotlin("android") version "1.4.30"
kotlin("kapt") version "1.4.30"
}
android {
@@ -21,7 +23,7 @@ android {
applicationId = "dev.patrickgold.florisboard"
minSdkVersion(23)
targetSdkVersion(30)
versionCode(29)
versionCode(30)
versionName("0.3.10")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -44,7 +46,7 @@ android {
create("beta") // Needed because by default the "beta" BuildType does not exist
named("beta").configure {
applicationIdSuffix = ".beta"
versionNameSuffix = "-beta01"
versionNameSuffix = "-beta02"
proguardFiles.add(getDefaultProguardFile("proguard-android-optimize.txt"))
resValue("mipmap", "floris_app_icon", "@mipmap/ic_app_icon_beta")
@@ -72,6 +74,7 @@ android {
}
}
dependencies {
implementation("androidx.activity", "activity-ktx", "1.2.1")
implementation("androidx.appcompat", "appcompat", "1.2.0")
@@ -88,6 +91,8 @@ dependencies {
implementation("com.jaredrummler", "colorpicker", "1.1.0")
implementation("com.jakewharton.timber", "timber", "4.7.1")
implementation("com.nambimobile.widgets", "expandable-fab", "1.0.2")
implementation("androidx.room", "room-runtime", "2.2.6")
kapt("androidx.room", "room-compiler","2.2.6")
testImplementation("junit", "junit", "4.13.1")
testImplementation("org.mockito", "mockito-inline", "3.7.7")

View File

@@ -111,6 +111,14 @@
android:label="@string/crash_dialog__title"
android:theme="@style/CrashDialogTheme"/>
<provider
android:name="dev.patrickgold.florisboard.ime.clip.provider.FlorisContentProvider"
android:authorities="dev.patrickgold.florisboard.provider.clip"
android:grantUriPermissions="true"
android:exported="false">
</provider>
</application>
</manifest>

View File

@@ -7,7 +7,8 @@
"~enter": {
"main": { "code": -213, "label": "switch_to_media_context", "type": "system_gui" },
"relevant": [
{ "code": -216, "label": "toggle_one_handed_mode_right", "type": "system_gui" }
{ "code": -216, "label": "toggle_one_handed_mode_right", "type": "system_gui" },
{ "code": -214, "label": "switch_to_clipboard_context", "type": "system_gui"}
]
},
"~left": {

View File

@@ -10,7 +10,8 @@
{ "code": -20, "label": "arrow_left", "type": "navigation" },
{ "code": -21, "label": "arrow_right", "type": "navigation" },
{ "code": -131, "label": "clipboard_cut", "type": "enter_editing" },
{ "code": -132, "label": "clipboard_paste", "type": "enter_editing" }
{ "code": -132, "label": "clipboard_paste", "type": "enter_editing" },
{ "code": -214, "label": "switch_to_clipboard_context", "type": "system_gui"}
]
]
}

View File

@@ -0,0 +1,114 @@
package dev.patrickgold.florisboard.ime.clip
import android.graphics.drawable.Drawable
import android.view.LayoutInflater
import android.view.View
import android.view.View.GONE
import android.view.View.VISIBLE
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import dev.patrickgold.florisboard.R
import dev.patrickgold.florisboard.ime.clip.provider.ClipboardItem
import dev.patrickgold.florisboard.ime.clip.provider.ItemType
import dev.patrickgold.florisboard.ime.core.FlorisBoard
class ClipboardHistoryItemAdapter(
private val dataSet: ArrayDeque<FlorisClipboardManager.TimedClipData>,
private val pins: ArrayDeque<ClipboardItem>
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
class ClipboardHistoryTextViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val textView: TextView = view.findViewById(R.id.clipboard_history_item_text)
}
class ClipboardHistoryImageViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val imgView: ImageView = view.findViewById(R.id.clipboard_history_item_img)
}
companion object {
private const val MAX_SIZE: Int = 256
}
override fun getItemViewType(position: Int): Int {
return if (position < pins.size) {
// is a pin
pins[position].type.value
}else {
// regular history item
dataSet[position - pins.size].data.type.value
}
}
override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
// Create a new view, which defines the UI of the list item
val vh = when (viewType) {
ItemType.IMAGE.value -> {
val view = LayoutInflater.from(viewGroup.context)
.inflate(R.layout.clipboard_history_item_image, viewGroup, false)
ClipboardHistoryImageViewHolder(view)
}
ItemType.TEXT.value -> {
val view = LayoutInflater.from(viewGroup.context)
.inflate(R.layout.clipboard_history_item_text, viewGroup, false)
ClipboardHistoryTextViewHolder(view)
}
else -> null
}!!
val clipboardInputManager = ClipboardInputManager.getInstance()
(vh.itemView as ClipboardHistoryItemView).keyboardView = clipboardInputManager.getClipboardHistoryView()
return vh
}
// Replace the contents of a view (invoked by the layout manager)
override fun onBindViewHolder(viewHolder: RecyclerView.ViewHolder, position: Int) {
when (viewHolder) {
is ClipboardHistoryTextViewHolder -> {
var text = if (position < pins.size) {
(viewHolder.itemView as ClipboardHistoryItemView).setPinned()
pins[position].text
}else {
(viewHolder.itemView as ClipboardHistoryItemView).setUnpinned()
dataSet[position - pins.size].data.text
}
if (text!!.length > MAX_SIZE) {
text = text.subSequence(0 until MAX_SIZE).toString() + "..."
}
viewHolder.textView.text = text
}
is ClipboardHistoryImageViewHolder -> {
val uri = if (position < pins.size) {
(viewHolder.itemView as ClipboardHistoryItemView).setPinned()
pins[position].uri
}else {
(viewHolder.itemView as ClipboardHistoryItemView).setUnpinned()
dataSet[position - pins.size].data.uri
}
viewHolder.imgView.clipToOutline = true
viewHolder.imgView.visibility = GONE
// For very large images, this can take a bit
FlorisClipboardManager.getInstance().executor.execute {
val resolver = FlorisBoard.getInstance().context.contentResolver
val inputStream = resolver.openInputStream(uri!!)
val drawable = Drawable.createFromStream(inputStream, "clipboard URI")
viewHolder.itemView.post {
viewHolder.imgView.setImageDrawable(drawable)
viewHolder.imgView.visibility = VISIBLE
}
}
}
}
}
override fun getItemCount() = pins.size + dataSet.size
}

View File

@@ -0,0 +1,86 @@
package dev.patrickgold.florisboard.ime.clip
import android.content.Context
import android.util.AttributeSet
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import androidx.constraintlayout.widget.ConstraintLayout
import dev.patrickgold.florisboard.R
import dev.patrickgold.florisboard.ime.core.FlorisBoard
import dev.patrickgold.florisboard.ime.theme.Theme
import dev.patrickgold.florisboard.ime.theme.ThemeManager
class ClipboardHistoryItemView: ConstraintLayout, ThemeManager.OnThemeUpdatedListener {
lateinit var keyboardView: ClipboardHistoryView
constructor(context: Context) : this(context, null as AttributeSet?)
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
private var popupManager: ClipboardPopupManager? = null
override fun onAttachedToWindow() {
super.onAttachedToWindow()
popupManager = ClipboardPopupManager(keyboardView, FlorisBoard.getInstance().popupLayerView, this)
setOnClickListener{
onClickItem()
}
setOnLongClickListener{
onLongClickItem()
}
val themeManager = ThemeManager.default()
themeManager.registerOnThemeUpdatedListener(this)
}
override fun onThemeUpdated(theme: Theme) {
background.setTint(theme.getAttr(Theme.Attr.KEY_BACKGROUND).toSolidColor().color)
val pin = findViewById<ImageView>(R.id.clipboard_pin).drawable
pin?.setTint(theme.getAttr(Theme.Attr.KEY_FOREGROUND).toSolidColor().color)
}
private fun onLongClickItem() : Boolean {
popupManager?.show(this)
return true
}
private fun onClickItem(){
val position = ClipboardInputManager.getInstance().getPositionOfView(this)
val instance = FlorisClipboardManager.getInstance()
val canPaste = instance.canBePasted(instance.peekHistoryOrPin(position))
if (canPaste) {
instance.pasteItem(position)
}else {
Toast.makeText(context, context.getString(R.string.clip__cant_paste), Toast.LENGTH_SHORT).show()
}
}
fun setPinned() {
val view = findViewById<TextView>(R.id.clipboard_history_item_text)
view?.run {
val params = layoutParams as LayoutParams
params.marginEnd = resources.getDimensionPixelSize(R.dimen.clipboard_text_item_pin_margin)
layoutParams = params
}
findViewById<ImageView>(R.id.clipboard_pin).visibility = VISIBLE
invalidate()
val themeManager = ThemeManager.default()
onThemeUpdated(themeManager.activeTheme)
}
fun setUnpinned(){
val view = findViewById<TextView>(R.id.clipboard_history_item_text)
// if text view, also update margin.
view?.run {
val params = layoutParams as LayoutParams
params.marginEnd = 0
layoutParams = params
invalidate()
}
findViewById<ImageView>(R.id.clipboard_pin).visibility = INVISIBLE
}
}

View File

@@ -0,0 +1,71 @@
package dev.patrickgold.florisboard.ime.clip
import android.content.Context
import android.util.AttributeSet
import android.widget.ImageButton
import android.widget.LinearLayout
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import dev.patrickgold.florisboard.R
import dev.patrickgold.florisboard.ime.core.FlorisBoard
import dev.patrickgold.florisboard.ime.theme.Theme
import dev.patrickgold.florisboard.ime.theme.ThemeManager
import kotlin.math.roundToInt
class ClipboardHistoryView : LinearLayout, FlorisBoard.EventListener,
ThemeManager.OnThemeUpdatedListener {
private val florisboard: FlorisBoard? = FlorisBoard.getInstanceOrNull()
private val themeManager: ThemeManager = ThemeManager.default()
var backButton: ImageButton? = null
private set
var clipText: TextView? = null
private set
var clipboardBar: LinearLayout? = null
private set
private var clipboardHistory: RecyclerView? = null
private var clearAll: ImageButton? = null
constructor(context: Context) : this(context, null)
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
override fun onAttachedToWindow() {
super.onAttachedToWindow()
florisboard?.addEventListener(this)
themeManager.registerOnThemeUpdatedListener(this)
backButton = findViewById(R.id.back_to_keyboard_button)
clipText = findViewById(R.id.clipboard_text)
clipboardBar = findViewById(R.id.clipboard_bar)
clipboardHistory = findViewById(R.id.clipboard_history_items)
clearAll = findViewById(R.id.clear_clipboard_history)
onApplyThemeAttributes()
// lord alone knows why it doesn't work without this..
onThemeUpdated(themeManager.activeTheme)
}
override fun onDetachedFromWindow() {
themeManager.unregisterOnThemeUpdatedListener(this)
florisboard?.removeEventListener(this)
super.onDetachedFromWindow()
}
override fun onThemeUpdated(theme: Theme) {
val fgColor = theme.getAttr(Theme.Attr.KEY_FOREGROUND).toSolidColor().color
clipText?.setTextColor(fgColor)
backButton?.drawable?.setTint(fgColor)
clearAll?.setColorFilter(fgColor)
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val height = florisboard?.inputView?.desiredMediaKeyboardViewHeight ?: 0.0f
super.onMeasure(widthMeasureSpec, MeasureSpec.makeMeasureSpec(height.roundToInt(), MeasureSpec.EXACTLY))
}
}

View File

@@ -0,0 +1,218 @@
package dev.patrickgold.florisboard.ime.clip
import android.annotation.SuppressLint
import android.os.Handler
import android.os.Looper
import android.view.MotionEvent
import android.view.View
import android.widget.*
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.StaggeredGridLayoutManager
import dev.patrickgold.florisboard.BuildConfig
import dev.patrickgold.florisboard.R
import dev.patrickgold.florisboard.ime.clip.provider.ClipboardItem
import dev.patrickgold.florisboard.ime.core.FlorisBoard
import dev.patrickgold.florisboard.ime.core.InputKeyEvent
import dev.patrickgold.florisboard.ime.core.InputView
import dev.patrickgold.florisboard.ime.text.key.KeyCode
import dev.patrickgold.florisboard.ime.text.key.KeyData
import kotlinx.coroutines.*
import kotlin.math.pow
/**
* Handles the clipboard view and allows for communication between UI and logic.
*/
class ClipboardInputManager private constructor() : CoroutineScope by MainScope(),
FlorisBoard.EventListener{
private val florisboard = FlorisBoard.getInstance()
private var repeatedKeyPressHandler: Handler? = null
private var recyclerView: RecyclerView? = null
private var adapter: ClipboardHistoryItemAdapter? = null
companion object {
private var instance: ClipboardInputManager? = null
@Synchronized
fun getInstance(): ClipboardInputManager {
if (instance == null) {
instance = ClipboardInputManager()
}
return instance!!
}
}
init {
florisboard.addEventListener(this)
}
override fun onCreateInputView() {
super.onCreateInputView()
repeatedKeyPressHandler = Handler(florisboard.context.mainLooper)
}
/**
* Called when a new input view has been registered. Used to initialize all media-relevant
* views and layouts.
*/
@SuppressLint("ClickableViewAccessibility")
override fun onRegisterInputView(inputView: InputView) {
launch(Dispatchers.Default) {
inputView.findViewById<ImageButton>(R.id.back_to_keyboard_button)
.setOnTouchListener { view, event -> onButtonPressEvent(view, event) }
inputView.findViewById<ImageButton>(R.id.clear_clipboard_history)
.setOnTouchListener { view, event -> onButtonPressEvent(view, event) }
recyclerView = inputView.findViewById(R.id.clipboard_history_items)
if (BuildConfig.DEBUG && adapter == null) {
error("initClipboard() not called")
}
recyclerView!!.adapter = adapter
val manager = StaggeredGridLayoutManager(2, StaggeredGridLayoutManager.VERTICAL)
recyclerView!!.layoutManager = manager
}
}
/**
* Clean-up of resources and stopping all coroutines.
*/
override fun onDestroy() {
cancel()
instance = null
}
/**
* Returns a reference to the [ClipboardHistoryView]
*/
fun getClipboardHistoryView() : ClipboardHistoryView{
return FlorisBoard.getInstance().inputView?.mainViewFlipper?.getChildAt(2) as ClipboardHistoryView
}
/**
* Returns the adapter position of the view, i.e the position that the item is displayed at (including pins and
* history items).
*
* @param view The ClipboardHistoryItemView whose position is to be determined.
* @return The adapter position of the view
*/
fun getPositionOfView(view: View): Int {
return recyclerView?.getChildLayoutPosition(view)!!
}
/**
* Notify adapter that an item was inserted.
*
* @param position The position the item was inserted at
*/
fun notifyItemInserted(position: Int) = adapter?.notifyItemInserted(position)
/**
* Notify adapter that an item was removed
* @param position The position the item was removed from
*/
fun notifyItemRemoved(position: Int) = adapter?.notifyItemRemoved(position)
/**
* Notify adapter that an item range was removed.
* @param start The index the range starts at (inclusive)
* @param numberOfItems The number of items removed
*/
fun notifyItemRangeRemoved(start: Int, numberOfItems: Int) = adapter?.notifyItemRangeRemoved(start, numberOfItems)
/**
* Notify adapter that an item was moved
* @param from The original position
* @param to The final position
*/
fun notifyItemMoved(from: Int, to: Int) = adapter?.notifyItemMoved(from, to)
/**
* Notify adapter that an item was changed.
*
* @param i The position of the item
*/
fun notifyItemChanged(i: Int) = adapter?.notifyItemChanged(i)
/**
* Handles clicks on the back to keyboard button.
*/
private fun onButtonPressEvent(view: View, event: MotionEvent?): Boolean {
event ?: return false
val data = when (view.id) {
R.id.back_to_keyboard_button -> KeyData(code = KeyCode.SWITCH_TO_TEXT_CONTEXT)
R.id.clear_clipboard_history -> KeyData(code = KeyCode.CLEAR_CLIPBOARD_HISTORY)
else -> null
}!!
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
florisboard.keyPressVibrate()
florisboard.keyPressSound(data)
florisboard.textInputManager.inputEventDispatcher.send(InputKeyEvent.down(data))
}
MotionEvent.ACTION_UP -> {
florisboard.textInputManager.inputEventDispatcher.send(InputKeyEvent.up(data))
}
MotionEvent.ACTION_CANCEL -> {
florisboard.textInputManager.inputEventDispatcher.send(InputKeyEvent.cancel(data))
}
}
// MUST return false here so the background selector for showing a transparent bg works
return false
}
/**
* [recyclerView] will be linked to [dataSet] and [pins] when initialized.
*
* @param dataSet the data set to link to
* @param pins The pins to link to
*/
fun initClipboard(dataSet: ArrayDeque<FlorisClipboardManager.TimedClipData>, pins: ArrayDeque<ClipboardItem>) {
this.adapter = ClipboardHistoryItemAdapter(dataSet = dataSet, pins= pins)
}
/**
* Plays an animation of all items moving off the the clipboard from the top.
*
* @param start The index to start at (to ignore pins)
* @param size The size of the clipboard
* @return The time in millis till the last animation will complete.
*/
fun clearClipboardWithAnimation(start: Int, size: Int): Long {
// list of views to animate
val views = arrayListOf<View>()
for(i in 0 until size){
recyclerView?.findViewHolderForLayoutPosition(i + start)?.let {
views.add(it.itemView)
}
}
// animate the views
var delay = 1L
for (view in views) {
delay += (10 * delay.toDouble().pow(0.1)).toLong()
val an = view.animate().translationX(1500f)
an.startDelay = delay
an.duration = 250
}
// a little while later we reset the views so they can be reused.
Handler(Looper.getMainLooper()).postDelayed({
for (view in views) {
view.translationX = 0f
}
}, 450 + delay)
return 280 + delay
}
}

View File

@@ -0,0 +1,127 @@
package dev.patrickgold.florisboard.ime.clip
import android.view.LayoutInflater
import android.view.View
import android.view.View.GONE
import android.view.View.VISIBLE
import android.widget.LinearLayout
import android.widget.Space
import android.widget.TextView
import dev.patrickgold.florisboard.R
import dev.patrickgold.florisboard.ime.core.FlorisBoard
import dev.patrickgold.florisboard.ime.popup.PopupLayerView
import kotlin.math.max
class ClipboardPopupManager(private val keyboardView: ClipboardHistoryView,
private val popupLayerView: PopupLayerView?,
private val clipboardHistoryItem: ClipboardHistoryItemView) {
private val popupView: ClipboardPopupView = LayoutInflater.from(keyboardView.context).inflate(R.layout.clip_popup_layout, null) as ClipboardPopupView
private var width = 0
private var height = 0
private var xOffset = 0
private var yOffset = 0
init {
popupLayerView?.addView(popupView)
}
private fun pinButtonListener() {
val pos = ClipboardInputManager.getInstance().getPositionOfView(clipboardHistoryItem)
val pinned = FlorisClipboardManager.getInstance().isPinned(pos)
if (pinned) {
FlorisClipboardManager.getInstance().unpinClip(pos)
hide()
} else {
FlorisClipboardManager.getInstance().pinClip(pos)
hide()
}
}
/**
* Show a popup.
*/
fun show(view: ClipboardHistoryItemView) {
val pinButton = popupView.findViewById<LinearLayout>(R.id.pin_clip_item)
pinButton.setOnClickListener {
pinButtonListener()
}
val pos = ClipboardInputManager.getInstance().getPositionOfView(clipboardHistoryItem)
val pinned = FlorisClipboardManager.getInstance().isPinned(pos)
if (pinned) {
pinButton.findViewById<TextView>(R.id.pin_clip_item_text).text = view.context.getString(R.string.clip__unpin_item)
}
val delete = popupView.findViewById<LinearLayout>(R.id.remove_from_history)
delete.setOnClickListener {
FlorisClipboardManager.getInstance().removeClip(pos)
hide()
}
val clipboardManager = FlorisClipboardManager.getInstance()
val clipItem = clipboardManager.peekHistoryOrPin(pos)
val pasteShouldBeEnabled = FlorisClipboardManager.getInstance().canBePasted(clipItem)
// the clipboard item has any of the supported mime types of the editor OR is plain text.
val paste = popupView.findViewById<LinearLayout>(R.id.paste_clip_item)
if (pasteShouldBeEnabled) {
paste.setOnClickListener {
FlorisClipboardManager.getInstance().pasteItem(pos)
hide()
}
popupView.findViewById<Space>(R.id.paste_clip_item_space).visibility = VISIBLE
paste.visibility = VISIBLE
}else {
popupView.findViewById<Space>(R.id.paste_clip_item_space).visibility = GONE
paste.visibility = GONE
}
FlorisBoard.getInstance().isClipboardContextMenuShown = true
popupLayerView?.clipboardPopupManager = this
popupLayerView?.intercept = popupView
calc(view)
popupView.properties.let {
it.width = this.width
it.height = this.height
it.xOffset = this.xOffset
it.yOffset = this.yOffset
}
popupView.show(keyboardView)
}
/**
* Calculate sizes of popup.
*/
private fun calc(view: ClipboardHistoryItemView) {
val widthMeasureSpec: Int = View.MeasureSpec.makeMeasureSpec(view.width, View.MeasureSpec.AT_MOST)
val heightMeasureSpec: Int = View.MeasureSpec.makeMeasureSpec(100000, View.MeasureSpec.AT_MOST)
popupView.invalidate()
popupView.measure(widthMeasureSpec, heightMeasureSpec)
width = view.width * 4 / 5
height = popupView.measuredHeight
xOffset = view.x.toInt() + (view.width - width) / 2
// y offset is either where the top of the item is OR if the top is off screen, the top of the keyboard.
yOffset = max(view.y.toInt() - keyboardView.height - height / 2 - 20, keyboardView.y.toInt() - keyboardView.height - height / 2 - 20)
}
/**
* Hides a popup.
*/
fun hide() {
popupView.hide()
popupLayerView?.intercept = null
popupLayerView?.clipboardPopupManager = null
FlorisBoard.getInstance().isClipboardContextMenuShown = false
popupView.apply {
visibility = GONE
}
}
}

View File

@@ -0,0 +1,130 @@
package dev.patrickgold.florisboard.ime.clip
import android.content.Context
import android.graphics.drawable.PaintDrawable
import android.util.AttributeSet
import android.view.View
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import dev.patrickgold.florisboard.R
import dev.patrickgold.florisboard.ime.theme.Theme
import dev.patrickgold.florisboard.ime.theme.ThemeManager
import dev.patrickgold.florisboard.util.ViewLayoutUtils
class ClipboardPopupView: LinearLayout, ThemeManager.OnThemeUpdatedListener {
constructor(context: Context) : this(context, null)
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(
context,
attrs,
defStyleAttr
)
private var backgroundDrawable: PaintDrawable = PaintDrawable().apply {
setCornerRadius(ViewLayoutUtils.convertDpToPixel(6.0f, context))
}
private val themeManager: ThemeManager = ThemeManager.default()
val properties: Properties = Properties(
width = 0,
height = 0,
xOffset = 0,
yOffset = 0
)
private val isShowing: Boolean
get() = visibility == VISIBLE
init {
visibility = GONE
background = backgroundDrawable
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
themeManager.registerOnThemeUpdatedListener(this)
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
themeManager.unregisterOnThemeUpdatedListener(this)
}
override fun onThemeUpdated(theme: Theme) {
backgroundDrawable.apply {
setTint(theme.getAttr(Theme.Attr.POPUP_BACKGROUND).toSolidColor().color)
}
this.findViewById<ImageView>(R.id.pin_clip_item_icon).drawable.apply {
setTint(theme.getAttr(Theme.Attr.WINDOW_TEXT_COLOR).toSolidColor().color)
}
this.findViewById<ImageView>(R.id.remove_from_history_icon).drawable.apply {
setTint(theme.getAttr(Theme.Attr.WINDOW_TEXT_COLOR).toSolidColor().color)
}
this.findViewById<ImageView>(R.id.paste_clip_item_icon).drawable.apply {
setTint(theme.getAttr(Theme.Attr.WINDOW_TEXT_COLOR).toSolidColor().color)
}
if (isShowing) {
invalidate()
}
}
private fun applyProperties(anchor: View) {
val anchorCoords = IntArray(2)
anchor.getLocationInWindow(anchorCoords)
val anchorX = anchorCoords[0]
val anchorY = anchorCoords[1] + anchor.measuredHeight
when (val lp = layoutParams) {
is FrameLayout.LayoutParams -> lp.apply {
width = properties.width
height = properties.height
setMargins(
anchorX + properties.xOffset,
anchorY + properties.yOffset,
0,
0
)
}
else -> {
layoutParams = FrameLayout.LayoutParams(properties.width, properties.height).apply {
setMargins(
anchorX + properties.xOffset,
anchorY + properties.yOffset,
0,
0
)
}
}
}
if (isShowing) {
requestLayout()
invalidate()
}
}
fun show(anchor: View) {
applyProperties(anchor)
visibility = VISIBLE
requestLayout()
invalidate()
}
fun hide() {
visibility = GONE
requestLayout()
invalidate()
}
data class Properties(
var width: Int,
var height: Int,
var xOffset: Int,
var yOffset: Int
)
}

View File

@@ -0,0 +1,401 @@
package dev.patrickgold.florisboard.ime.clip
import android.content.ClipboardManager
import android.content.Context
import android.content.Context.CLIPBOARD_SERVICE
import android.os.Handler
import android.os.Looper
import dev.patrickgold.florisboard.ime.clip.provider.*
import dev.patrickgold.florisboard.ime.core.FlorisBoard
import dev.patrickgold.florisboard.ime.core.PrefHelper
import dev.patrickgold.florisboard.util.cancelAll
import dev.patrickgold.florisboard.util.postAtScheduledRate
import timber.log.Timber
import java.io.Closeable
import java.util.*
import java.util.concurrent.ExecutorService
import kotlin.collections.ArrayDeque
/**
* [FlorisClipboardManager] manages the clipboard and clipboard history.
*
* Also just going to document how all the classes here work.
*
* [FlorisClipboardManager] handles storage and retrieval of clipboard items. All manipulation of the
* clipboard goes through here.
*
* [ClipboardInputManager] handles the input view and allows for communication between UI and logic
*
* [ClipboardHistoryView] is the view representing the clipboard context. Only does some theme stuff.
*
* [ClipboardHistoryItemView] is the view representing an item in the clipboard history (either image or text). Only
* does UI stuff.
*
* [ClipboardHistoryItemAdapter] is the recyclerview adapter that backs the clipboard history.
*
* [ClipboardPopupManager] handles the popups for each [ClipboardHistoryItemView] (each item has its own popup manager)
*
* [ClipboardPopupView] is the view representing a popup displayed when long pressing on a clipboard history item.
*/
class FlorisClipboardManager private constructor() : ClipboardManager.OnPrimaryClipChangedListener, Closeable {
private lateinit var pinsDao: PinnedClipboardItemDao
lateinit var executor: ExecutorService
// Using ArrayDeque because it's "technically" the correct data structure (I think).
// Newest stored first, oldest stored last.
private var history: ArrayDeque<TimedClipData> = ArrayDeque()
private var pins: ArrayDeque<ClipboardItem> = ArrayDeque()
private var current: ClipboardItem? = null
private var onPrimaryClipChangedListeners: ArrayList<OnPrimaryClipChangedListener> = arrayListOf()
private lateinit var systemClipboardManager: ClipboardManager
private lateinit var handler: Handler
private lateinit var prefHelper: PrefHelper
data class TimedClipData(val data: ClipboardItem, val timeUTC: Long)
interface OnPrimaryClipChangedListener {
fun onPrimaryClipChanged()
}
companion object {
private var instance: FlorisClipboardManager? = null
// 1 minute
private const val INTERVAL = 60 * 1000L
@Synchronized
fun getInstance(): FlorisClipboardManager {
if (instance == null) {
instance = FlorisClipboardManager()
}
return instance!!
}
/**
* Taken from ClipboardDescription.java from the AOSP
*
* Helper to compare two MIME types, where one may be a pattern.
* @param concreteType A fully-specified MIME type.
* @param desiredType A desired MIME type that may be a pattern such as * / *.
* @return Returns true if the two MIME types match.
*/
fun compareMimeTypes(concreteType: String, desiredType: String): Boolean {
val typeLength = desiredType.length
if (typeLength == 3 && desiredType == "*/*") {
return true
}
val slashpos = desiredType.indexOf('/')
if (slashpos > 0) {
if (typeLength == slashpos + 2 && desiredType[slashpos + 1] == '*') {
if (desiredType.regionMatches(0, concreteType, 0, slashpos + 1)) {
return true
}
} else if (desiredType == concreteType) {
return true
}
}
return false
}
}
/**
* Adds a new item to the clipboard history (if enabled).
*/
fun updateHistory(newData: ClipboardItem) {
val clipboardPrefs = prefHelper.clipboard
if (clipboardPrefs.enableHistory) {
if (clipboardPrefs.limitHistorySize) {
var numRemoved = 0
while (history.size >= clipboardPrefs.maxHistorySize) {
numRemoved += 1
history.removeLast().data.close()
}
ClipboardInputManager.getInstance().notifyItemRangeRemoved(history.size, numRemoved)
}
val timed = TimedClipData(newData, System.currentTimeMillis())
history.addFirst(timed)
ClipboardInputManager.getInstance().notifyItemInserted(pins.size)
}
}
/**
* Used so that [onPrimaryClipChanged] knows whether it was called by [changeCurrent] (and hence shouldn't update
* history)
*/
private var shouldUpdateHistory = true
/**
* Changes current clipboard item. WITHOUT updating the history.
*/
fun changeCurrent(newData: ClipboardItem, closePrevious: Boolean) {
if (prefHelper.clipboard.enableInternal) {
if (closePrevious) current?.close()
current = newData
val isEqual = when (newData.type) {
ItemType.TEXT -> newData.text == systemClipboardManager.primaryClip?.getItemAt(0)?.text
ItemType.IMAGE -> newData.uri == systemClipboardManager.primaryClip?.getItemAt(0)?.uri
}
if (prefHelper.clipboard.syncToSystem && !isEqual)
systemClipboardManager.setPrimaryClip(newData.toClipData())
} else {
shouldUpdateHistory = false
systemClipboardManager.setPrimaryClip(newData.toClipData())
}
onPrimaryClipChangedListeners.forEach { it.onPrimaryClipChanged() }
}
/**
* Change the current text on clipboard, update history (if enabled).
*
*/
fun addNewClip(newData: ClipboardItem) {
updateHistory(newData)
// If history is disabled, this new item will replace the old one and hence should be closed.
changeCurrent(newData, !prefHelper.clipboard.enableHistory)
}
/**
* Wraps some plaintext in a ClipData and calls [addNewClip]
*/
fun addNewPlaintext(newText: String) {
val newData = ClipboardItem(null, ItemType.TEXT, null, newText, arrayOf("text/plain"))
addNewClip(newData)
}
val primaryClip: ClipboardItem?
get() = if (prefHelper.clipboard.enableInternal) {
current
} else {
systemClipboardManager.primaryClip?.let { ClipboardItem.fromClipData(it, false) }
}
fun peekHistory(index: Int): ClipboardItem? {
return history.getOrNull(index)?.data
}
fun addPrimaryClipChangedListener(listener: OnPrimaryClipChangedListener) {
onPrimaryClipChangedListeners.add(listener)
}
fun removePrimaryClipChangedListener(listener: OnPrimaryClipChangedListener) {
onPrimaryClipChangedListeners.remove(listener)
}
/**
* Called by system clipboard when the contents are changed
*/
override fun onPrimaryClipChanged() {
// Run on async thread to avoid blocking.
if (systemClipboardManager.primaryClip?.getItemAt(0)?.text == null &&
systemClipboardManager.primaryClip?.getItemAt(0)?.uri == null) {
return
}
val isEqual = when (primaryClip?.type) {
ItemType.TEXT -> primaryClip?.text == systemClipboardManager.primaryClip?.getItemAt(0)?.text
ItemType.IMAGE -> primaryClip?.uri == systemClipboardManager.primaryClip?.getItemAt(0)?.uri
null -> false
}
systemClipboardManager.primaryClip?.let {
if (prefHelper.clipboard.enableInternal) {
// In the event that the internal clipboard is enabled, sync to internal clipboard is enabled
// and the item is not already in internal clipboard, add it.
if (prefHelper.clipboard.syncToFloris && !isEqual) {
addNewClip(ClipboardItem.fromClipData(it, true))
}
} else if (prefHelper.clipboard.enableHistory) {
// in the event history is enabled, and it should be updated it is updated
if (shouldUpdateHistory) {
updateHistory(ClipboardItem.fromClipData(it, false))
} else {
shouldUpdateHistory = true
}
}
}
}
fun hasPrimaryClip(): Boolean {
return this.primaryClip != null
}
/**
* Cleans up.
*
* Sets [instance] to null for GC. Unregisters the system clipboard listener, cancels clipboard clean ups.
*/
override fun close() {
systemClipboardManager.removePrimaryClipChangedListener(this)
handler.cancelAll()
instance = null
}
/**
* Initialize the floris clipboard manager. Exists to avoid dependency loop due to reference
* to [FlorisBoard.context]
*
* Sets up the clipboard cleanup task, links the recycler view in clipInputManager to [history].
*
* @param context Required to register as an onPrimaryClipChangedListener of ClipboardManager
*/
fun initialize(context: Context) {
this.systemClipboardManager = (context.getSystemService(CLIPBOARD_SERVICE) as ClipboardManager)
systemClipboardManager.addPrimaryClipChangedListener(this)
prefHelper = PrefHelper.getDefaultInstance(context)
val cleanUpClipboard = Runnable {
if (!prefHelper.clipboard.cleanUpOld) {
return@Runnable
}
val currentTime = System.currentTimeMillis()
var numToPop = 0
val expiryTime = prefHelper.clipboard.cleanUpAfter * 60 * 1000
for (item in history.asReversed()) {
if (item.timeUTC + expiryTime < currentTime) {
numToPop += 1
} else {
break
}
}
for (i in 0 until numToPop) {
history.removeLast().data.close()
}
ClipboardInputManager.getInstance().notifyItemRangeRemoved(pins.size + history.size, numToPop)
}
FlorisBoard.getInstance().clipInputManager.initClipboard(this.history, this.pins)
handler = Handler(Looper.getMainLooper())
prefHelper
handler.postAtScheduledRate(0, INTERVAL, cleanUpClipboard)
executor = FlorisBoard.getInstance().asyncExecutor
executor.execute {
pinsDao = PinnedItemsDatabase.getInstance().clipboardItemDao()
pinsDao.getAll().toCollection(this.pins)
FlorisContentProvider.getInstance().initIfNotAlready()
}
}
/**
* Clears the history with an animation.
*/
fun clearHistoryWithAnimation() {
val clipInputManager = FlorisBoard.getInstance().clipInputManager
val delay = clipInputManager.clearClipboardWithAnimation(pins.size, history.size)
handler.postDelayed({
val size = history.size
for (item in history) {
item.data.close()
}
history.clear()
clipInputManager.notifyItemRangeRemoved(pins.size, size)
}, delay)
}
fun pinClip(adapterPos: Int) {
val clipInputManager = FlorisBoard.getInstance().clipInputManager
val pin = history.removeAt(adapterPos - pins.size)
pins.addFirst(pin.data)
clipInputManager.notifyItemMoved(adapterPos, 0)
clipInputManager.notifyItemChanged(0)
executor.execute {
val uid = pinsDao.insert(pin.data)
pin.data.uid = uid
}
}
/**
* Get the item at a particular [adapterPos] (i.e the position the item is displayed at.)
*/
fun peekHistoryOrPin(adapterPos: Int): ClipboardItem {
return when {
adapterPos < pins.size -> pins[adapterPos]
else -> history[adapterPos - pins.size].data
}
}
fun isPinned(position: Int): Boolean {
return when {
position < pins.size -> true
else -> false
}
}
fun unpinClip(adapterPos: Int) {
val clipInputManager = FlorisBoard.getInstance().clipInputManager
val item = pins.removeAt(adapterPos)
val clipboardPrefs = prefHelper.clipboard
if (clipboardPrefs.limitHistorySize) {
var numRemoved = 0
while (history.size >= clipboardPrefs.maxHistorySize) {
numRemoved += 1
history.removeLast().data.close()
}
ClipboardInputManager.getInstance().notifyItemRangeRemoved(history.size, numRemoved)
}
val timed = TimedClipData(item, System.currentTimeMillis())
history.addFirst(timed)
clipInputManager.notifyItemMoved(adapterPos, pins.size)
clipInputManager.notifyItemChanged(pins.size)
executor.execute {
pinsDao.delete(item)
}
}
fun removeClip(pos: Int) {
when {
pos < pins.size -> {
val item = pins.removeAt(pos)
executor.execute {
Timber.d("removing pin")
pinsDao.delete(item)
}
item.close()
}
else -> {
history.removeAt(pos - pins.size).data.close()
}
}
val clipboardInputManager = ClipboardInputManager.getInstance()
clipboardInputManager.notifyItemRemoved(pos)
}
fun pasteItem(pos: Int) {
val item = peekHistoryOrPin(pos)
FlorisBoard.getInstance().activeEditorInstance.commitClipboardItem(item)
}
/**
* Returns true if the editor can accept the clip item, else false.
*/
fun canBePasted(clipItem: ClipboardItem?): Boolean {
if (clipItem == null) return false
return clipItem.mimeTypes.contains("text/plain") || FlorisBoard.getInstance().activeEditorInstance.contentMimeTypes?.any { editorType ->
clipItem.mimeTypes.any { clipType ->
if (editorType != null) {
compareMimeTypes(clipType, editorType)
}else { false }
}
} == true
}
}

View File

@@ -0,0 +1,75 @@
package dev.patrickgold.florisboard.ime.clip.provider
import android.net.Uri
import dev.patrickgold.florisboard.ime.core.FlorisBoard
import timber.log.Timber
import java.io.File
/**
* Backend class which is used by [FlorisContentProvider] to serve content.
*/
class FileStorage private constructor() {
companion object {
private const val BUF_SIZE = 1024 * 8
private var instance: FileStorage? = null
private var offset = 0
fun getInstance() : FileStorage {
if (this.instance == null){
this.instance = FileStorage()
}
return instance!!
}
}
/**
* Clones a content URI to internal storage.
* @param uri The URI
* @return the file's name which is a unique long
*/
@Synchronized
fun cloneURI(uri: Uri) : Long {
val context = FlorisBoard.getInstance().context
// nanoTime + the number of items created so that it's unique.
val name = (System.nanoTime() + offset)
// Just a normal copy from input stream to output stream.
val source = context.contentResolver.openInputStream(uri)!!
val sink = File(context.filesDir, name.toString()).outputStream()
var nread = 0L
val buf = ByteArray(BUF_SIZE)
var n: Int
while (source.read(buf).also { n = it } > 0) {
sink.write(buf, 0, n)
nread += n.toLong()
}
source.close()
sink.close()
return name
}
/**
* Deletes the file corresponding to an id.
*/
fun deleteById(id: Long) {
Timber.d("Cleaning up $id")
val file = File(FlorisBoard.getInstance().filesDir, id.toString())
file.delete()
}
/**
* Get the file address of an id.
*/
fun getAddress(id: Long): String {
return FlorisBoard.getInstance().filesDir.toString() + "/$id"
}
}

View File

@@ -0,0 +1,132 @@
package dev.patrickgold.florisboard.ime.clip.provider
import android.content.*
import android.database.Cursor
import android.net.Uri
import android.os.ParcelFileDescriptor
import androidx.room.Room
import dev.patrickgold.florisboard.ime.core.FlorisBoard
import timber.log.Timber
import java.io.File
import java.util.concurrent.ExecutorService
/**
* Allows apps to access images on the clipboard.
*
* This is sometimes called by the UI thread, so all functions are non blocking.
* Database accesses are performed async.
*/
class FlorisContentProvider : ContentProvider() {
private lateinit var fileUriDao: FileUriDao
private val mimeTypes: HashMap<Long, Array<String>> = hashMapOf()
private lateinit var executor: ExecutorService
override fun onCreate(): Boolean {
instance = this
return true
}
fun initIfNotAlready(){
if (this::fileUriDao.isInitialized){
return
}
fileUriDao = Room.databaseBuilder(
context!!,
FileUriDatabase::class.java, "fileuridb"
).build().fileUriDao()
executor = FlorisBoard.getInstance().asyncExecutor
for (fileUri in fileUriDao.getAll()) {
mimeTypes[fileUri.fileName] = fileUri.mimeTypes
}
}
override fun query(
uri: Uri,
projection: Array<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?
): Cursor? {
// just return nothing, nothing should call this function at all.
return null
}
override fun getType(uri: Uri): String {
return when (matcher.match(uri)) {
CLIP_ITEM -> mimeTypes.getOrElse(ContentUris.parseId(uri), { throw IllegalArgumentException("Don't have this item!") })[0]
CLIPS_TABLE -> "vnd.android.cursor.dir/$AUTHORITY.clip"
else -> throw IllegalArgumentException("Don't know what this is $uri")
}
}
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor {
val id = ContentUris.parseId(uri)
val path = File(FileStorage.getInstance().getAddress(id))
// Nothing has permission to write anyway.
return ParcelFileDescriptor.open(path, ParcelFileDescriptor.MODE_READ_ONLY)
}
override fun insert(uri: Uri, values: ContentValues?): Uri {
when (matcher.match(uri)){
CLIPS_TABLE -> {
val id = FileStorage.getInstance().cloneURI(Uri.parse(values?.getAsString("uri")))
val mimes = values?.getAsString("mimetypes")?.split(",")?.toTypedArray()
mimes?.let {
mimeTypes[id] = mimes
executor.execute {
Timber.d("Inserted file uri $id")
fileUriDao.insert(FileUri(id, mimes))
}
}
return ContentUris.withAppendedId(CLIPS_URI, id)
}
else -> throw IllegalArgumentException("Don't know what this is $uri")
}
}
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int {
when (matcher.match(uri)){
CLIP_ITEM -> {
val id = ContentUris.parseId(uri)
FileStorage.getInstance().deleteById(id)
mimeTypes.remove(id)
context?.revokeUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
executor.execute {
fileUriDao.delete(id)
}
return 1
}
else -> throw IllegalArgumentException("Don't know what this is $uri")
}
}
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int {
throw IllegalArgumentException("This ContentProvider does not support update.")
}
companion object {
private var instance: FlorisContentProvider? = null
const val AUTHORITY = "dev.patrickgold.florisboard.provider.clip"
val CONTENT_URI: Uri = Uri.parse("content://$AUTHORITY")
val CLIPS_URI: Uri = Uri.parse("content://$AUTHORITY/clips")
fun getInstance(): FlorisContentProvider {
return instance!!
}
private const val CLIPS_TABLE = 1
private const val CLIP_ITEM = 0
val matcher: UriMatcher = UriMatcher(UriMatcher.NO_MATCH).apply {
addURI(AUTHORITY, "clips/#", CLIP_ITEM)
addURI(AUTHORITY, "clips", CLIPS_TABLE)
}
}
}

View File

@@ -0,0 +1,242 @@
package dev.patrickgold.florisboard.ime.clip.provider
import android.content.ClipData
import android.content.ContentValues
import android.net.Uri
import android.provider.BaseColumns
import androidx.room.*
import dev.patrickgold.florisboard.ime.core.FlorisBoard
import java.io.Closeable
enum class ItemType(val value: Int) {
TEXT(1),
IMAGE(2);
companion object {
fun fromInt(value : Int) : ItemType {
return values().first { it.value == value }
}
}
}
/**
* Represents an item on the clipboard.
* The URI stored belongs to FlorisContentProvider, not whatever app copied the image
*
* If type == ItemType.IMAGE there must be a uri set
* if type == ItemType.TEXT there must be a text set
*/
@Entity(tableName = "pins")
data class ClipboardItem(
/** Only used for pins */
@PrimaryKey(autoGenerate = true) @ColumnInfo(name=BaseColumns._ID, index=true) var uid: Long?,
val type: ItemType,
val uri: Uri?,
val text: String?,
val mimeTypes: Array<String>) : Closeable{
/**
* Creates a new ClipData which has the same contents as this.
*/
fun toClipData(): ClipData {
return when (type) {
ItemType.IMAGE -> {
ClipData.newUri(FlorisBoard.getInstance().context.contentResolver, "Clipboard data", uri)
}
ItemType.TEXT -> {
ClipData.newPlainText("Clipboard data", text)
}
}
}
/**
* Instructs the content provider to delete this URI. If not an image, is a noop
*/
override fun close() {
if (type == ItemType.IMAGE) {
FlorisBoard.getInstance().context.contentResolver.delete(this.uri!!, null, null)
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ClipboardItem
if (uid != other.uid) return false
if (type != other.type) return false
if (uri != other.uri) return false
if (text != other.text) return false
if (!mimeTypes.contentEquals(other.mimeTypes)) return false
return true
}
override fun hashCode(): Int {
var result = uid.hashCode()
result = 31 * result + type.hashCode()
result = 31 * result + (uri?.hashCode() ?: 0)
result = 31 * result + (text?.hashCode() ?: 0)
result = 31 * result + mimeTypes.contentHashCode()
return result
}
companion object {
/**
* Returns a new ClipboardItem based on a ClipData
*
* @param data The ClipData to clone.
* @param cloneUri Whether to store the image using [FlorisContentProvider].
*/
fun fromClipData(data: ClipData, cloneUri: Boolean) : ClipboardItem {
val type = when {
data.getItemAt(0)?.uri != null -> ItemType.IMAGE
data.getItemAt(0)?.text != null -> ItemType.TEXT
else -> null
}!!
val uri = if (type == ItemType.IMAGE) {
if (data.getItemAt(0).uri.authority == FlorisContentProvider.CONTENT_URI.authority || !cloneUri){
data.getItemAt(0).uri
}else {
val values = ContentValues().apply{
put("uri", data.getItemAt(0).uri.toString())
put("mimetypes", data.description.filterMimeTypes("*/*").joinToString(","))
}
FlorisBoard.getInstance().context.contentResolver.insert(FlorisContentProvider.CLIPS_URI, values)
}
}else { null }
val text = data.getItemAt(0).text?.toString()
val mimeTypes = Array(data.description.mimeTypeCount) { "" }
(0 until data.description.mimeTypeCount).forEach {
mimeTypes[it] = data.description.getMimeType(it)
}
return ClipboardItem(null, type, uri, text, mimeTypes)
}
}
}
class Converters {
@TypeConverter
fun uriFromString(value: String?): Uri? {
return Uri.parse(value)
}
@TypeConverter
fun stringFromUri(value: Uri?): String {
return value.toString()
}
@TypeConverter
fun itemTypeToInt(value: ItemType?): Int? {
return value?.value
}
@TypeConverter
fun intToItemType(value: Int?): ItemType? {
return value?.let { ItemType.fromInt(it) }
}
/**
* Only works because the string array is a mimetype.
* DOES NOT USE A GENERALIZED FORMAT.
*/
@TypeConverter
fun mimeTypesToString(mimeTypes: Array<String>): String {
return mimeTypes.joinToString(",")
}
@TypeConverter
fun stringToMimeTypes(value: String): Array<String> {
return value.split(",").toTypedArray()
}
}
@Dao
interface PinnedClipboardItemDao {
@Query("SELECT * FROM pins")
fun getAll(): List<ClipboardItem>
@Insert
fun insert(item: ClipboardItem) : Long
@Delete
fun delete(item: ClipboardItem)
}
@Database(entities = [ClipboardItem::class], version = 1)
@TypeConverters(Converters::class)
abstract class PinnedItemsDatabase : RoomDatabase() {
abstract fun clipboardItemDao() : PinnedClipboardItemDao
companion object {
private var instance: PinnedItemsDatabase? = null
fun getInstance(): PinnedItemsDatabase {
if (instance == null) {
instance = Room.databaseBuilder(
FlorisBoard.getInstance().context,
PinnedItemsDatabase::class.java,
"pins").build()
}
return instance!!
}
}
}
@Entity(tableName = "file_uris")
data class FileUri(
@PrimaryKey @ColumnInfo(name=BaseColumns._ID, index=true) val fileName: Long,
val mimeTypes: Array<String>
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as FileUri
if (fileName != other.fileName) return false
if (!mimeTypes.contentEquals(other.mimeTypes)) return false
return true
}
override fun hashCode(): Int {
var result = 31 + fileName.hashCode()
result = 31 * result + mimeTypes.contentHashCode()
return result
}
}
@Dao
interface FileUriDao {
@Query("SELECT * FROM file_uris WHERE ${BaseColumns._ID} == (:uid)")
fun getById(uid: Long) : FileUri
@Query("DELETE FROM file_uris WHERE ${BaseColumns._ID} == (:id)")
fun delete(id: Long)
@Insert
fun insert(vararg fileUris: FileUri)
@Query("SELECT COUNT(*) FROM file_uris WHERE ${BaseColumns._ID} == (:id)")
fun numberWithId(id: Long): Int
@Query("SELECT * FROM file_uris")
fun getAll(): List<FileUri>
}
@Database(entities = [FileUri::class], version = 1)
@TypeConverters(Converters::class)
abstract class FileUriDatabase : RoomDatabase() {
abstract fun fileUriDao() : FileUriDao
}

View File

@@ -16,6 +16,8 @@
package dev.patrickgold.florisboard.ime.core
import android.content.ClipDescription
import android.content.Intent
import android.inputmethodservice.InputMethodService
import android.os.Build
import android.os.SystemClock
@@ -26,6 +28,12 @@ import android.view.KeyEvent
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputConnection
import androidx.annotation.RequiresApi
import androidx.core.view.inputmethod.InputConnectionCompat
import androidx.core.view.inputmethod.InputContentInfoCompat
import dev.patrickgold.florisboard.ime.clip.FlorisClipboardManager
import dev.patrickgold.florisboard.ime.clip.provider.ClipboardItem
import dev.patrickgold.florisboard.ime.clip.provider.ItemType
import timber.log.Timber
/**
* Class which holds information relevant to an editor instance like the [cachedInput], [selection],
@@ -36,10 +44,12 @@ class EditorInstance private constructor(
private val ims: InputMethodService?,
val imeOptions: ImeOptions,
val inputAttributes: InputAttributes,
val packageName: String
val packageName: String,
private val editorInfo: EditorInfo
) {
val cachedInput: CachedInput = CachedInput(this)
var contentMimeTypes: Array<out String?>? = null
private val florisClipboardManager: FlorisClipboardManager = FlorisClipboardManager.getInstance()
val cursorCapsMode: InputAttributes.CapsMode
get() {
val ic = inputConnection ?: return InputAttributes.CapsMode.NONE
@@ -75,7 +85,8 @@ class EditorInstance private constructor(
ims = null,
imeOptions = ImeOptions.fromImeOptionsInt(EditorInfo.IME_NULL),
inputAttributes = InputAttributes.fromInputTypeInt(InputType.TYPE_NULL),
packageName = "undefined"
packageName = "undefined",
editorInfo = EditorInfo()
)
}
@@ -85,7 +96,8 @@ class EditorInstance private constructor(
ims = ims,
imeOptions = ImeOptions.fromImeOptionsInt(editorInfo.imeOptions),
inputAttributes = InputAttributes.fromInputTypeInt(editorInfo.inputType),
packageName = editorInfo.packageName
packageName = editorInfo.packageName,
editorInfo = editorInfo
).apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
contentMimeTypes = editorInfo.contentMimeTypes
@@ -198,6 +210,41 @@ class EditorInstance private constructor(
}
}
/**
* Commits the given [ClipboardItem]. If the clip data is text (incl. HTML), it delegates to [commitText].
* If the item has a content URI (and the EditText supports it), the item is committed as rich data.
* This allows for committing (e.g) images.
*
* @param item The ClipboardItem to commit
* @return True on success, false if something went wrong.
*/
fun commitClipboardItem(item: ClipboardItem): Boolean {
val mimeTypes = item.mimeTypes
return when (item.type){
ItemType.IMAGE -> {
val inputContentInfo = InputContentInfoCompat(
item.uri!!,
ClipDescription("clipboard image", mimeTypes),
null
)
val ic = inputConnection ?: return false
ic.finishComposingText()
var flags = 0
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
flags = flags or InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION
}else {
FlorisBoard.getInstance().context.grantUriPermission(editorInfo.packageName, item.uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
InputConnectionCompat.commitContent(ic, editorInfo, inputContentInfo, flags, null)
}
ItemType.TEXT -> {
commitText(item.text.toString())
}
}
}
/**
* Executes a backward delete on this editor's text. If a text selection is active, all
* characters inside this selection will be removed, else only the left-most character from
@@ -355,15 +402,11 @@ class EditorInstance private constructor(
* @return True on success, false if an error occurred or the input connection is invalid.
*/
fun performClipboardCut(): Boolean {
Timber.d("performClipboardCut")
isPhantomSpaceActive = false
wasPhantomSpaceActiveLastUpdate = false
val ic = inputConnection ?: return false
if (isRawInputEditor) {
sendDownUpKeyEvent(KeyEvent.KEYCODE_X, meta(ctrl = true))
} else {
ic.performContextMenuAction(android.R.id.cut)
}
return true
florisClipboardManager.addNewPlaintext(selection.text)
return sendDownUpKeyEvent(KeyEvent.KEYCODE_DEL)
}
/**
@@ -373,17 +416,11 @@ class EditorInstance private constructor(
* @return True on success, false if an error occurred or the input connection is invalid.
*/
fun performClipboardCopy(): Boolean {
Timber.d("performClipboardCopy")
isPhantomSpaceActive = false
wasPhantomSpaceActiveLastUpdate = false
val ic = inputConnection ?: return false
if (isRawInputEditor) {
sendDownUpKeyEvent(KeyEvent.KEYCODE_C, meta(ctrl = true)) &&
sendDownUpKeyEvent(KeyEvent.KEYCODE_DPAD_RIGHT)
} else {
ic.performContextMenuAction(android.R.id.copy)
selection.updateAndNotify(selection.end, selection.end)
}
return true
florisClipboardManager.addNewPlaintext(selection.text)
return selection.updateAndNotify(selection.end, selection.end)
}
/**
@@ -395,13 +432,8 @@ class EditorInstance private constructor(
fun performClipboardPaste(): Boolean {
isPhantomSpaceActive = false
wasPhantomSpaceActiveLastUpdate = false
val ic = inputConnection ?: return false
if (isRawInputEditor) {
sendDownUpKeyEvent(KeyEvent.KEYCODE_V, meta(ctrl = true))
} else {
ic.performContextMenuAction(android.R.id.paste)
}
return true
Timber.d("Before commit clip data")
return commitClipboardItem(florisClipboardManager.primaryClip!!)
}
/**

View File

@@ -17,7 +17,6 @@
package dev.patrickgold.florisboard.ime.core
import android.annotation.SuppressLint
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
@@ -42,6 +41,8 @@ import androidx.lifecycle.*
import com.squareup.moshi.Json
import dev.patrickgold.florisboard.BuildConfig
import dev.patrickgold.florisboard.R
import dev.patrickgold.florisboard.ime.clip.ClipboardInputManager
import dev.patrickgold.florisboard.ime.clip.FlorisClipboardManager
import dev.patrickgold.florisboard.ime.landscapeinput.LandscapeInputUiMode
import dev.patrickgold.florisboard.ime.media.MediaInputManager
import dev.patrickgold.florisboard.ime.onehanded.OneHandedMode
@@ -58,6 +59,9 @@ import dev.patrickgold.florisboard.util.*
import timber.log.Timber
import java.lang.ref.WeakReference
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.Executor
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
/**
* Variable which holds the current [FlorisBoard] instance. To get this instance from another
@@ -69,7 +73,7 @@ private var florisboardInstance: FlorisBoard? = null
* Core class responsible to link together both the text and media input managers as well as
* managing the one-handed UI.
*/
class FlorisBoard : InputMethodService(), LifecycleOwner, ClipboardManager.OnPrimaryClipChangedListener,
class FlorisBoard : InputMethodService(), LifecycleOwner, FlorisClipboardManager.OnPrimaryClipChangedListener,
ThemeManager.OnThemeUpdatedListener {
lateinit var prefs: PrefHelper
@@ -89,7 +93,7 @@ class FlorisBoard : InputMethodService(), LifecycleOwner, ClipboardManager.OnPri
private var audioManager: AudioManager? = null
var imeManager:InputMethodManager? = null
var clipboardManager: ClipboardManager? = null
var florisClipboardManager: FlorisClipboardManager? = null
private val themeManager: ThemeManager = ThemeManager.default()
private var vibrator: Vibrator? = null
private val osHandler = Handler()
@@ -116,14 +120,20 @@ class FlorisBoard : InputMethodService(), LifecycleOwner, ClipboardManager.OnPri
val textInputManager: TextInputManager
val mediaInputManager: MediaInputManager
val clipInputManager: ClipboardInputManager
var isClipboardContextMenuShown = false
init {
florisboardInstance = this
textInputManager = TextInputManager.getInstance()
mediaInputManager = MediaInputManager.getInstance()
clipInputManager = ClipboardInputManager.getInstance()
}
lateinit var asyncExecutor: ExecutorService
companion object {
private const val IME_ID: String = "dev.patrickgold.florisboard/.ime.core.FlorisBoard"
private const val IME_ID_BETA: String = "dev.patrickgold.florisboard.beta/dev.patrickgold.florisboard.ime.core.FlorisBoard"
@@ -209,13 +219,10 @@ class FlorisBoard : InputMethodService(), LifecycleOwner, ClipboardManager.OnPri
)
}*/
Timber.i("onCreate()")
serviceLifecycleDispatcher.onServicePreSuperOnCreate()
imeManager = getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
audioManager = getSystemService(Context.AUDIO_SERVICE) as? AudioManager
clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
clipboardManager?.addPrimaryClipChangedListener(this)
vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
prefs = PrefHelper.getDefaultInstance(this)
prefs.initDefaultPreferences()
@@ -231,6 +238,11 @@ class FlorisBoard : InputMethodService(), LifecycleOwner, ClipboardManager.OnPri
AppVersionUtils.updateVersionOnInstallAndLastUse(this, prefs)
asyncExecutor = Executors.newSingleThreadExecutor()
florisClipboardManager = FlorisClipboardManager.getInstance()
florisClipboardManager!!.initialize(context)
florisClipboardManager?.addPrimaryClipChangedListener(this)
super.onCreate()
eventListeners.toList().forEach { it?.onCreate() }
}
@@ -282,7 +294,8 @@ class FlorisBoard : InputMethodService(), LifecycleOwner, ClipboardManager.OnPri
Timber.i("onDestroy()")
themeManager.unregisterOnThemeUpdatedListener(this)
clipboardManager?.removePrimaryClipChangedListener(this)
florisClipboardManager!!.removePrimaryClipChangedListener(this)
florisClipboardManager!!.close()
osHandler.removeCallbacksAndMessages(null)
florisboardInstance = null
@@ -340,7 +353,6 @@ class FlorisBoard : InputMethodService(), LifecycleOwner, ClipboardManager.OnPri
override fun onStartInput(attribute: EditorInfo?, restarting: Boolean) {
Timber.i("onStartInput($attribute, $restarting)")
super.onStartInput(attribute, restarting)
currentInputConnection?.requestCursorUpdates(InputConnection.CURSOR_UPDATE_MONITOR)
}
@@ -568,6 +580,7 @@ class FlorisBoard : InputMethodService(), LifecycleOwner, ClipboardManager.OnPri
val inputView = this.inputView ?: return
val inputWindowView = this.inputWindowView ?: return
// TODO: Check also if the keyboard is currently suppressed by a hardware keyboard
if (!isInputViewShown) {
outInsets?.contentTopInsets = inputWindowView.height
outInsets?.visibleTopInsets = inputWindowView.height
@@ -576,6 +589,11 @@ class FlorisBoard : InputMethodService(), LifecycleOwner, ClipboardManager.OnPri
val visibleTopY = inputWindowView.height - inputView.measuredHeight
outInsets?.contentTopInsets = visibleTopY
outInsets?.visibleTopInsets = visibleTopY
if (isClipboardContextMenuShown) {
outInsets?.touchableInsets = Insets.TOUCHABLE_INSETS_FRAME
outInsets?.touchableRegion?.setEmpty()
}
}
/**
@@ -725,6 +743,7 @@ class FlorisBoard : InputMethodService(), LifecycleOwner, ClipboardManager.OnPri
private fun onSubtypeChanged(newSubtype: Subtype) {
textInputManager.onSubtypeChanged(newSubtype)
mediaInputManager.onSubtypeChanged(newSubtype)
clipInputManager.onSubtypeChanged(newSubtype)
}
fun setActiveInput(type: Int) {
@@ -735,6 +754,9 @@ class FlorisBoard : InputMethodService(), LifecycleOwner, ClipboardManager.OnPri
R.id.media_input -> {
inputView?.mainViewFlipper?.displayedChild = 1
}
R.id.clip_input -> {
inputView?.mainViewFlipper?.displayedChild = 2
}
}
}

View File

@@ -53,6 +53,7 @@ class PrefHelper(
val smartbar = Smartbar(this)
val suggestion = Suggestion(this)
val theme = Theme(this)
val clipboard = Clipboard(this)
/**
* Checks the cache if an entry for [key] exists, else calls [getPrefInternal] to retrieve the
@@ -494,4 +495,52 @@ class PrefHelper(
get() = prefHelper.getPref(SUNSET_TIME, TimeUtil.encode(18, 0))
set(v) = prefHelper.setPref(SUNSET_TIME, v)
}
/**
* Wrapper class for clipboard preferences
*/
class Clipboard(private val prefHelper: PrefHelper) {
companion object {
const val ENABLE_INTERNAL = "clipboard__enable_internal"
const val SYNC_TO_SYSTEM = "clipboard__sync_to_system"
const val SYNC_TO_FLORIS = "clipboard__sync_to_floris"
const val ENABLE_HISTORY = "clipboard__enable_history"
const val CLEAN_UP_OLD = "clipboard__clean_up_old"
const val LIMIT_HISTORY_SIZE = "clipboard__limit_history_size"
const val CLEAN_UP_AFTER = "clipboard__clean_up_after"
const val MAX_HISTORY_SIZE = "clipboard__max_history_size"
}
var enableInternal: Boolean
get() = prefHelper.getPref(ENABLE_INTERNAL, false)
set(v) = prefHelper.setPref(ENABLE_INTERNAL, v)
var syncToSystem: Boolean
get() = prefHelper.getPref(SYNC_TO_SYSTEM, false)
set(v) = prefHelper.setPref(SYNC_TO_SYSTEM, v)
var syncToFloris: Boolean
get() = prefHelper.getPref(SYNC_TO_FLORIS, true)
set(v) = prefHelper.setPref(SYNC_TO_FLORIS, v)
var enableHistory: Boolean
get() = prefHelper.getPref(ENABLE_HISTORY, false)
set(v) = prefHelper.setPref(ENABLE_HISTORY, v)
var cleanUpOld: Boolean
get() = prefHelper.getPref(CLEAN_UP_OLD, false)
set(v) = prefHelper.setPref(CLEAN_UP_OLD, v)
var limitHistorySize: Boolean
get() = prefHelper.getPref(LIMIT_HISTORY_SIZE, true)
set(v) = prefHelper.setPref(LIMIT_HISTORY_SIZE, v)
var cleanUpAfter: Int
get() = prefHelper.getPref(CLEAN_UP_AFTER, 20)
set(v) = prefHelper.setPref(CLEAN_UP_AFTER, v)
var maxHistorySize: Int
get() = prefHelper.getPref(MAX_HISTORY_SIZE, 20)
set(v) = prefHelper.setPref(MAX_HISTORY_SIZE, v)
}
}

View File

@@ -176,13 +176,13 @@ class AssetManager private constructor(private val applicationContext: Context)
}
}
fun <T : Asset> loadAsset(uri: Uri, assetClass: KClass<T>): Result<T> {
val rawJsonData = ExternalContentUtils.readTextFromUri(applicationContext, uri).onFailure {
fun <T : Asset> loadAsset(uri: Uri, assetClass: KClass<T>, maxSize: Int): Result<T> {
val rawJsonData = ExternalContentUtils.readTextFromUri(applicationContext, uri, maxSize).getOrElse {
return Result.failure(it)
}
return try {
val adapter = moshi.adapter(assetClass.java)
val asset = adapter.fromJson(rawJsonData.getOrNull()!!)
val asset = adapter.fromJson(rawJsonData)
if (asset != null) {
Result.success(asset)
} else {
@@ -193,7 +193,6 @@ class AssetManager private constructor(private val applicationContext: Context)
}
}
fun loadAssetRaw(ref: AssetRef): Result<String> {
return when (ref.source) {
is AssetSource.Assets -> {

View File

@@ -21,11 +21,16 @@ import android.net.Uri
class ExternalContentUtils private constructor() {
companion object {
fun readTextFromUri(context: Context, uri: Uri): Result<String> {
fun readTextFromUri(context: Context, uri: Uri, maxSize: Int): Result<String> {
val contentResolver = context.contentResolver
?: return Result.failure(NullPointerException("System content resolver not available"))
val inputStream = contentResolver.openInputStream(uri)
?: return Result.failure(NullPointerException("Cannot open input stream for given uri '$uri'"))
val assetFileDescriptor = contentResolver.openAssetFileDescriptor(uri, "r")
?: return Result.failure(NullPointerException("Cannot open asset file descriptor for given uri '$uri'"))
if (assetFileDescriptor.length > maxSize) {
return Result.failure(Exception("Contents of given uri '$uri' exceeds maximum size of $maxSize bytes!"))
}
val rawText = inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }
return Result.success(rawText)
}
@@ -36,7 +41,7 @@ class ExternalContentUtils private constructor() {
// Must use "rwt" mode to ensure destination file length is truncated after writing.
val outputStream = contentResolver.openOutputStream(uri, "rwt")
?: return Result.failure(NullPointerException("Cannot open output stream for given uri '$uri'"))
outputStream.bufferedWriter(Charsets.UTF_8).use { it.flush(); it.write(text) }
outputStream.bufferedWriter(Charsets.UTF_8).use { it.write(text) }
return Result.success(Unit)
}
}

View File

@@ -18,10 +18,15 @@ package dev.patrickgold.florisboard.ime.popup
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Rect
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import dev.patrickgold.florisboard.ime.clip.ClipboardPopupManager
import dev.patrickgold.florisboard.ime.clip.ClipboardPopupView
import timber.log.Timber
/**
* Basic helper view class which acts as a non-interactive layer view, which sits above the whole
@@ -41,12 +46,29 @@ class PopupLayerView : FrameLayout {
)
}
var clipboardPopupManager: ClipboardPopupManager? = null
var intercept: ClipboardPopupView? = null
var shouldIntercept = true
override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
if (ev != null) {
intercept?.run {
val viewRect = Rect()
getGlobalVisibleRect(viewRect)
return when {
!viewRect.contains(ev.x.toInt(), ev.y.toInt()) -> {
clipboardPopupManager?.hide()
true
}
else -> false
}
}
}
return true
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent?): Boolean {
override fun onTouchEvent(ev: MotionEvent?): Boolean {
return false
}
}

View File

@@ -48,7 +48,8 @@ class PopupManager<T_KBD: View, T_KV: View>(
KeyCode.ENTER,
KeyCode.LANGUAGE_SWITCH,
KeyCode.SWITCH_TO_TEXT_CONTEXT,
KeyCode.SWITCH_TO_MEDIA_CONTEXT
KeyCode.SWITCH_TO_MEDIA_CONTEXT,
KeyCode.SWITCH_TO_CLIPBOARD_CONTEXT
)
private var keyPopupWidth: Int
private var keyPopupHeight: Int
@@ -108,6 +109,11 @@ class PopupManager<T_KBD: View, T_KV: View>(
PopupExtendedView.Element.Icon(it, adjustedIndex)
} ?: PopupExtendedView.Element.Undefined
}
KeyCode.SWITCH_TO_CLIPBOARD_CONTEXT -> {
getDrawable(keyView.context, R.drawable.ic_assignment)?.let {
PopupExtendedView.Element.Icon(it, adjustedIndex)
} ?: PopupExtendedView.Element.Undefined
}
KeyCode.URI_COMPONENT_TLD -> {
PopupExtendedView.Element.Tld(
keyView.data.popup[adjustedIndex].label, adjustedIndex

View File

@@ -403,6 +403,7 @@ class TextInputManager private constructor() : CoroutineScope by MainScope(), In
SwipeAction.MOVE_CURSOR_START_OF_PAGE -> KeyData.MOVE_START_OF_PAGE
SwipeAction.MOVE_CURSOR_END_OF_PAGE -> KeyData.MOVE_END_OF_PAGE
SwipeAction.SHIFT -> KeyData.SHIFT
SwipeAction.SWITCH_TO_CLIPBOARD_CONTEXT -> KeyData.SWITCH_TO_CLIPBOARD_CONTEXT
SwipeAction.SHOW_INPUT_METHOD_PICKER -> KeyData.SHOW_INPUT_METHOD_PICKER
else -> null
}
@@ -720,7 +721,9 @@ class TextInputManager private constructor() : CoroutineScope by MainScope(), In
KeyCode.SHIFT_LOCK -> handleShiftLock()
KeyCode.SHOW_INPUT_METHOD_PICKER -> florisboard.imeManager?.showInputMethodPicker()
KeyCode.SWITCH_TO_MEDIA_CONTEXT -> florisboard.setActiveInput(R.id.media_input)
KeyCode.SWITCH_TO_CLIPBOARD_CONTEXT -> florisboard.setActiveInput(R.id.clip_input)
KeyCode.SWITCH_TO_TEXT_CONTEXT -> florisboard.setActiveInput(R.id.text_input)
KeyCode.CLEAR_CLIPBOARD_HISTORY -> florisboard.florisClipboardManager?.clearHistoryWithAnimation()
KeyCode.TOGGLE_ONE_HANDED_MODE_LEFT -> florisboard.toggleOneHandedMode(isRight = false)
KeyCode.TOGGLE_ONE_HANDED_MODE_RIGHT -> florisboard.toggleOneHandedMode(isRight = true)
KeyCode.VIEW_CHARACTERS -> setActiveKeyboardMode(KeyboardMode.CHARACTERS)

View File

@@ -80,7 +80,11 @@ class EditingKeyboardView : ConstraintLayout, FlorisBoard.EventListener,
else -> View.GONE
}
copyKey?.isEnabled = isSelectionActive
pasteKey?.isEnabled = florisboard?.clipboardManager?.hasPrimaryClip() ?: false
pasteKey?.isEnabled =
florisboard?.florisClipboardManager?.hasPrimaryClip() == true &&
florisboard.activeEditorInstance.contentMimeTypes?.any {
florisboard.florisClipboardManager!!.primaryClip?.mimeTypes?.contains(it) ?: false
} == true
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {

View File

@@ -40,6 +40,7 @@ enum class SwipeAction {
SHOW_INPUT_METHOD_PICKER,
SWITCH_TO_PREV_SUBTYPE,
SWITCH_TO_NEXT_SUBTYPE,
SWITCH_TO_CLIPBOARD_CONTEXT,
SWITCH_TO_PREV_KEYBOARD;
companion object {

View File

@@ -65,9 +65,10 @@ object KeyCode {
const val CLIPBOARD_PASTE_POPUP = -133
const val CLIPBOARD_SELECT = -134
const val CLIPBOARD_SELECT_ALL = -135
const val CLEAR_CLIPBOARD_HISTORY = -136
const val UNDO = -136
const val REDO = -137
const val UNDO = -137
const val REDO = -138
const val PHONE_PAUSE = 44
const val PHONE_WAIT = 59

View File

@@ -161,6 +161,12 @@ open class KeyData(
code = KeyCode.SWITCH_TO_TEXT_CONTEXT,
label = "switch_to_text_context"
)
/** Predefined key data for [KeyCode.SWITCH_TO_CLIPBOARD_CONTEXT] */
val SWITCH_TO_CLIPBOARD_CONTEXT = KeyData(
type = KeyType.SYSTEM_GUI,
code = KeyCode.SWITCH_TO_CLIPBOARD_CONTEXT,
label = "switch_to_clipboard_context"
)
/** Predefined key data for [KeyCode.SHIFT] */
val SHIFT = KeyData(

View File

@@ -25,6 +25,7 @@ import android.graphics.Rect
import android.graphics.Typeface
import android.graphics.drawable.Drawable
import android.graphics.drawable.PaintDrawable
import android.opengl.Visibility
import android.os.Handler
import android.view.MotionEvent
import android.view.View
@@ -34,6 +35,7 @@ import androidx.core.content.ContextCompat.getDrawable
import androidx.core.view.children
import com.google.android.flexbox.FlexboxLayout
import dev.patrickgold.florisboard.R
import dev.patrickgold.florisboard.ime.clip.FlorisClipboardManager
import dev.patrickgold.florisboard.ime.core.FlorisBoard
import dev.patrickgold.florisboard.ime.core.ImeOptions
import dev.patrickgold.florisboard.ime.core.InputKeyEvent
@@ -50,6 +52,7 @@ import dev.patrickgold.florisboard.ime.theme.ThemeValue
import dev.patrickgold.florisboard.util.ViewLayoutUtils
import dev.patrickgold.florisboard.util.cancelAll
import dev.patrickgold.florisboard.util.postDelayed
import timber.log.Timber
import java.util.*
import kotlin.math.abs
@@ -194,7 +197,8 @@ class KeyView(
*/
fun getComputedLetter(
keyData: KeyData = data,
caps: Boolean = florisboard?.textInputManager?.caps ?: false,
caps: Boolean = florisboard?.textInputManager?.caps ?: false &&
florisboard?.textInputManager?.getActiveKeyboardMode() == KeyboardMode.CHARACTERS,
subtype: Subtype = florisboard?.activeSubtype ?: Subtype.DEFAULT
): String {
return if (caps && keyData is FlorisKeyData && keyData.shift != null) {
@@ -580,14 +584,27 @@ class KeyView(
isEnabled = when (data.code) {
KeyCode.CLIPBOARD_COPY,
KeyCode.CLIPBOARD_CUT -> (florisboard != null
&& florisboard.activeEditorInstance.selection.isSelectionMode
&& !florisboard.activeEditorInstance.isRawInputEditor)
KeyCode.CLIPBOARD_PASTE -> florisboard?.clipboardManager?.hasPrimaryClip() == true
&& florisboard.activeEditorInstance.selection.isSelectionMode
&& !florisboard.activeEditorInstance.isRawInputEditor)
KeyCode.CLIPBOARD_PASTE -> (
// such gore. checks
// 1. has a clipboard item
// 2. the clipboard item has any of the supported mime types of the editor OR is plain text.
florisboard?.florisClipboardManager?.canBePasted(florisboard.florisClipboardManager?.primaryClip)
) == true
KeyCode.CLIPBOARD_SELECT_ALL -> {
florisboard?.activeEditorInstance?.isRawInputEditor == false
}
KeyCode.SWITCH_TO_CLIPBOARD_CONTEXT -> {
visibility = when (prefs.clipboard.enableHistory ) {
true -> VISIBLE
false -> GONE
}
prefs.clipboard.enableHistory
}
else -> true
}
if (data.code == KeyCode.CLIPBOARD_PASTE)
if (!isEnabled) {
isKeyPressed = false
}
@@ -720,6 +737,12 @@ class KeyView(
}
}
}
KeyCode.SWITCH_TO_CLIPBOARD_CONTEXT -> {
when (prefs.clipboard.enableHistory) {
true -> VISIBLE
false -> GONE
}
}
KeyCode.LANGUAGE_SWITCH -> {
val tempUtilityKeyAction = when {
prefs.keyboard.utilityKeyEnabled -> prefs.keyboard.utilityKeyAction
@@ -819,6 +842,9 @@ class KeyView(
KeyCode.CLIPBOARD_SELECT_ALL -> {
drawable = getDrawable(context, R.drawable.ic_select_all)
}
KeyCode.SWITCH_TO_CLIPBOARD_CONTEXT -> {
drawable = getDrawable(context, R.drawable.ic_assignment)
}
KeyCode.DELETE -> {
drawable = getDrawable(context, R.drawable.ic_backspace)
}
@@ -867,6 +893,9 @@ class KeyView(
KeyCode.SWITCH_TO_MEDIA_CONTEXT -> {
drawable = getDrawable(context, R.drawable.ic_sentiment_satisfied)
}
KeyCode.SWITCH_TO_CLIPBOARD_CONTEXT -> {
drawable = getDrawable(context, R.drawable.ic_assignment)
}
KeyCode.SWITCH_TO_TEXT_CONTEXT,
KeyCode.VIEW_CHARACTERS -> {
label = resources.getString(R.string.key__view_characters)

View File

@@ -301,9 +301,14 @@ class SmartbarView : ConstraintLayout, ThemeManager.OnThemeUpdatedListener {
fun onPrimaryClipChanged() {
if (prefs.suggestion.enabled && prefs.suggestion.suggestClipboardContent &&
florisboard?.activeEditorInstance?.isPrivateMode == false) {
shouldSuggestClipboardContents = true
val item = florisboard.clipboardManager?.primaryClip?.getItemAt(0)
florisboard?.activeEditorInstance?.isPrivateMode == false ) {
// only suggest if mime types make sense.
shouldSuggestClipboardContents = florisboard.florisClipboardManager?.canBePasted(
florisboard.florisClipboardManager?.primaryClip
) == true
val item = florisboard.florisClipboardManager?.primaryClip
when {
item?.text != null -> {
binding.clipboardSuggestion.text = item.text

View File

@@ -58,10 +58,10 @@ class ThemeManager private constructor(
private set
companion object {
/**
* The static relative path where a theme is located, regardless of the [AssetSource].
*/
/** The static relative path where a theme is located, regardless of the [AssetSource]. */
const val THEME_PATH_REL: String = "ime/theme"
/** Maximum size in bytes a theme file may have when loaded. */
const val THEME_MAX_SIZE: Int = 512_000
private var defaultInstance: ThemeManager? = null
@@ -94,7 +94,7 @@ class ThemeManager private constructor(
}
/**
* Updates the current theme ref and loads the corresponding theme, as well as notfies all
* Updates the current theme ref and loads the corresponding theme, as well as notifies all
* callback receivers about the new theme.
*/
fun update() {
@@ -254,25 +254,21 @@ class ThemeManager private constructor(
}
fun loadTheme(ref: AssetRef): Result<Theme> {
assetManager.loadAsset(ref, ThemeJson::class).onSuccess { themeJson ->
val theme = themeJson.toTheme()
return Result.success(theme)
}.onFailure {
val themeJson = assetManager.loadAsset(ref, ThemeJson::class).getOrElse {
Timber.e(it.toString())
return Result.failure(it)
}
return Result.failure(Exception("Unreachable code"))
val theme = themeJson.toTheme()
return Result.success(theme)
}
fun loadTheme(uri: Uri): Result<Theme> {
assetManager.loadAsset(uri, ThemeJson::class).onSuccess { themeJson ->
val theme = themeJson.toTheme()
return Result.success(theme)
}.onFailure {
val themeJson = assetManager.loadAsset(uri, ThemeJson::class, THEME_MAX_SIZE).getOrElse {
Timber.e(it.toString())
return Result.failure(it)
}
return Result.failure(Exception("Unreachable code"))
val theme = themeJson.toTheme()
return Result.success(theme)
}
fun writeTheme(ref: AssetRef, theme: Theme): Result<Unit> {

View File

@@ -67,7 +67,7 @@ class ThemeManagerActivity : FlorisActivity<ThemeManagerActivityBinding>() {
}
private val importTheme = registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
// If uri is null it indicates that the selection activity was cancelled (mostly by pressing the back button,
// If uri is null it indicates that the selection activity was cancelled (mostly by pressing the back button),
// so we don't display an error message here.
if (uri == null) return@registerForActivityResult
val toBeImportedTheme = themeManager.loadTheme(uri)

View File

@@ -33,7 +33,7 @@ class EnableImeFragment : Fragment() {
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
): View {
binding = SetupFragmentEnableImeBinding.inflate(inflater, container, false)
binding.languageAndInputButton.setOnClickListener {
val intent = Intent()

View File

@@ -30,7 +30,7 @@ class FinishFragment : Fragment() {
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
): View {
binding = SetupFragmentFinishBinding.inflate(inflater, container, false)
// Set theme to floris_day

View File

@@ -35,7 +35,7 @@ object LocaleUtils {
}
}
class JsonAdapter() {
class JsonAdapter {
@FromJson
fun fromJson(raw: String): Locale {
return stringToLocale(raw)

View File

@@ -0,0 +1,5 @@
<vector android:autoMirrored="true" android:height="24dp"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="?android:attr/textColorPrimary" android:pathData="M7 15h7v2H7zm0-4h10v2H7zm0-4h10v2H7zm12-4h-4.18C14.4 1.84 13.3 1 12 1c-1.3 0-2.4.84-2.82 2H5c-.14 0-.27.01-.4.04-.39.08-.74.28-1.01.55-.18.18-.33.4-.43.64-.1.23-.16.49-.16.77v14c0 .27.06.54.16.78s.25.45.43.64c.27.27.62.47 1.01.55.13.02.26.03.4.03h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7-.25c.41 0 .75.34.75.75s-.34.75-.75.75-.75-.34-.75-.75.34-.75.75-.75zM19 19H5V5h14v14z"/>
</vector>

View File

@@ -0,0 +1,5 @@
<vector android:autoMirrored="true" android:height="24dp"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="?android:attr/textColorPrimary" android:pathData="M5 13h14v-2H5v2zm-2 4h14v-2H3v2zM7 7v2h14V7H7z"/>
</vector>

View File

@@ -0,0 +1,6 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp"
android:viewportWidth="24" android:viewportHeight="24"
android:autoMirrored="true">
<path android:fillColor="?android:attr/textColorPrimary" android:pathData="M14,4v5c0,1.12,0.37,2.16,1,3H9c0.65-0.86,1-1.9,1-3V4H14 M17,2H7C6.45,2,6,2.45,6,3c0,0.55,0.45,1,1,1c0,0,0,0,0,0l1,0v5 c0,1.66-1.34,3-3,3v2h5.97v7l1,1l1-1v-7H19v-2c0,0,0,0,0,0c-1.66,0-3-1.34-3-3V4l1,0c0,0,0,0,0,0c0.55,0,1-0.45,1-1 C18,2.45,17.55,2,17,2L17,2z"/>
</vector>

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Bottom 2dp Shadow -->
<item>
<shape android:shape="rectangle">
<solid android:color="@color/cardview_shadow_start_color" />
<corners android:radius="7dp" />
</shape>
</item>
<!-- White Top color -->
<item android:right="3px" android:bottom="5px">
<shape android:shape="rectangle">
<corners android:radius="7dp" />
</shape>
</item>
</layer-list>

View File

@@ -0,0 +1,105 @@
<?xml version="1.0" encoding="utf-8"?>
<dev.patrickgold.florisboard.ime.clip.ClipboardPopupView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="10dp"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:id="@+id/pin_clip_item"
android:orientation="horizontal"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/pin_clip_item_icon"
android:layout_width="wrap_content"
android:scaleX="0.8"
android:scaleY="0.8"
android:layout_height="wrap_content"
android:src="@drawable/ic_pin"
android:contentDescription="@string/clip__pin_item" />
<Space
android:layout_width="10dp"
android:layout_height="0dp"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/clip__pin_item"
android:id="@+id/pin_clip_item_text"
tools:layout_editor_absoluteY="3dp" />
</LinearLayout>
<Space
android:layout_width="0dp"
android:layout_height="10dp"/>
<LinearLayout
android:layout_width="match_parent"
android:id="@+id/remove_from_history"
android:orientation="horizontal"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/remove_from_history_icon"
android:layout_width="wrap_content"
android:scaleX="0.8"
android:scaleY="0.8"
android:layout_height="wrap_content"
android:src="@drawable/ic_delete"
android:contentDescription="@string/clip__delete_item" />
<Space
android:layout_width="10dp"
android:layout_height="0dp"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/clip__delete_item"
android:id="@+id/remove_from_history_text"
tools:layout_editor_absoluteY="3dp" />
</LinearLayout>
<Space
android:id="@+id/paste_clip_item_space"
android:layout_width="0dp"
android:layout_height="10dp"/>
<LinearLayout
android:layout_width="match_parent"
android:id="@+id/paste_clip_item"
android:orientation="horizontal"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/paste_clip_item_icon"
android:layout_width="wrap_content"
android:scaleX="0.8"
android:scaleY="0.8"
android:layout_height="wrap_content"
android:src="@drawable/ic_content_paste"
android:contentDescription="@string/clip__paste_item" />
<Space
android:layout_width="10dp"
android:layout_height="0dp"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/clip__paste_item"
android:id="@+id/paste_clip_item_text"
tools:layout_editor_absoluteY="3dp" />
</LinearLayout>
</dev.patrickgold.florisboard.ime.clip.ClipboardPopupView>

View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<dev.patrickgold.florisboard.ime.clip.ClipboardHistoryItemView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="5dp"
android:padding="0dp"
android:background="@drawable/shape_rect_rounded_3"
android:foreground="?selectableItemBackground"
>
<ImageView
android:id="@+id/clipboard_history_item_img"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:adjustViewBounds="true"
android:layout_marginEnd="0dp"
android:padding="0dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:background="@drawable/shape_rect_rounded_3"
tools:ignore="ContentDescription" />
<ImageView
android:id="@+id/clipboard_pin"
android:layout_width="15dp"
android:layout_height="15dp"
android:layout_marginEnd="5dp"
android:layout_marginTop="5dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:src="@drawable/ic_pin"
android:contentDescription="@string/clip__pin_item"/>
</dev.patrickgold.florisboard.ime.clip.ClipboardHistoryItemView>

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<dev.patrickgold.florisboard.ime.clip.ClipboardHistoryItemView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="5dp"
android:padding="5dp"
android:foreground="?selectableItemBackground"
android:background="@drawable/shape_rect_rounded_3"
>
<TextView
android:id="@+id/clipboard_history_item_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginEnd="25dp"
android:padding="6dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="A clipboard item would go here." />
<ImageView
android:id="@+id/clipboard_pin"
android:layout_width="15dp"
android:layout_height="15dp"
android:layout_marginEnd="5dp"
android:layout_marginTop="5dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:src="@drawable/ic_pin"
android:contentDescription="@string/clip__pin_item"/>
</dev.patrickgold.florisboard.ime.clip.ClipboardHistoryItemView>

View File

@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<dev.patrickgold.florisboard.ime.clip.ClipboardHistoryView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/clip_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:id="@+id/clipboard_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<ImageButton
android:id="@+id/back_to_keyboard_button"
android:layout_width="@dimen/clipboard_button_width"
android:layout_height="@dimen/clipboard_button_height"
android:layout_gravity="center_vertical"
android:background="@drawable/button_transparent_bg_on_press"
android:hapticFeedbackEnabled="false"
android:soundEffectsEnabled="false"
android:layout_weight="0"
android:src="@drawable/ic_arrow_back"
android:contentDescription="@string/clip__back_to_text_input" />
<TextView
android:id="@+id/clipboard_text"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:gravity="center_vertical"
android:layout_weight="1"
android:text="@string/clip__context_header"
android:textSize="15sp" />
<ImageButton
android:id="@+id/clear_clipboard_history"
android:layout_width="@dimen/clipboard_button_width"
android:layout_height="@dimen/clipboard_button_height"
android:layout_gravity="center_vertical"
android:background="@drawable/button_transparent_bg_on_press"
android:hapticFeedbackEnabled="false"
android:soundEffectsEnabled="false"
android:layout_weight="0"
android:src="@drawable/ic_clear_all"
android:contentDescription="@string/clip__clear_history" />
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/clipboard_history_items"
android:paddingLeft="5dp"
android:paddingRight="5dp"
/>
</LinearLayout>
</dev.patrickgold.florisboard.ime.clip.ClipboardHistoryView>

View File

@@ -57,6 +57,8 @@
<include layout="@layout/media_input_layout"/>
<include layout="@layout/clipboard_layout"/>
</dev.patrickgold.florisboard.ime.core.FlorisViewFlipper>
<dev.patrickgold.florisboard.ime.onehanded.OneHandedPanel

View File

@@ -98,6 +98,7 @@
<item>@string/pref__gestures__swipe_action__switch_to_prev_subtype</item>
<item>@string/pref__gestures__swipe_action__switch_to_next_subtype</item>
<item>@string/pref__gestures__swipe_action__switch_to_prev_keyboard</item>
<item>@string/pref__gestures__swipe_action__switch_to_clipboard_context</item>
</string-array>
<string-array name="pref__gestures__swipe_action__values">
<item>no_action</item>
@@ -117,6 +118,7 @@
<item>switch_to_prev_subtype</item>
<item>switch_to_next_subtype</item>
<item>switch_to_prev_keyboard</item>
<item>switch_to_clipboard_context</item>
</string-array>
<string-array name="pref__gestures__swipe_action_delete__entries">

View File

@@ -38,6 +38,9 @@
<dimen name="media_tab_indicator_height">4dp</dimen>
<dimen name="media_tab_paddingH">0dp</dimen>
<dimen name="clipboard_button_width">60dp</dimen>
<dimen name="clipboard_button_height">@dimen/key_height</dimen>
<dimen name="one_handed_width">50dp</dimen>
<dimen name="one_handed_button_width">@dimen/one_handed_width</dimen>
<dimen name="one_handed_button_height">@dimen/one_handed_width</dimen>
@@ -54,6 +57,7 @@
<dimen name="gesture_distance_threshold_normal">32dp</dimen>
<dimen name="gesture_distance_threshold_long">36dp</dimen>
<dimen name="gesture_distance_threshold_very_long">40dp</dimen>
<dimen name="clipboard_text_item_pin_margin" >25dp</dimen>
<integer name="gesture_velocity_threshold_very_slow">50</integer>
<integer name="gesture_velocity_threshold_slow">60</integer>

View File

@@ -360,4 +360,15 @@
<string name="crash_once_notification__body" comment="Body of the notification for a single crash">Tap to view error details</string>
<string name="crash_multiple_notification__title" comment="Title of the notification for consecutive crashes">FlorisBoard seems to stop working repeatedly…</string>
<string name="crash_multiple_notification__body" comment="Body of the notification for consecutive crashes">Falling back to previous keyboard to stop infinite crash loop. Tap to view error details</string>
<!-- clipboard strings -->
<string name="pref__gestures__swipe_action__switch_to_clipboard_context">Switch to clipboard history</string>
<string name="clip__context_header">Clipboard history</string>
<string name="clip__clear_history">Clear history</string>
<string name="clip__unpin_item">Unpin item</string>
<string name="clip__pin_item">Pin item</string>
<string name="clip__delete_item">Delete</string>
<string name="clip__paste_item">Paste</string>
<string name="clip__back_to_text_input">Back to text input</string>
<string name="clip__cant_paste">This app doesn\'t allow pasting this content.</string>
</resources>

View File

@@ -234,4 +234,78 @@
</PreferenceCategory>
<PreferenceCategory
app:iconSpaceReserved="false"
android:title="Clipboard">
<SwitchPreferenceCompat
android:defaultValue="true"
app:key="clipboard__enable_internal"
app:iconSpaceReserved="false"
app:title="Use internal clipboard"
app:summary="Use an internal clipboard instead of the system clipboard"/>
<SwitchPreferenceCompat
android:dependency="clipboard__enable_internal"
android:defaultValue="true"
app:key="clipboard__sync_to_floris"
app:iconSpaceReserved="false"
app:title="Sync from system clipboard"
app:summary="System clipboard updates also update Floris clipboard"/>
<SwitchPreferenceCompat
android:dependency="clipboard__enable_internal"
android:defaultValue="false"
app:key="clipboard__sync_to_system"
app:iconSpaceReserved="false"
app:title="Sync to system clipboard"
app:summary="Floris clipboard updates also update system clipboard"/>
<SwitchPreferenceCompat
android:defaultValue="false"
app:key="clipboard__enable_history"
app:iconSpaceReserved="false"
app:title="Enable clipboard history"
app:summary="Retain clipboard items"/>
<SwitchPreferenceCompat
android:dependency="clipboard__enable_history"
android:defaultValue="true"
app:key="clipboard__clean_up_old"
app:iconSpaceReserved="false"
app:title="Clean up old items" />
<dev.patrickgold.florisboard.settings.components.DialogSeekBarPreference
app:allowDividerAbove="false"
android:dependency="clipboard__clean_up_old"
android:defaultValue="15"
app:key="clipboard__clean_up_after"
app:min="0"
app:max="120"
app:iconSpaceReserved="false"
app:title="Clean up old items after"
app:seekBarIncrement="5"
app:unit=" minutes"/>
<SwitchPreferenceCompat
android:dependency="clipboard__enable_history"
android:defaultValue="true"
app:key="clipboard__limit_history_size"
app:iconSpaceReserved="false"
app:title="Limit history size" />
<dev.patrickgold.florisboard.settings.components.DialogSeekBarPreference
app:allowDividerAbove="false"
android:dependency="clipboard__limit_history_size"
android:defaultValue="25"
app:key="clipboard__max_history_size"
app:min="5"
app:max="100"
app:iconSpaceReserved="false"
app:title="Max history size"
app:seekBarIncrement="5"
app:unit=" items"/>
</PreferenceCategory>
</PreferenceScreen>

View File

@@ -26,7 +26,7 @@ class NgramNodeTest {
@Test
fun findWord_returnsCorrectNode_forExistingWord() {
val expected = ngramTreeToBeTested.higherOrderChildren['t']?.sameOrderChildren?.get('h')?.sameOrderChildren?.get('e')
val expected = ngramTreeToBeTested.higherOrderChildren['t'].sameOrderChildren.get('h').sameOrderChildren.get('e')
val actual = ngramTreeToBeTested.findWord("the")
assertThat(actual, `is`(expected))
}
@@ -50,7 +50,7 @@ class NgramNodeTest {
@Test
fun listAllSameOrderWords_returnsCorrectList_forGivenPrefix() {
val words = StagedSuggestionList<String, Int>(4)
ngramTreeToBeTested.higherOrderChildren['t']?.listAllSameOrderWords(words, true)
ngramTreeToBeTested.higherOrderChildren['t'].listAllSameOrderWords(words, true)
assertThat(
words,
`is`(StagedSuggestionList<String, Int>(4).apply {
@@ -63,5 +63,4 @@ class NgramNodeTest {
}
}
class FlorisLanguageModelTest {
}
class FlorisLanguageModelTest

View File

@@ -15,4 +15,5 @@
<li>Customizable actions for gestures: swipe up/down/left/right, space bar left/right, delete key swipe)</li>
<li>Integrated special symbols into character layouts</li>
<li>Clipboard/Cursor toolbar</li>
<li>Clipboard manager/history</li>
</ul>

View File

@@ -0,0 +1,8 @@
- Add clipboard manager / history (#454, thanks @X-yl)
- Manage your clipboard entries in a modern and organized UI
- Automatic clearing of items after specified time
- Pin items to the top
- Enable/disable sync with Android clipboard
- Support for copy/paste of images (does not work in all apps though)
- Fix import theme crash for big files (#465)
- Fix symbols layouts applying the caps state once again (#298)

View File

@@ -0,0 +1,19 @@
<p><i>FlorisBoard</i> is an open-source keyboard aimed at providing you with an easy way to type while respecting your privacy.</p>
<p><b>Note:</b> This project is currently in early-beta stage. If you want to see a feature being implemented or want to report a bug, please visit this project's repository (linked in the end of the description) on GitHub and file an issue. This helps making FlorisBoard even better! Thank you!</p>
<p><b>Currently implemented and fully working features:</b></p>
<ul>
<li>Huge variety of Latin keyboard layouts</li>
<li>Limited support for non-Latin keyboard layouts (Arabic, Persian and Hebrew currently, more are planned)</li>
<li>Easy switching between languages/layouts by defining subtypes in the settings</li>
<li>Full theme customization + theme presets for day/night themes</li>
<li>Automatic day/night theme switching</li>
<li>Keyboard layouts for typing in a (phone) number</li>
<li>Special characters input</li>
<li>Emoji/Emoticon keyboard</li>
<li>One-handed/compact mode for easier typing on large devices</li>
<li>Customization of key press sound/vibration</li>
<li>Customizable actions for gestures: swipe up/down/left/right, space bar left/right, delete key swipe)</li>
<li>Integrated special symbols into character layouts</li>
<li>Clipboard/Cursor toolbar</li>
<li>Clipboard manager/history</li>
</ul>

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

View File

@@ -0,0 +1 @@
Beta track of FlorisBoard, the open-source keyboard which respects your privacy.

View File

@@ -0,0 +1 @@
FlorisBoard Beta