Feature/copy paste inside anytype (#473)

This commit is contained in:
Evgenii Kozlov 2020-06-01 17:04:43 +03:00 committed by GitHub
parent 267e5e1b0b
commit 98367451ff
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
78 changed files with 1474 additions and 294 deletions

View File

@ -4,7 +4,8 @@
### New features 🚀
*
* Select text and copy-paste inside Anytype. First iteration (#467)
* Copy and paste multiple blocks in multi-select mode. First iteration (#467)
### Design & UX 🔳
@ -12,7 +13,8 @@
### Fixes & tech 🚒
*
* Resolve race conditions on split and merge (#463, #448)
* Turn-into code block in edit-mode and multi-select mode does not work (#468)
### Middleware ⚙️

View File

@ -79,6 +79,7 @@ dependencies {
implementation project(':persistence')
implementation project(':middleware')
implementation project(':presentation')
implementation project(':clipboard')
implementation project(':core-utils')
implementation project(':core-ui')
implementation project(':library-kanban-widget')

View File

@ -5,6 +5,9 @@ import com.agileburo.anytype.core_utils.di.scope.PerScreen
import com.agileburo.anytype.core_utils.tools.Counter
import com.agileburo.anytype.domain.block.interactor.*
import com.agileburo.anytype.domain.block.repo.BlockRepository
import com.agileburo.anytype.domain.clipboard.Clipboard
import com.agileburo.anytype.domain.clipboard.Copy
import com.agileburo.anytype.domain.clipboard.Paste
import com.agileburo.anytype.domain.download.DownloadFile
import com.agileburo.anytype.domain.download.Downloader
import com.agileburo.anytype.domain.event.interactor.EventChannel
@ -323,7 +326,8 @@ class PageModule {
updateAlignment: UpdateAlignment,
textInteractor: Interactor.TextInteractor,
setupBookmark: SetupBookmark,
paste: Clipboard.Paste,
copy: Copy,
paste: Paste,
undo: Undo,
redo: Redo
): Orchestrator = Orchestrator(
@ -348,7 +352,8 @@ class PageModule {
updateText = updateText,
updateAlignment = updateAlignment,
setupBookmark = setupBookmark,
paste = paste
paste = paste,
copy = copy
)
@Provides
@ -390,8 +395,22 @@ class PageModule {
@Provides
@PerScreen
fun provideClipboardPasteUseCase(
repo: BlockRepository
) : Clipboard.Paste = Clipboard.Paste(
repo = repo
repo: BlockRepository,
clipboard: Clipboard,
matcher: Clipboard.UriMatcher
) : Paste = Paste(
repo = repo,
clipboard = clipboard,
matcher = matcher
)
@Provides
@PerScreen
fun provideCopyUseCase(
repo: BlockRepository,
clipboard: Clipboard
) : Copy = Copy(
repo = repo,
clipboard = clipboard
)
}

View File

@ -0,0 +1,63 @@
package com.agileburo.anytype.di.main
import android.content.ClipboardManager
import android.content.Context
import android.content.Context.CLIPBOARD_SERVICE
import com.agileburo.anytype.clipboard.AnytypeClipboard
import com.agileburo.anytype.clipboard.AnytypeClipboardStorage
import com.agileburo.anytype.clipboard.AnytypeUriMatcher
import com.agileburo.anytype.data.auth.mapper.Serializer
import com.agileburo.anytype.data.auth.other.ClipboardDataUriMatcher
import com.agileburo.anytype.data.auth.repo.clipboard.ClipboardDataRepository
import com.agileburo.anytype.data.auth.repo.clipboard.ClipboardDataStore
import com.agileburo.anytype.domain.clipboard.Clipboard
import com.agileburo.anytype.middleware.converters.ClipboardSerializer
import dagger.Module
import dagger.Provides
import javax.inject.Singleton
@Module
class ClipboardModule {
@Provides
@Singleton
fun provideClipboardRepository(
factory: ClipboardDataStore.Factory
) : Clipboard = ClipboardDataRepository(factory)
@Provides
@Singleton
fun provideClipboardDataStoreFactory(
storage: ClipboardDataStore.Storage,
system: ClipboardDataStore.System
) : ClipboardDataStore.Factory = ClipboardDataStore.Factory(storage, system)
@Provides
@Singleton
fun provideClipboardStorage(
context: Context,
serializer: Serializer
) : ClipboardDataStore.Storage = AnytypeClipboardStorage(context, serializer)
@Provides
@Singleton
fun provideClipboardSystem(
cm: ClipboardManager
) : ClipboardDataStore.System = AnytypeClipboard(cm)
@Provides
@Singleton
fun provideClipboardManager(
context: Context
) : ClipboardManager = context.getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
@Provides
@Singleton
fun provideUriMatcher() : Clipboard.UriMatcher = ClipboardDataUriMatcher(
matcher = AnytypeUriMatcher()
)
@Provides
@Singleton
fun provideSerializer() : Serializer = ClipboardSerializer()
}

View File

@ -4,7 +4,7 @@ import android.content.Context
import com.agileburo.anytype.data.auth.other.DataDownloader
import com.agileburo.anytype.data.auth.other.Device
import com.agileburo.anytype.device.base.AndroidDevice
import com.agileburo.anytype.device.download.DeviceDownloader
import com.agileburo.anytype.device.download.AndroidDeviceDownloader
import com.agileburo.anytype.domain.download.Downloader
import dagger.Module
import dagger.Provides
@ -22,13 +22,13 @@ class DeviceModule {
@Provides
@Singleton
fun provideDevice(
downloader: DeviceDownloader
downloader: AndroidDeviceDownloader
): Device = AndroidDevice(downloader = downloader)
@Provides
@Singleton
fun provideDeviceDownloader(
context: Context
): DeviceDownloader = DeviceDownloader(context = context)
): AndroidDeviceDownloader = AndroidDeviceDownloader(context = context)
}

View File

@ -13,7 +13,8 @@ import javax.inject.Singleton
ConfigModule::class,
DeviceModule::class,
UtilModule::class,
EmojiModule::class
EmojiModule::class,
ClipboardModule::class
]
)
interface MainComponent {

View File

@ -30,6 +30,7 @@ import com.agileburo.anytype.core_ui.menu.DocumentPopUpMenu
import com.agileburo.anytype.core_ui.model.UiBlock
import com.agileburo.anytype.core_ui.reactive.clicks
import com.agileburo.anytype.core_ui.state.ControlPanelState
import com.agileburo.anytype.core_ui.tools.ClipboardInterceptor
import com.agileburo.anytype.core_ui.tools.FirstItemInvisibilityDetector
import com.agileburo.anytype.core_ui.tools.OutsideClickDetector
import com.agileburo.anytype.core_ui.widgets.ActionItemType
@ -70,6 +71,7 @@ open class PageFragment :
OnFragmentInteractionListener,
AddBlockFragment.AddBlockActionReceiver,
TurnIntoActionReceiver,
ClipboardInterceptor,
PickiTCallbacks {
private val vm by lazy {
@ -139,22 +141,7 @@ open class PageFragment :
onLongClickListener = vm::onBlockLongPressedClicked,
onTitleTextInputClicked = vm::onTitleTextInputClicked,
onClickListener = vm::onClickListener,
clipboardDetector = { range ->
// TODO this logic should be moved to device module
clipboard().primaryClip?.let { clip ->
if (clip.itemCount > 0) {
val item = clip.getItemAt(0)
vm.onPaste(
plain = item.text.toString(),
html = if (item.htmlText != null)
item.htmlText
else
null,
range = range
)
}
}
}
clipboardInterceptor = this
)
}
@ -330,6 +317,11 @@ open class PageFragment :
.onEach { vm.onMultiSelectModeDeleteClicked() }
.launchIn(lifecycleScope)
bottomMenu
.copyClicks()
.onEach { vm.onMultiSelectCopyClicked() }
.launchIn(lifecycleScope)
bottomMenu
.turnIntoClicks()
.onEach { vm.onMultiSelectTurnIntoButtonClicked() }
@ -641,6 +633,13 @@ open class PageFragment :
}
}
override fun onClipboardAction(action: ClipboardInterceptor.Action) {
when(action) {
is ClipboardInterceptor.Action.Copy -> vm.onCopy(action.selection)
is ClipboardInterceptor.Action.Paste -> vm.onPaste(action.selection)
}
}
private fun showSelectButton() {
ObjectAnimator.ofFloat(
select,

View File

@ -24,6 +24,7 @@
app:layout_behavior="@string/bottom_sheet_behavior">
<FrameLayout
android:background="?attr/selectableItemBackgroundBorderless"
android:id="@+id/select"
android:layout_width="wrap_content"
android:layout_height="48dp"

1
clipboard/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

62
clipboard/build.gradle Normal file
View File

@ -0,0 +1,62 @@
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
android {
def config = rootProject.extensions.getByName("ext")
compileSdkVersion config["compile_sdk"]
defaultConfig {
minSdkVersion config["min_sdk"]
targetSdkVersion config["target_sdk"]
versionCode config["version_code"]
versionName config["version_name"]
testInstrumentationRunner config["test_runner"]
}
buildTypes {
debug {
buildConfigField ANYTYPE_CLIPBOARD_URI_TYPE, ANYTYPE_CLIPBOARD_URI, ANYTYPE_CLIPBOARD_URI_VALUE
buildConfigField ANYTYPE_CLIPBOARD_LABEL_TYPE, ANYTYPE_CLIPBOARD_LABEL, ANYTYPE_CLIPBOARD_LABEL_VALUE
}
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
buildConfigField ANYTYPE_CLIPBOARD_URI_TYPE, ANYTYPE_CLIPBOARD_URI, ANYTYPE_CLIPBOARD_URI_VALUE
buildConfigField ANYTYPE_CLIPBOARD_LABEL_TYPE, ANYTYPE_CLIPBOARD_LABEL, ANYTYPE_CLIPBOARD_LABEL_VALUE
}
}
testOptions {
unitTests {
includeAndroidResources = true
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
}
dependencies {
def applicationDependencies = rootProject.ext.mainApplication
def unitTestDependencies = rootProject.ext.unitTesting
implementation project(':data')
implementation applicationDependencies.kotlin
implementation applicationDependencies.coroutines
implementation applicationDependencies.timber
testImplementation unitTestDependencies.junit
testImplementation unitTestDependencies.kotlinTest
testImplementation unitTestDependencies.androidXTestCore
testImplementation unitTestDependencies.robolectric
}

View File

View File

@ -0,0 +1,6 @@
ANYTYPE_CLIPBOARD_LABEL=ANYTYPE_CLIPBOARD_LABEL
ANYTYPE_CLIPBOARD_LABEL_VALUE="Anytype"
ANYTYPE_CLIPBOARD_LABEL_TYPE=String
ANYTYPE_CLIPBOARD_URI=ANYTYPE_CLIPBOARD_URI
ANYTYPE_CLIPBOARD_URI_VALUE="content://com.agileburo.anytype"
ANYTYPE_CLIPBOARD_URI_TYPE=String

21
clipboard/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@ -0,0 +1 @@
<manifest package="com.agileburo.anytype.clipboard" />

View File

@ -0,0 +1,56 @@
package com.agileburo.anytype.clipboard
import android.content.ClipData
import android.content.ClipboardManager
import android.net.Uri
import com.agileburo.anytype.clipboard.BuildConfig.ANYTYPE_CLIPBOARD_LABEL
import com.agileburo.anytype.clipboard.BuildConfig.ANYTYPE_CLIPBOARD_URI
import com.agileburo.anytype.data.auth.model.ClipEntity
import com.agileburo.anytype.data.auth.repo.clipboard.ClipboardDataStore
class AnytypeClipboard(
private val cm: ClipboardManager
) : ClipboardDataStore.System {
override suspend fun put(text: String, html: String?) {
val uri = Uri.parse(ANYTYPE_CLIPBOARD_URI)
if (html != null)
cm.setPrimaryClip(
ClipData.newHtmlText(ANYTYPE_CLIPBOARD_LABEL, text, html).apply {
addItem(ClipData.Item(uri))
}
)
else
cm.setPrimaryClip(
ClipData.newPlainText(ANYTYPE_CLIPBOARD_LABEL, text).apply {
addItem(ClipData.Item(uri))
}
)
}
override suspend fun clip(): ClipEntity? {
return cm.primaryClip?.let { clip ->
when {
clip.itemCount > 1 -> {
ClipEntity(
text = clip.getItemAt(0).text.toString(),
html = clip.getItemAt(0).htmlText,
uri = clip.getItemAt(1).uri.toString()
)
}
clip.itemCount == 1 -> {
ClipEntity(
text = clip.getItemAt(0).text.toString(),
html = clip.getItemAt(0).htmlText,
uri = null
)
}
else -> {
null
}
}
}
}
}

View File

@ -0,0 +1,30 @@
package com.agileburo.anytype.clipboard
import android.content.Context
import com.agileburo.anytype.data.auth.mapper.Serializer
import com.agileburo.anytype.data.auth.model.BlockEntity
import com.agileburo.anytype.data.auth.repo.clipboard.ClipboardDataStore
class AnytypeClipboardStorage(
private val context: Context,
private val serializer: Serializer
) : ClipboardDataStore.Storage {
override fun persist(blocks: List<BlockEntity>) {
val serialized = serializer.serialize(blocks)
context.openFileOutput(CLIPBOARD_FILE_NAME, Context.MODE_PRIVATE).use {
it.write(serialized)
it.flush()
}
}
override fun fetch(): List<BlockEntity> {
val stream = context.openFileInput(CLIPBOARD_FILE_NAME)
val blob = stream.use { it.readBytes() }
return serializer.deserialize(blob)
}
companion object {
const val CLIPBOARD_FILE_NAME = "anytype_clipboard"
}
}

View File

@ -0,0 +1,9 @@
package com.agileburo.anytype.clipboard
import com.agileburo.anytype.data.auth.other.ClipboardUriMatcher
class AnytypeUriMatcher : ClipboardUriMatcher {
override fun isAnytypeUri(uri: String): Boolean {
return uri == BuildConfig.ANYTYPE_CLIPBOARD_URI
}
}

View File

@ -0,0 +1,77 @@
package com.agileburo.anytype.clipboard
import android.content.ClipboardManager
import android.content.Context
import android.os.Build
import androidx.test.core.app.ApplicationProvider
import kotlinx.coroutines.runBlocking
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
@Config(sdk = [Build.VERSION_CODES.P])
@RunWith(RobolectricTestRunner::class)
class AndroidClipboardTest {
private lateinit var clipboard : AnytypeClipboard
private lateinit var cm: ClipboardManager
@Before
fun setup() {
val context = ApplicationProvider.getApplicationContext<Context>()
cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard = AnytypeClipboard(cm = cm)
}
@Test
fun `should put only text and uri`() {
val text = MockDataFactory.randomString()
runBlocking {
clipboard.put(
text = text,
html = null
)
}
assertEquals(
expected = text,
actual = cm.primaryClip?.getItemAt(0)?.text
)
assertNull(cm.primaryClip?.getItemAt(0)?.htmlText)
assertNotNull(cm.primaryClip?.getItemAt(1)?.uri)
}
@Test
fun `should put text, html as first item and uri as second item`() {
val text = MockDataFactory.randomString()
val html = MockDataFactory.randomString()
runBlocking {
clipboard.put(
text = text,
html = html
)
}
assertEquals(
expected = text,
actual = cm.primaryClip?.getItemAt(0)?.text
)
assertEquals(
expected = html,
actual = cm.primaryClip?.getItemAt(0)?.htmlText
)
assertNull(cm.primaryClip?.getItemAt(0)?.uri)
assertNotNull(cm.primaryClip?.getItemAt(1)?.uri)
}
}

View File

@ -0,0 +1,63 @@
package com.agileburo.anytype.clipboard
import java.util.*
import java.util.concurrent.ThreadLocalRandom
object MockDataFactory {
fun randomUuid(): String {
return UUID.randomUUID().toString()
}
fun randomString(): String {
return randomUuid()
}
fun randomInt(): Int {
return ThreadLocalRandom.current().nextInt(0, 1000 + 1)
}
fun randomInt(max: Int): Int {
return ThreadLocalRandom.current().nextInt(0, max)
}
fun randomLong(): Long {
return randomInt().toLong()
}
fun randomFloat(): Float {
return randomInt().toFloat()
}
fun randomDouble(): Double {
return randomInt().toDouble()
}
fun randomBoolean(): Boolean {
return Math.random() < 0.5
}
fun makeIntList(count: Int): List<Int> {
val items = mutableListOf<Int>()
repeat(count) {
items.add(randomInt())
}
return items
}
fun makeStringList(count: Int): List<String> {
val items = mutableListOf<String>()
repeat(count) {
items.add(randomUuid())
}
return items
}
fun makeDoubleList(count: Int): List<Double> {
val items = mutableListOf<Double>()
repeat(count) {
items.add(randomDouble())
}
return items
}
}

View File

@ -62,4 +62,5 @@ dependencies {
testImplementation unitTestDependencies.kotlinTest
testImplementation unitTestDependencies.robolectric
testImplementation unitTestDependencies.androidXTestCore
testImplementation unitTestDependencies.mockitoKotlin
}

View File

@ -38,6 +38,7 @@ import com.agileburo.anytype.core_ui.features.page.BlockViewHolder.Companion.HOL
import com.agileburo.anytype.core_ui.features.page.BlockViewHolder.Companion.HOLDER_VIDEO_ERROR
import com.agileburo.anytype.core_ui.features.page.BlockViewHolder.Companion.HOLDER_VIDEO_PLACEHOLDER
import com.agileburo.anytype.core_ui.features.page.BlockViewHolder.Companion.HOLDER_VIDEO_UPLOAD
import com.agileburo.anytype.core_ui.tools.ClipboardInterceptor
import com.agileburo.anytype.core_utils.ext.typeOf
import timber.log.Timber
@ -72,7 +73,7 @@ class BlockAdapter(
private val onToggleClicked: (String) -> Unit,
private val onMarkupActionClicked: (Markup.Type) -> Unit,
private val onLongClickListener: (String) -> Unit,
private val clipboardDetector: (IntRange) -> Unit
private val clipboardInterceptor: ClipboardInterceptor
) : RecyclerView.Adapter<BlockViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BlockViewHolder {
@ -844,9 +845,7 @@ class BlockAdapter(
else
holder.setOnClickListener { onTextInputClicked(blocks[holder.adapterPosition].id) }
holder.content.clipboardDetector = {
clipboardDetector(holder.content.selectionStart..holder.content.selectionEnd)
}
holder.content.clipboardInterceptor = clipboardInterceptor
}
}

View File

@ -0,0 +1,11 @@
package com.agileburo.anytype.core_ui.tools
interface ClipboardInterceptor {
fun onClipboardAction(action: Action)
sealed class Action {
data class Copy(val selection: IntRange) : Action()
data class Paste(val selection: IntRange) : Action()
}
}

View File

@ -13,6 +13,7 @@ import android.view.inputmethod.EditorInfo
import androidx.appcompat.widget.AppCompatEditText
import androidx.core.graphics.withTranslation
import com.agileburo.anytype.core_ui.extensions.toast
import com.agileburo.anytype.core_ui.tools.ClipboardInterceptor
import com.agileburo.anytype.core_ui.tools.DefaultTextWatcher
import com.agileburo.anytype.core_ui.widgets.text.highlight.HighlightAttributeReader
import com.agileburo.anytype.core_ui.widgets.text.highlight.HighlightDrawer
@ -28,10 +29,12 @@ class TextInputWidget : AppCompatEditText {
}
private val watchers: MutableList<TextWatcher> = mutableListOf()
private var highlightDrawer: HighlightDrawer? = null
var selectionDetector: ((IntRange) -> Unit)? = null
var clipboardDetector: (() -> Unit)? = null
var clipboardInterceptor: ClipboardInterceptor? = null
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
@ -121,19 +124,40 @@ class TextInputWidget : AppCompatEditText {
}
override fun onTextContextMenuItem(id: Int): Boolean {
var consumed = true
if (clipboardInterceptor == null) {
return super.onTextContextMenuItem(id)
}
var consumed = false
when(id) {
R.id.paste -> {
clipboardDetector?.invoke()
}
R.id.cut -> {
consumed = super.onTextContextMenuItem(id)
if (clipboardInterceptor != null) {
clipboardInterceptor?.onClipboardAction(
ClipboardInterceptor.Action.Paste(
selection = selectionStart..selectionEnd
)
)
consumed = true
}
}
R.id.copy -> {
consumed = super.onTextContextMenuItem(id)
if (clipboardInterceptor != null) {
clipboardInterceptor?.onClipboardAction(
ClipboardInterceptor.Action.Copy(
selection = selectionStart..selectionEnd
)
)
consumed = true
}
}
}
return consumed
return if (!consumed) {
super.onTextContextMenuItem(id)
} else {
consumed
}
}
override fun onDraw(canvas: Canvas?) {

View File

@ -39,6 +39,9 @@ class MultiSelectBottomToolbarWidget : ConstraintLayout {
fun deleteClicks() = delete.clicks()
fun turnIntoClicks() = convert.clicks()
// Temporary button usage for copying.
fun copyClicks() = more.clicks()
fun showWithAnimation() {
ObjectAnimator.ofFloat(this, ANIMATED_PROPERTY, 0f).apply {
duration = ANIMATION_DURATION

View File

@ -3,10 +3,10 @@
xmlns:app="http://schemas.android.com/apk/res-auto">
<FrameLayout
android:background="?attr/selectableItemBackgroundBorderless"
android:id="@+id/delete"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:paddingStart="20dp"
android:paddingEnd="10dp"
app:layout_constraintBottom_toBottomOf="parent"
@ -30,6 +30,7 @@
android:id="@+id/convert"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:paddingStart="10dp"
android:paddingEnd="10dp"
app:layout_constraintBottom_toBottomOf="parent"
@ -52,6 +53,7 @@
style="@style/BottomMultiSelectToolbarOptionStyle"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:paddingStart="10dp"
android:paddingEnd="10dp"
app:layout_constraintBottom_toBottomOf="parent"
@ -63,7 +65,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@string/more" />
android:text="@string/copy" />
</FrameLayout>
@ -88,12 +90,12 @@
</FrameLayout>
<FrameLayout
android:visibility="invisible"
android:id="@+id/title"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:visibility="invisible"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">

View File

@ -197,5 +197,6 @@
<string name="undo">Undo</string>
<string name="redo">Redo</string>
<string name="copy">Copy</string>
</resources>

View File

@ -23,9 +23,11 @@ import com.agileburo.anytype.core_ui.features.page.BlockViewDiffUtil.Companion.T
import com.agileburo.anytype.core_ui.features.page.BlockViewDiffUtil.Companion.TEXT_COLOR_CHANGED
import com.agileburo.anytype.core_ui.features.page.BlockViewHolder
import com.agileburo.anytype.core_ui.features.page.BlockViewHolder.Companion.FOCUS_TIMEOUT_MILLIS
import com.agileburo.anytype.core_ui.tools.ClipboardInterceptor
import com.agileburo.anytype.core_ui.widgets.text.TextInputWidget.Companion.TEXT_INPUT_WIDGET_INPUT_TYPE
import com.agileburo.anytype.core_utils.ext.dimen
import com.agileburo.anytype.core_utils.ext.hexColorCode
import com.nhaarman.mockitokotlin2.mock
import kotlinx.android.synthetic.main.item_block_bookmark_placeholder.view.*
import kotlinx.android.synthetic.main.item_block_checkbox.view.*
import kotlinx.android.synthetic.main.item_block_page.view.*
@ -44,6 +46,8 @@ class BlockAdapterTest {
private val context: Context = ApplicationProvider.getApplicationContext()
private val clipboardInterceptor : ClipboardInterceptor = mock()
@Test
fun `should return transparent hex code when int color value is zero`() {
@ -3263,7 +3267,7 @@ class BlockAdapterTest {
onLongClickListener = {},
onTitleTextInputClicked = {},
onClickListener = {},
clipboardDetector = {}
clipboardInterceptor = clipboardInterceptor
)
}
}

View File

@ -3,10 +3,11 @@ package com.agileburo.anytype.data.auth.mapper
import com.agileburo.anytype.data.auth.model.*
import com.agileburo.anytype.domain.auth.model.Account
import com.agileburo.anytype.domain.auth.model.Wallet
import com.agileburo.anytype.domain.block.interactor.Clipboard
import com.agileburo.anytype.domain.block.model.Block
import com.agileburo.anytype.domain.block.model.Command
import com.agileburo.anytype.domain.block.model.Position
import com.agileburo.anytype.domain.clipboard.Copy
import com.agileburo.anytype.domain.clipboard.Paste
import com.agileburo.anytype.domain.config.Config
import com.agileburo.anytype.domain.event.model.Event
import com.agileburo.anytype.domain.event.model.Payload
@ -49,10 +50,8 @@ fun BlockEntity.Details.toDomain(): Block.Details = Block.Details(
fun BlockEntity.Content.toDomain(): Block.Content = when (this) {
is BlockEntity.Content.Text -> toDomain()
is BlockEntity.Content.Dashboard -> toDomain()
is BlockEntity.Content.Page -> toDomain()
is BlockEntity.Content.Layout -> toDomain()
is BlockEntity.Content.Image -> toDomain()
is BlockEntity.Content.Link -> toDomain()
is BlockEntity.Content.Divider -> toDomain()
is BlockEntity.Content.File -> toDomain()
@ -116,12 +115,6 @@ fun BlockEntity.Content.Text.toDomain(): Block.Content.Text {
)
}
fun BlockEntity.Content.Dashboard.toDomain(): Block.Content.Dashboard {
return Block.Content.Dashboard(
type = Block.Content.Dashboard.Type.valueOf(type.name)
)
}
fun BlockEntity.Content.Page.toDomain(): Block.Content.Page {
return Block.Content.Page(
style = Block.Content.Page.Style.valueOf(style.name)
@ -148,12 +141,6 @@ fun Block.Content.Layout.toEntity(): BlockEntity.Content.Layout {
)
}
fun BlockEntity.Content.Image.toDomain(): Block.Content.Image {
return Block.Content.Image(
path = path
)
}
fun BlockEntity.Content.Divider.toDomain() = Block.Content.Divider
@ -161,12 +148,6 @@ fun BlockEntity.Content.Smart.toDomain() = Block.Content.Smart(
type = Block.Content.Smart.Type.valueOf(type.name)
)
fun Block.Content.Image.toEntity(): BlockEntity.Content.Image {
return BlockEntity.Content.Image(
path = path
)
}
fun BlockEntity.Content.Text.Mark.toDomain(): Block.Content.Text.Mark {
return Block.Content.Text.Mark(
range = range,
@ -186,10 +167,8 @@ fun Block.toEntity(): BlockEntity {
fun Block.Content.toEntity(): BlockEntity.Content = when (this) {
is Block.Content.Text -> toEntity()
is Block.Content.Dashboard -> toEntity()
is Block.Content.Page -> toEntity()
is Block.Content.Layout -> toEntity()
is Block.Content.Image -> toEntity()
is Block.Content.Link -> toEntity()
is Block.Content.Divider -> toEntity()
is Block.Content.File -> toEntity()
@ -249,12 +228,6 @@ fun Block.Content.Text.toEntity(): BlockEntity.Content.Text {
)
}
fun Block.Content.Dashboard.toEntity(): BlockEntity.Content.Dashboard {
return BlockEntity.Content.Dashboard(
type = BlockEntity.Content.Dashboard.Type.valueOf(type.name)
)
}
fun Block.Content.Page.toEntity(): BlockEntity.Content.Page {
return BlockEntity.Content.Page(
style = BlockEntity.Content.Page.Style.valueOf(style.name)
@ -404,6 +377,12 @@ fun Command.Paste.toEntity() = CommandEntity.Paste(
range = range
)
fun Command.Copy.toEntity() = CommandEntity.Copy(
context = context,
blocks = blocks.map { it.toEntity() },
range = range
)
fun Command.CreateDocument.toEntity() = CommandEntity.CreateDocument(
context = context,
target = target,
@ -573,8 +552,14 @@ fun BlockEntity.Align.toDomain(): Block.Align = when (this) {
BlockEntity.Align.AlignRight -> Block.Align.AlignRight
}
fun Response.Clipboard.Paste.toDomain() = Clipboard.Paste.Response(
fun Response.Clipboard.Paste.toDomain() = Paste.Response(
blocks = blocks,
cursor = cursor,
payload = payload.toDomain()
)
fun Response.Clipboard.Copy.toDomain() = Copy.Response(
text = plain,
html = html,
blocks = blocks.map { it.toDomain() }
)

View File

@ -0,0 +1,8 @@
package com.agileburo.anytype.data.auth.mapper
import com.agileburo.anytype.data.auth.model.BlockEntity
interface Serializer {
fun serialize(blocks: List<BlockEntity>) : ByteArray
fun deserialize(blob: ByteArray) : List<BlockEntity>
}

View File

@ -56,18 +56,10 @@ data class BlockEntity(
enum class Type { ROW, COLUMN, DIV }
}
data class Image(
val path: String
) : Content()
data class Icon(
val name: String
) : Content()
data class Dashboard(val type: Type) : Content() {
enum class Type { MAIN_SCREEN, ARCHIVE }
}
data class Page(val style: Style) : Content() {
enum class Style { EMPTY, TASK, SET }
}

View File

@ -0,0 +1,12 @@
package com.agileburo.anytype.data.auth.model
import com.agileburo.anytype.domain.clipboard.Clip
/**
* @see Clip
*/
class ClipEntity(
override val text: String,
override val html: String?,
override val uri: String?
) : Clip

View File

@ -133,4 +133,10 @@ class CommandEntity {
val html: String?,
val blocks: List<BlockEntity>
)
data class Copy(
val context: String,
val range: IntRange?,
val blocks: List<BlockEntity>
)
}

View File

@ -2,10 +2,16 @@ package com.agileburo.anytype.data.auth.model
sealed class Response {
sealed class Clipboard : Response() {
data class Paste(
class Paste(
val cursor: Int,
val blocks: List<String>,
val payload: PayloadEntity
) : Clipboard()
class Copy(
val plain: String,
val html: String?,
val blocks: List<BlockEntity>
) : Clipboard()
}
}

View File

@ -0,0 +1,12 @@
package com.agileburo.anytype.data.auth.other
import com.agileburo.anytype.domain.clipboard.Clipboard
class ClipboardDataUriMatcher(
private val matcher: ClipboardUriMatcher
) : Clipboard.UriMatcher {
override fun isAnytypeUri(
uri: String
): Boolean = matcher.isAnytypeUri(uri)
}

View File

@ -0,0 +1,5 @@
package com.agileburo.anytype.data.auth.other
interface ClipboardUriMatcher {
fun isAnytypeUri(uri: String) : Boolean
}

View File

@ -2,9 +2,10 @@ package com.agileburo.anytype.data.auth.repo.block
import com.agileburo.anytype.data.auth.mapper.toDomain
import com.agileburo.anytype.data.auth.mapper.toEntity
import com.agileburo.anytype.domain.block.interactor.Clipboard
import com.agileburo.anytype.domain.block.model.Command
import com.agileburo.anytype.domain.block.repo.BlockRepository
import com.agileburo.anytype.domain.clipboard.Copy
import com.agileburo.anytype.domain.clipboard.Paste
import com.agileburo.anytype.domain.common.Id
import com.agileburo.anytype.domain.event.model.Payload
@ -126,5 +127,9 @@ class BlockDataRepository(
override suspend fun paste(
command: Command.Paste
): Clipboard.Paste.Response = factory.remote.paste(command.toEntity()).toDomain()
): Paste.Response = factory.remote.paste(command.toEntity()).toDomain()
override suspend fun copy(
command: Command.Copy
): Copy.Response = factory.remote.copy(command.toEntity()).toDomain()
}

View File

@ -40,4 +40,5 @@ interface BlockDataStore {
suspend fun redo(command: CommandEntity.Redo) : PayloadEntity
suspend fun archiveDocument(command: CommandEntity.ArchiveDocument)
suspend fun paste(command: CommandEntity.Paste) : Response.Clipboard.Paste
suspend fun copy(command: CommandEntity.Copy) : Response.Clipboard.Copy
}

View File

@ -40,4 +40,5 @@ interface BlockRemote {
suspend fun redo(command: CommandEntity.Redo) : PayloadEntity
suspend fun archiveDocument(command: CommandEntity.ArchiveDocument)
suspend fun paste(command: CommandEntity.Paste) : Response.Clipboard.Paste
suspend fun copy(command: CommandEntity.Copy) : Response.Clipboard.Copy
}

View File

@ -109,4 +109,8 @@ class BlockRemoteDataStore(private val remote: BlockRemote) : BlockDataStore {
override suspend fun paste(
command: CommandEntity.Paste
): Response.Clipboard.Paste = remote.paste(command)
override suspend fun copy(
command: CommandEntity.Copy
): Response.Clipboard.Copy = remote.copy(command)
}

View File

@ -0,0 +1,28 @@
package com.agileburo.anytype.data.auth.repo.clipboard
import com.agileburo.anytype.data.auth.mapper.toDomain
import com.agileburo.anytype.data.auth.mapper.toEntity
import com.agileburo.anytype.domain.block.model.Block
import com.agileburo.anytype.domain.clipboard.Clip
import com.agileburo.anytype.domain.clipboard.Clipboard
class ClipboardDataRepository(
private val factory: ClipboardDataStore.Factory
) : Clipboard {
override suspend fun put(text: String, html: String?, blocks: List<Block>) {
factory.storage.persist(
blocks = blocks.map { it.toEntity() }
)
factory.system.put(
text = text,
html = html
)
}
override suspend fun blocks(): List<Block> {
return factory.storage.fetch().map { it.toDomain() }
}
override suspend fun clip(): Clip? = factory.system.clip()
}

View File

@ -0,0 +1,35 @@
package com.agileburo.anytype.data.auth.repo.clipboard
import com.agileburo.anytype.data.auth.model.BlockEntity
import com.agileburo.anytype.data.auth.model.ClipEntity
interface ClipboardDataStore {
/**
* Stores last copied Anytype blocks.
* @see ClipEntity
*/
interface Storage {
fun persist(blocks: List<BlockEntity>)
fun fetch() : List<BlockEntity>
}
/**
* Provides access to system clipboard.
*/
interface System {
/**
* Stores copied [text] and optionally a [html] representation on the systen clipboard.
*/
suspend fun put(text: String, html: String?)
/**
* @return current clip on the clipboard.
*/
suspend fun clip() : ClipEntity?
}
class Factory(
val storage: Storage,
val system: System
)
}

View File

@ -53,4 +53,6 @@ dependencies {
testImplementation unitTestDependencies.junit
testImplementation unitTestDependencies.kotlinTest
testImplementation unitTestDependencies.androidXTestCore
testImplementation unitTestDependencies.robolectric
}

View File

@ -1,10 +1,11 @@
package com.agileburo.anytype.device.base
import com.agileburo.anytype.data.auth.other.Device
import com.agileburo.anytype.device.download.DeviceDownloader
class AndroidDevice(private val downloader: DeviceDownloader) : Device {
import com.agileburo.anytype.device.download.AndroidDeviceDownloader
class AndroidDevice(
private val downloader: AndroidDeviceDownloader
) : Device {
override fun download(url: String, name: String) {
downloader.download(url = url, name = name)
}

View File

@ -8,7 +8,7 @@ import android.net.Uri
import android.os.Environment.DIRECTORY_DOWNLOADS
import timber.log.Timber
class DeviceDownloader(private val context: Context) {
class AndroidDeviceDownloader(private val context: Context) {
private val manager by lazy {
context.getSystemService(DOWNLOAD_SERVICE) as DownloadManager

View File

@ -1,65 +0,0 @@
package com.agileburo.anytype.domain.block.interactor
import com.agileburo.anytype.domain.base.BaseUseCase
import com.agileburo.anytype.domain.block.model.Block
import com.agileburo.anytype.domain.block.model.Command
import com.agileburo.anytype.domain.block.repo.BlockRepository
import com.agileburo.anytype.domain.common.Id
import com.agileburo.anytype.domain.event.model.Payload
interface Clipboard {
/**
* Use-case for pasting to Anytype clipboard.
*/
class Paste(
private val repo: BlockRepository
) : BaseUseCase<Paste.Response, Paste.Params>(), Clipboard {
override suspend fun run(params: Params) = safe {
repo.paste(
command = Command.Paste(
context = params.context,
focus = params.focus,
selected = params.selected,
range = params.range,
text = params.text,
html = params.html,
blocks = params.blocks
)
)
}
/**
* Params for pasting to Anytype clipboard
* @property context id of the context
* @property focus id of the focused/target block
* @property selected id of currently selected blocks
* @property range selected text range
* @property text plain text to paste
* @property html optional html to paste
* @property blocks blocks currently contained in clipboard
*/
data class Params(
val context: Id,
val focus: Id,
val selected: List<Id>,
val range: IntRange,
val text: String,
val html: String?,
val blocks: List<Block>
)
/**
* Response for [Clipboard.Paste] use-case.
* @param cursor caret position
* @param blocks ids of the new blocks
* @param payload response payload
*/
data class Response(
val cursor: Int,
val blocks: List<Id>,
val payload: Payload
)
}
}

View File

@ -53,9 +53,6 @@ data class Block(
fun asText() = this as Text
fun asLink() = this as Link
fun asDashboard() = this as Dashboard
fun asDivider() = this as Divider
fun asFile() = this as File
/**
* Smart block.
@ -135,14 +132,6 @@ data class Block(
enum class Type { ROW, COLUMN, DIV }
}
data class Image(
val path: String
) : Content()
data class Dashboard(val type: Type) : Content() {
enum class Type { MAIN_SCREEN, ARCHIVE }
}
data class Page(val style: Style) : Content() {
enum class Style { EMPTY, TASK, SET }
}

View File

@ -231,7 +231,7 @@ sealed class Command {
data class Redo(val context: Id)
/**
* Params for clipboard pasting operation
* Command for clipboard paste operation
* @property context id of the context
* @property focus id of the focused/target block
* @property selected id of currently selected blocks
@ -249,4 +249,16 @@ sealed class Command {
val html: String?,
val blocks: List<Block>
)
/**
* Command for clipboard copy operation.
* @param context id of the context
* @param range selected text range
* @param blocks associated blocks
*/
data class Copy(
val context: Id,
val range: IntRange?,
val blocks: List<Block>
)
}

View File

@ -1,12 +1,14 @@
package com.agileburo.anytype.domain.block.repo
import com.agileburo.anytype.domain.block.interactor.Clipboard
import com.agileburo.anytype.domain.block.model.Command
import com.agileburo.anytype.domain.clipboard.Copy
import com.agileburo.anytype.domain.clipboard.Paste
import com.agileburo.anytype.domain.common.Id
import com.agileburo.anytype.domain.config.Config
import com.agileburo.anytype.domain.event.model.Payload
interface BlockRepository {
suspend fun dnd(command: Command.Dnd)
suspend fun unlink(command: Command.Unlink): Payload
@ -79,5 +81,6 @@ interface BlockRepository {
suspend fun undo(command: Command.Undo) : Payload
suspend fun redo(command: Command.Redo) : Payload
suspend fun paste(command: Command.Paste) : Clipboard.Paste.Response
suspend fun copy(command: Command.Copy) : Copy.Response
suspend fun paste(command: Command.Paste) : Paste.Response
}

View File

@ -0,0 +1,13 @@
package com.agileburo.anytype.domain.clipboard
/**
* A clip on the clipboard.
* @property text plain text
* @property html html representation
* @property uri uri for the copied content (Anytype URI or an external app URI)
*/
interface Clip {
val text: String
val html: String?
val uri: String?
}

View File

@ -0,0 +1,29 @@
package com.agileburo.anytype.domain.clipboard
import com.agileburo.anytype.domain.block.model.Block
interface Clipboard {
/**
* @param text plain text to put on the clipboard
* @param html optional html to put on the clipboard
* @param blocks Anytype blocks to store on the clipboard)
*/
suspend fun put(text: String, html: String?, blocks: List<Block>)
/**
* @return Anytype blocks currently stored on (or linked to) the clipboard
*/
suspend fun blocks() : List<Block>
/**
* @return return current clip on the clipboard
*/
suspend fun clip() : Clip?
interface UriMatcher {
/**
* Checks whether this [uri] is internal Anytype clipboard URI.
*/
fun isAnytypeUri(uri: String) : Boolean
}
}

View File

@ -0,0 +1,51 @@
package com.agileburo.anytype.domain.clipboard
import com.agileburo.anytype.domain.base.BaseUseCase
import com.agileburo.anytype.domain.block.model.Block
import com.agileburo.anytype.domain.block.model.Command
import com.agileburo.anytype.domain.block.repo.BlockRepository
import com.agileburo.anytype.domain.common.Id
class Copy(
private val repo: BlockRepository,
private val clipboard: Clipboard
) : BaseUseCase<Unit, Copy.Params>() {
override suspend fun run(params: Params) = safe {
val result = repo.copy(
command = Command.Copy(
context = params.context,
range = params.range,
blocks = params.blocks
)
)
clipboard.put(
text = result.text,
html = result.html,
blocks = result.blocks
)
}
/**
* Params for clipboard paste operation.
* @param context id of the context
* @param range selected text range
* @param blocks associated blocks
*/
data class Params(
val context: Id,
val range: IntRange?,
val blocks: List<Block>
)
/**
* @param text plain text
* @param html optional html
* @param blocks anytype clipboard slot
*/
class Response(
val text: String,
val html: String?,
val blocks: List<Block>
)
}

View File

@ -0,0 +1,69 @@
package com.agileburo.anytype.domain.clipboard
import com.agileburo.anytype.domain.base.BaseUseCase
import com.agileburo.anytype.domain.block.model.Command
import com.agileburo.anytype.domain.block.repo.BlockRepository
import com.agileburo.anytype.domain.common.Id
import com.agileburo.anytype.domain.event.model.Payload
/**
* Use-case for pasting to Anytype clipboard.
*/
class Paste(
private val repo: BlockRepository,
private val clipboard: Clipboard,
private val matcher: Clipboard.UriMatcher
) : BaseUseCase<Paste.Response, Paste.Params>() {
override suspend fun run(params: Params) = safe {
val clip = clipboard.clip()
if (clip != null) {
val uri = clip.uri
val blocks = if (uri != null && matcher.isAnytypeUri(uri))
clipboard.blocks()
else
emptyList()
repo.paste(
command = Command.Paste(
context = params.context,
focus = params.focus,
selected = emptyList(),
range = params.range,
text = clip.text,
html = clip.html,
blocks = blocks
)
)
} else {
throw IllegalStateException("Empty clip!")
}
}
/**
* Params for pasting to Anytype clipboard
* @property context id of the context
* @property focus id of the focused/target block
* @property range selected text range
*/
data class Params(
val context: Id,
val focus: Id,
val range: IntRange
)
/**
* Response for the use-case.
* @param cursor caret position
* @param blocks ids of the new blocks
* @param payload response payload
*/
data class Response(
val cursor: Int,
val blocks: List<Id>,
val payload: Payload
)
}

View File

@ -25,7 +25,6 @@ fun Map<String, List<Block>>.asRender(anchor: String): List<Block> {
children.forEach { child ->
when (child.content) {
is Content.Text,
is Content.Image,
is Content.Link,
is Content.Divider,
is Content.Bookmark,

View File

@ -683,12 +683,11 @@ class BlockExtensionTest {
@Test(expected = ClassCastException::class)
fun `should throw exception when block is not text`() {
val block = Block(
id = MockDataFactory.randomUuid(),
fields = Block.Fields.empty(),
content = Block.Content.Dashboard(
type = Block.Content.Dashboard.Type.MAIN_SCREEN
),
content = Block.Content.Divider,
children = emptyList()
)
val range = IntRange(10, 13)

View File

@ -1,26 +0,0 @@
package com.agileburo.anytype;
import android.content.Context;
import androidx.test.InstrumentationRegistry;
import androidx.test.runner.AndroidJUnit4;
import org.junit.Test;
import org.junit.runner.RunWith;
import static org.junit.Assert.*;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getTargetContext();
assertEquals("com.agileburo.anytype.test", appContext.getPackageName());
}
}

View File

@ -6,8 +6,8 @@ import com.agileburo.anytype.data.auth.model.AccountEntity
import com.agileburo.anytype.data.auth.model.WalletEntity
import com.agileburo.anytype.data.auth.repo.AuthRemote
import com.agileburo.anytype.middleware.EventProxy
import com.agileburo.anytype.middleware.converters.toAccountEntity
import com.agileburo.anytype.middleware.interactor.Middleware
import com.agileburo.anytype.middleware.toAccountEntity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.filter

View File

@ -5,8 +5,8 @@ import com.agileburo.anytype.data.auth.model.ConfigEntity
import com.agileburo.anytype.data.auth.model.PayloadEntity
import com.agileburo.anytype.data.auth.model.Response
import com.agileburo.anytype.data.auth.repo.block.BlockRemote
import com.agileburo.anytype.middleware.converters.mark
import com.agileburo.anytype.middleware.interactor.Middleware
import com.agileburo.anytype.middleware.toMiddleware
class BlockMiddleware(
private val middleware: Middleware
@ -41,7 +41,7 @@ class BlockMiddleware(
command.contextId,
command.blockId,
command.text,
command.marks.map { it.toMiddleware() }
command.marks.map { it.mark() }
)
}
@ -133,4 +133,8 @@ class BlockMiddleware(
override suspend fun paste(
command: CommandEntity.Paste
): Response.Clipboard.Paste = middleware.paste(command)
override suspend fun copy(
command: CommandEntity.Copy
): Response.Clipboard.Copy = middleware.copy(command)
}

View File

@ -0,0 +1,18 @@
package com.agileburo.anytype.middleware.converters
import anytype.clipboard.ClipboardOuterClass.Clipboard
import com.agileburo.anytype.data.auth.mapper.Serializer
import com.agileburo.anytype.data.auth.model.BlockEntity
class ClipboardSerializer : Serializer {
override fun serialize(blocks: List<BlockEntity>): ByteArray {
val models = blocks.map { it.block() }
val clipboard = Clipboard.newBuilder().addAllBlocks(models).build()
return clipboard.toByteArray()
}
override fun deserialize(blob: ByteArray): List<BlockEntity> {
return Clipboard.parseFrom(blob).blocksList.blocks()
}
}

View File

@ -1,7 +1,6 @@
package com.agileburo.anytype.middleware
package com.agileburo.anytype.middleware.converters
import anytype.Events
import anytype.model.Models
import anytype.model.Models.Account
import anytype.model.Models.Block
import com.agileburo.anytype.data.auth.model.AccountEntity
@ -11,7 +10,6 @@ import com.google.protobuf.Struct
import com.google.protobuf.Value
import timber.log.Timber
fun Events.Event.Account.Show.toAccountEntity(): AccountEntity {
return AccountEntity(
id = account.id,
@ -22,69 +20,6 @@ fun Events.Event.Account.Show.toAccountEntity(): AccountEntity {
)
}
fun BlockEntity.Content.Text.Mark.toMiddleware(): Block.Content.Text.Mark {
val rangeModel = Models.Range.newBuilder()
.setFrom(range.first)
.setTo(range.last)
.build()
return when (type) {
BlockEntity.Content.Text.Mark.Type.BOLD -> {
Block.Content.Text.Mark
.newBuilder()
.setType(Block.Content.Text.Mark.Type.Bold)
.setRange(rangeModel)
.build()
}
BlockEntity.Content.Text.Mark.Type.ITALIC -> {
Block.Content.Text.Mark
.newBuilder()
.setType(Block.Content.Text.Mark.Type.Italic)
.setRange(rangeModel)
.build()
}
BlockEntity.Content.Text.Mark.Type.STRIKETHROUGH -> {
Block.Content.Text.Mark
.newBuilder()
.setType(Block.Content.Text.Mark.Type.Strikethrough)
.setRange(rangeModel)
.build()
}
BlockEntity.Content.Text.Mark.Type.TEXT_COLOR -> {
Block.Content.Text.Mark
.newBuilder()
.setType(Block.Content.Text.Mark.Type.TextColor)
.setRange(rangeModel)
.setParam(param)
.build()
}
BlockEntity.Content.Text.Mark.Type.LINK -> {
Block.Content.Text.Mark
.newBuilder()
.setType(Block.Content.Text.Mark.Type.Link)
.setRange(rangeModel)
.setParam(param)
.build()
}
BlockEntity.Content.Text.Mark.Type.BACKGROUND_COLOR -> {
Block.Content.Text.Mark
.newBuilder()
.setType(Block.Content.Text.Mark.Type.BackgroundColor)
.setRange(rangeModel)
.setParam(param)
.build()
}
BlockEntity.Content.Text.Mark.Type.KEYBOARD -> {
Block.Content.Text.Mark
.newBuilder()
.setType(Block.Content.Text.Mark.Type.Keyboard)
.setRange(rangeModel)
.build()
}
else -> throw IllegalStateException("Unsupported mark type: ${type.name}")
}
}
fun Block.fields(): BlockEntity.Fields = BlockEntity.Fields().also { result ->
fields.fieldsMap.forEach { (key, value) ->
result.map[key] = when (val case = value.kindCase) {

View File

@ -0,0 +1,234 @@
package com.agileburo.anytype.middleware.converters
import anytype.model.Models.Block
import anytype.model.Models.Range
import com.agileburo.anytype.data.auth.model.BlockEntity
import com.google.protobuf.Struct
import com.google.protobuf.Value
typealias Mark = Block.Content.Text.Mark
typealias File = Block.Content.File
typealias FileState = Block.Content.File.State
typealias FileType = Block.Content.File.Type
typealias Link = Block.Content.Link
typealias LinkType = Block.Content.Link.Style
typealias Bookmark = Block.Content.Bookmark
typealias Marks = Block.Content.Text.Marks
typealias Text = Block.Content.Text
typealias Layout = Block.Content.Layout
typealias LayoutStyle = Block.Content.Layout.Style
typealias Divider = Block.Content.Div
typealias DividerStyle = Block.Content.Div.Style
//region block mapping
fun BlockEntity.block(): Block {
val builder = Block.newBuilder()
builder.id = id
when (val content = content) {
is BlockEntity.Content.Text -> {
builder.text = content.text()
}
is BlockEntity.Content.Bookmark -> {
builder.bookmark = content.bookmark()
}
is BlockEntity.Content.File -> {
builder.file = content.file()
}
is BlockEntity.Content.Link -> {
builder.link = content.link()
}
is BlockEntity.Content.Layout -> {
builder.layout = content.layout()
}
is BlockEntity.Content.Divider -> {
builder.div = content.divider()
}
}
return builder.build()
}
//endregion
//region text block mapping
fun BlockEntity.Content.Text.text(): Text {
return Text
.newBuilder()
.setText(text)
.setMarks(marks())
.setStyle(style.toMiddleware())
.build()
}
fun BlockEntity.Content.Text.marks(): Marks {
return Marks
.newBuilder()
.addAllMarks(marks.map { it.mark() })
.build()
}
fun BlockEntity.Content.Text.Mark.mark(): Mark = when (type) {
BlockEntity.Content.Text.Mark.Type.BOLD -> {
Mark.newBuilder()
.setType(Block.Content.Text.Mark.Type.Bold)
.setRange(range.range())
.build()
}
BlockEntity.Content.Text.Mark.Type.ITALIC -> {
Mark.newBuilder()
.setType(Block.Content.Text.Mark.Type.Italic)
.setRange(range.range())
.build()
}
BlockEntity.Content.Text.Mark.Type.STRIKETHROUGH -> {
Mark.newBuilder()
.setType(Block.Content.Text.Mark.Type.Strikethrough)
.setRange(range.range())
.build()
}
BlockEntity.Content.Text.Mark.Type.TEXT_COLOR -> {
Mark.newBuilder()
.setType(Block.Content.Text.Mark.Type.TextColor)
.setRange(range.range())
.setParam(param)
.build()
}
BlockEntity.Content.Text.Mark.Type.LINK -> {
Mark.newBuilder()
.setType(Block.Content.Text.Mark.Type.Link)
.setRange(range.range())
.setParam(param)
.build()
}
BlockEntity.Content.Text.Mark.Type.BACKGROUND_COLOR -> {
Mark.newBuilder()
.setType(Block.Content.Text.Mark.Type.BackgroundColor)
.setRange(range.range())
.setParam(param)
.build()
}
BlockEntity.Content.Text.Mark.Type.KEYBOARD -> {
Mark.newBuilder()
.setType(Block.Content.Text.Mark.Type.Keyboard)
.setRange(range.range())
.build()
}
else -> throw IllegalStateException("Unsupported mark type: ${type.name}")
}
//endregion
//region bookmark block mapping
fun BlockEntity.Content.Bookmark.bookmark(): Bookmark {
val builder = Bookmark.newBuilder()
description?.let { builder.setDescription(it) }
favicon?.let { builder.setFaviconHash(it) }
title?.let { builder.setTitle(it) }
url?.let { builder.setUrl(it) }
image?.let { builder.setImageHash(it) }
return builder.build()
}
//endregion
//region file block mapping
fun BlockEntity.Content.File.file(): File {
val builder = File.newBuilder()
hash?.let { builder.setHash(it) }
name?.let { builder.setName(it) }
mime?.let { builder.setMime(it) }
size?.let { builder.setSize(it) }
state?.let { builder.setState(it.state()) }
type?.let { builder.setType(it.type()) }
return builder.build()
}
fun BlockEntity.Content.File.State.state(): FileState = when (this) {
BlockEntity.Content.File.State.EMPTY -> FileState.Empty
BlockEntity.Content.File.State.UPLOADING -> FileState.Uploading
BlockEntity.Content.File.State.DONE -> FileState.Done
BlockEntity.Content.File.State.ERROR -> FileState.Error
}
fun BlockEntity.Content.File.Type.type(): FileType = when (this) {
BlockEntity.Content.File.Type.NONE -> FileType.None
BlockEntity.Content.File.Type.FILE -> FileType.File
BlockEntity.Content.File.Type.IMAGE -> FileType.Image
BlockEntity.Content.File.Type.VIDEO -> FileType.Video
}
//endregion
//region link mapping
fun BlockEntity.Content.Link.link(): Link {
return Link.newBuilder()
.setTargetBlockId(target)
.setStyle(type.type())
.setFields(fields.fields())
.build()
}
fun BlockEntity.Content.Link.Type.type() : LinkType = when(this) {
BlockEntity.Content.Link.Type.ARCHIVE -> LinkType.Archive
BlockEntity.Content.Link.Type.DASHBOARD -> LinkType.Dashboard
BlockEntity.Content.Link.Type.DATA_VIEW -> LinkType.Dataview
BlockEntity.Content.Link.Type.PAGE -> LinkType.Page
}
//endregion
//region layout mapping
fun BlockEntity.Content.Layout.layout() : Layout {
val builder = Layout.newBuilder()
when(type) {
BlockEntity.Content.Layout.Type.ROW -> builder.style = LayoutStyle.Row
BlockEntity.Content.Layout.Type.COLUMN -> builder.style = LayoutStyle.Column
BlockEntity.Content.Layout.Type.DIV -> builder.style = LayoutStyle.Div
}
return builder.build()
}
//endregion
//region divider mapping
fun BlockEntity.Content.Divider.divider() : Divider {
return Divider.newBuilder().setStyle(DividerStyle.Line).build()
}
//endregion
//region other mapping
fun BlockEntity.Fields.fields() : Struct {
val builder = Struct.newBuilder()
map.forEach { (key, value) ->
if (key != null && value != null)
when(value) {
is String -> {
builder.putFields(key, Value.newBuilder().setStringValue(value).build())
}
is Boolean -> {
builder.putFields(key, Value.newBuilder().setBoolValue(value).build())
}
is Double-> {
builder.putFields(key, Value.newBuilder().setNumberValue(value).build())
}
else -> throw IllegalStateException("Unexpected value type: ${value::class.java}")
}
}
return builder.build()
}
fun IntRange.range(): Range = Range.newBuilder().setFrom(first).setTo(last).build()
//endregion

View File

@ -782,6 +782,8 @@ public class Middleware {
html = command.getHtml();
}
List<Models.Block> blocks = mapper.toMiddleware(command.getBlocks());
Block.Paste.Request request = Block.Paste.Request
.newBuilder()
.setContextId(command.getContext())
@ -789,6 +791,7 @@ public class Middleware {
.setTextSlot(command.getText())
.setHtmlSlot(html)
.setSelectedTextRange(range)
.addAllAnySlot(blocks)
.addAllSelectedBlockIds(command.getSelected())
.build();
@ -808,4 +811,47 @@ public class Middleware {
mapper.toPayload(response.getEvent())
);
}
public Response.Clipboard.Copy copy(CommandEntity.Copy command) throws Exception {
Range range;
if (command.getRange() != null) {
range = Range.newBuilder()
.setFrom(command.getRange().getFirst())
.setTo(command.getRange().getLast())
.build();
} else {
range = Range.getDefaultInstance();
}
List<Models.Block> blocks = mapper.toMiddleware(command.getBlocks());
Block.Copy.Request.Builder builder = Block.Copy.Request.newBuilder();
if (range != null) {
builder.setSelectedTextRange(range);
}
Block.Copy.Request request = builder
.setContextId(command.getContext())
.addAllBlocks(blocks)
.build();
if (BuildConfig.DEBUG) {
Timber.d(request.getClass().getName() + "\n" + request.toString());
}
Block.Copy.Response response = service.blockCopy(request);
if (BuildConfig.DEBUG) {
Timber.d(response.getClass().getName() + "\n" + response.toString());
}
return new Response.Clipboard.Copy(
response.getTextSlot(),
response.getHtmlSlot(),
mapper.toEntity(response.getAnySlotList())
);
}
}

View File

@ -3,10 +3,10 @@ package com.agileburo.anytype.middleware.interactor
import anytype.Events.Event
import com.agileburo.anytype.data.auth.model.BlockEntity
import com.agileburo.anytype.data.auth.model.EventEntity
import com.agileburo.anytype.middleware.blocks
import com.agileburo.anytype.middleware.entity
import com.agileburo.anytype.middleware.fields
import com.agileburo.anytype.middleware.marks
import com.agileburo.anytype.middleware.converters.blocks
import com.agileburo.anytype.middleware.converters.entity
import com.agileburo.anytype.middleware.converters.fields
import com.agileburo.anytype.middleware.converters.marks
fun Event.Message.toEntity(
context: String

View File

@ -2,7 +2,9 @@ package com.agileburo.anytype.middleware.interactor
import anytype.model.Models.Block
import com.agileburo.anytype.data.auth.model.BlockEntity
import com.agileburo.anytype.middleware.toMiddleware
import com.agileburo.anytype.middleware.converters.state
import com.agileburo.anytype.middleware.converters.toMiddleware
import com.agileburo.anytype.middleware.converters.type
class MiddlewareFactory {
@ -29,8 +31,8 @@ class MiddlewareFactory {
}
is BlockEntity.Prototype.File -> {
val file = Block.Content.File.newBuilder().apply {
state = prototype.state.toMiddleware()
type = prototype.type.toMiddleware()
state = prototype.state.state()
type = prototype.type.type()
}
builder.setFile(file).build()
}

View File

@ -5,10 +5,16 @@ import anytype.model.Models.Block
import com.agileburo.anytype.data.auth.model.BlockEntity
import com.agileburo.anytype.data.auth.model.PayloadEntity
import com.agileburo.anytype.data.auth.model.PositionEntity
import com.agileburo.anytype.middleware.toMiddleware
import com.agileburo.anytype.middleware.converters.block
import com.agileburo.anytype.middleware.converters.blocks
import com.agileburo.anytype.middleware.converters.toMiddleware
class MiddlewareMapper {
fun toMiddleware(blocks: List<BlockEntity>) : List<Block> {
return blocks.map { it.block() }
}
fun toMiddleware(style: BlockEntity.Content.Text.Style): Block.Content.Text.Style {
return style.toMiddleware()
}
@ -30,4 +36,8 @@ class MiddlewareMapper {
fun toMiddleware(alignment: BlockEntity.Align): Block.Align {
return alignment.toMiddleware()
}
fun toEntity(blocks: List<Block>) : List<BlockEntity> {
return blocks.blocks()
}
}

View File

@ -327,4 +327,15 @@ public class DefaultMiddlewareService implements MiddlewareService {
return response;
}
}
@Override
public Block.Copy.Response blockCopy(Block.Copy.Request request) throws Exception {
byte[] encoded = Lib.blockCopy(request.toByteArray());
Block.Copy.Response response = Block.Copy.Response.parseFrom(encoded);
if (response.getError() != null && response.getError().getCode() != Block.Copy.Response.Error.Code.NULL) {
throw new Exception(response.getError().getDescription());
} else {
return response;
}
}
}

View File

@ -67,4 +67,6 @@ public interface MiddlewareService {
Block.Set.Details.Response blockSetDetails(Block.Set.Details.Request request) throws Exception;
Block.Paste.Response blockPaste(Block.Paste.Request request) throws Exception;
Block.Copy.Response blockCopy(Block.Copy.Request request) throws Exception;
}

View File

@ -1117,6 +1117,20 @@ class PageViewModel(
}
}
fun onMultiSelectCopyClicked() {
viewModelScope.launch {
orchestrator.proxies.intents.send(
Intent.Clipboard.Copy(
context = context,
blocks = blocks.filter { block ->
currentSelection().contains(block.id)
},
range = null
)
)
}
}
fun onMultiSelectModeSelectAllClicked() {
(stateData.value as ViewState.Success).let { state ->
val update = state.blocks.map { block ->
@ -1436,8 +1450,6 @@ class PageViewModel(
}
fun onPaste(
plain: String,
html: String?,
range: IntRange
) {
viewModelScope.launch {
@ -1446,10 +1458,21 @@ class PageViewModel(
context = context,
focus = orchestrator.stores.focus.current(),
range = range,
blocks = emptyList(),
selected = emptyList(),
html = html,
text = plain
selected = emptyList()
)
)
}
}
fun onCopy(
range: IntRange
) {
viewModelScope.launch {
orchestrator.proxies.intents.send(
Intent.Clipboard.Copy(
context = context,
range = range,
blocks = listOf(blocks.first { it.id == focus.value })
)
)
}

View File

@ -56,9 +56,11 @@ sealed class Intent {
val context: Id,
val focus: Id,
val selected: List<Id>,
val range: IntRange,
val text: String,
val html: String?,
val range: IntRange
) : Clipboard()
class Copy(
val context: Id,
val range: IntRange?,
val blocks: List<Block>
) : Clipboard()
}

View File

@ -1,6 +1,8 @@
package com.agileburo.anytype.presentation.page.editor
import com.agileburo.anytype.domain.block.interactor.*
import com.agileburo.anytype.domain.clipboard.Copy
import com.agileburo.anytype.domain.clipboard.Paste
import com.agileburo.anytype.domain.common.Id
import com.agileburo.anytype.domain.download.DownloadFile
import com.agileburo.anytype.domain.event.model.Payload
@ -28,7 +30,8 @@ class Orchestrator(
private val updateText: UpdateText,
private val updateAlignment: UpdateAlignment,
private val setupBookmark: SetupBookmark,
private val paste: Clipboard.Paste,
private val copy: Copy,
private val paste: Paste,
private val undo: Undo,
private val redo: Redo,
val memory: Editor.Memory,
@ -263,14 +266,10 @@ class Orchestrator(
}
is Intent.Clipboard.Paste -> {
paste(
params = Clipboard.Paste.Params(
params = Paste.Params(
context = intent.context,
focus = intent.focus,
range = intent.range,
blocks = emptyList(),
html = intent.html,
text = intent.text,
selected = intent.selected
range = intent.range
)
).proceed(
failure = defaultOnError,
@ -280,6 +279,20 @@ class Orchestrator(
}
)
}
is Intent.Clipboard.Copy -> {
copy(
params = Copy.Params(
context = intent.context,
blocks = intent.blocks,
range = intent.range
)
).proceed(
success = {
Timber.d("Copy sucessful")
},
failure = defaultOnError
)
}
}
}
}

View File

@ -11,6 +11,8 @@ import com.agileburo.anytype.domain.base.Either
import com.agileburo.anytype.domain.block.interactor.*
import com.agileburo.anytype.domain.block.model.Block
import com.agileburo.anytype.domain.block.model.Position
import com.agileburo.anytype.domain.clipboard.Copy
import com.agileburo.anytype.domain.clipboard.Paste
import com.agileburo.anytype.domain.common.Id
import com.agileburo.anytype.domain.config.Config
import com.agileburo.anytype.domain.download.DownloadFile
@ -113,7 +115,10 @@ class PageViewModelTest {
lateinit var uploadUrl: UploadUrl
@Mock
lateinit var paste: Clipboard.Paste
lateinit var paste: Paste
@Mock
lateinit var copy: Copy
@Mock
lateinit var undo: Undo
@ -4266,7 +4271,8 @@ class PageViewModelTest {
),
updateAlignment = updateAlignment,
setupBookmark = setupBookmark,
paste = paste
paste = paste,
copy = copy
)
)
}

View File

@ -0,0 +1,8 @@
syntax = "proto3";
package anytype.clipboard;
import "models.proto";
message Clipboard {
repeated anytype.model.Block blocks = 1;
}

View File

@ -42,6 +42,10 @@ dependencies {
def applicationDependencies = rootProject.ext.mainApplication
def protobufDependencies = rootProject.ext.protobuf
implementation protobufDependencies.protobufJava
implementation 'com.github.HBiSoft:PickiT:0.1.9'
implementation 'com.vdurmont:emoji-java:5.1.1'
@ -49,6 +53,8 @@ dependencies {
implementation project(':core-utils')
implementation project(':core-ui')
implementation project(':library-page-icon-picker-widget')
implementation project(':middleware')
implementation project(':protobuf')
implementation applicationDependencies.timber

View File

@ -18,13 +18,17 @@
android:supportsRtl="true"
android:theme="@style/AppTheme"
tools:ignore="GoogleAppIndexingWarning">
<activity android:name=".MainActivity" />
<activity android:name=".KeyboardActivity" android:windowSoftInputMode="adjustResize">
<activity android:name=".ClipboardActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity android:name=".MainActivity" />
<activity
android:name=".KeyboardActivity"
android:windowSoftInputMode="adjustResize">
</activity>
</application>
</manifest>

View File

@ -0,0 +1,85 @@
package com.agileburo.anytype.sample
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.net.Uri
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import anytype.clipboard.ClipboardOuterClass.Clipboard
import anytype.model.Models.Block
import com.agileburo.anytype.core_ui.common.ThemeColor
import kotlinx.android.synthetic.main.activity_clipboard.*
class ClipboardActivity : AppCompatActivity() {
private val cm: ClipboardManager
get() = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_clipboard)
setup()
}
private fun setup() {
write.setOnClickListener { write() }
read.setOnClickListener { read() }
copy.setOnClickListener { copy() }
paste.setOnClickListener { paste() }
}
private fun write() {
val text = Block.Content.Text
.newBuilder()
.setText("Everything was in confusion")
.setColor(ThemeColor.ICE.title)
.setStyle(Block.Content.Text.Style.Checkbox)
.build()
val blocks = listOf(
Block.newBuilder()
.setId("1")
.setText(text)
.build(),
Block.newBuilder()
.setId("2")
.setText(text)
.build()
)
val clipboard = Clipboard.newBuilder().addAllBlocks(blocks).build()
val stream = openFileOutput(DEFAULT_FILE_NAME, Context.MODE_PRIVATE)
clipboard.writeTo(stream)
stream.flush()
stream.close()
}
private fun read() {
val stream = openFileInput(DEFAULT_FILE_NAME)
val board = Clipboard.parseFrom(stream)
output.text = board.toString()
}
private fun copy() {
output.clearComposingText()
val uri = Uri.parse(BASE_URI)
val clip = ClipData.newUri(contentResolver, "URI", uri)
cm.setPrimaryClip(clip)
}
private fun paste() {
output.text = cm.primaryClip.toString()
}
companion object {
private const val DEFAULT_FILE_NAME = "test"
private const val AUTHORITY = "com.agileburo.anytype.sample"
private const val BASE_URI = "content://$AUTHORITY"
}
}

View File

@ -18,4 +18,8 @@ class SampleApp : Application() {
else
Timber.plant(CrashlyticsTree())
}
companion object {
const val BASE_URI = "content://com.agileburo.anytype"
}
}

View File

@ -0,0 +1,64 @@
package com.agileburo.anytype.sample.helpers
import java.util.*
import java.util.concurrent.ThreadLocalRandom
object MockDataFactory {
fun randomUuid(): String {
return UUID.randomUUID().toString()
}
fun randomString(): String {
return randomUuid()
}
fun randomInt(): Int {
return ThreadLocalRandom.current().nextInt(0, 1000 + 1)
}
fun randomInt(max: Int): Int {
return ThreadLocalRandom.current().nextInt(0, max)
}
fun randomLong(): Long {
return randomInt().toLong()
}
fun randomFloat(): Float {
return randomInt().toFloat()
}
fun randomDouble(): Double {
return randomInt().toDouble()
}
fun randomBoolean(): Boolean {
return Math.random() < 0.5
}
fun makeIntList(count: Int): List<Int> {
val items = mutableListOf<Int>()
repeat(count) {
items.add(randomInt())
}
return items
}
fun makeStringList(count: Int): List<String> {
val items = mutableListOf<String>()
repeat(count) {
items.add(randomUuid())
}
return items
}
fun makeDoubleList(count: Int): List<Double> {
val items = mutableListOf<Double>()
repeat(count) {
items.add(randomDouble())
}
return items
}
}

View File

@ -0,0 +1,69 @@
<androidx.constraintlayout.widget.ConstraintLayout 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="match_parent">
<Button
android:id="@+id/copy"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:text="copy"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.07999998" />
<Button
android:id="@+id/paste"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:text="paste"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/copy" />
<TextView
android:gravity="center"
android:padding="16dp"
tools:text="sdsdsdsd"
android:textColor="@color/white"
android:background="@color/black"
android:fontFamily="monospace"
android:id="@+id/output"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="16dp"
android:layout_marginTop="64dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="32dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/paste" />
<Button
android:id="@+id/write"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginTop="32dp"
android:text="write"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<Button
android:id="@+id/read"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:layout_marginEnd="32dp"
android:text="read"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -13,3 +13,4 @@ include ':app',
':library-page-icon-picker-widget',
':library-emojifier',
':sample'
include ':clipboard'