refactor: Ensure copying text experience is consistent (#1115)

Previous code was inconsistent about whether or not a notification toast
was shown after copying text (contrary to platform guidelines), and
there was some code duplication.

Fix this with a new `ClipboardUseCase` with a `copyTextTo` method that
handles copying text to the clipboard and showing a message afterwards
(depending on platform level).
This commit is contained in:
Nik Clayton 2024-11-20 14:51:24 +01:00 committed by GitHub
parent c84a77e862
commit 5c048311b2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 138 additions and 74 deletions

View File

@ -21,9 +21,6 @@ import android.Manifest
import android.animation.Animator import android.animation.Animator
import android.animation.AnimatorListenerAdapter import android.animation.AnimatorListenerAdapter
import android.app.DownloadManager import android.app.DownloadManager
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.Color import android.graphics.Color
import android.os.Build import android.os.Build
@ -53,6 +50,7 @@ import app.pachli.core.navigation.AttachmentViewData
import app.pachli.core.navigation.ViewMediaActivityIntent import app.pachli.core.navigation.ViewMediaActivityIntent
import app.pachli.core.navigation.ViewThreadActivityIntent import app.pachli.core.navigation.ViewThreadActivityIntent
import app.pachli.core.navigation.pachliAccountId import app.pachli.core.navigation.pachliAccountId
import app.pachli.core.ui.ClipboardUseCase
import app.pachli.databinding.ActivityViewMediaBinding import app.pachli.databinding.ActivityViewMediaBinding
import app.pachli.fragment.MediaActionsListener import app.pachli.fragment.MediaActionsListener
import app.pachli.pager.ImagePagerAdapter import app.pachli.pager.ImagePagerAdapter
@ -83,6 +81,9 @@ class ViewMediaActivity : BaseActivity(), MediaActionsListener {
@Inject @Inject
lateinit var downloadUrlUseCase: DownloadUrlUseCase lateinit var downloadUrlUseCase: DownloadUrlUseCase
@Inject
lateinit var clipboard: ClipboardUseCase
private val viewModel: ViewMediaViewModel by viewModels() private val viewModel: ViewMediaViewModel by viewModels()
private val binding by viewBinding(ActivityViewMediaBinding::inflate) private val binding by viewBinding(ActivityViewMediaBinding::inflate)
@ -260,8 +261,7 @@ class ViewMediaActivity : BaseActivity(), MediaActionsListener {
private fun copyLink() { private fun copyLink() {
val url = imageUrl ?: attachmentViewData!![binding.viewPager.currentItem].attachment.url val url = imageUrl ?: attachmentViewData!![binding.viewPager.currentItem].attachment.url
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager clipboard.copyTextTo(url)
clipboard.setPrimaryClip(ClipData.newPlainText(null, url))
} }
private fun shareMedia() { private fun shareMedia() {

View File

@ -17,9 +17,6 @@
package app.pachli.components.account package app.pachli.components.account
import android.animation.ArgbEvaluator import android.animation.ArgbEvaluator
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.res.ColorStateList import android.content.res.ColorStateList
import android.content.res.Configuration import android.content.res.Configuration
@ -83,6 +80,7 @@ import app.pachli.core.network.model.Relationship
import app.pachli.core.network.parseAsMastodonHtml import app.pachli.core.network.parseAsMastodonHtml
import app.pachli.core.preferences.AppTheme import app.pachli.core.preferences.AppTheme
import app.pachli.core.preferences.PrefKeys import app.pachli.core.preferences.PrefKeys
import app.pachli.core.ui.ClipboardUseCase
import app.pachli.core.ui.LinkListener import app.pachli.core.ui.LinkListener
import app.pachli.core.ui.extensions.reduceSwipeSensitivity import app.pachli.core.ui.extensions.reduceSwipeSensitivity
import app.pachli.core.ui.getDomain import app.pachli.core.ui.getDomain
@ -129,6 +127,9 @@ class AccountActivity :
@Inject @Inject
lateinit var draftsAlert: DraftsAlert lateinit var draftsAlert: DraftsAlert
@Inject
lateinit var clipboard: ClipboardUseCase
private val viewModel: AccountViewModel by viewModels() private val viewModel: AccountViewModel by viewModels()
private val binding: ActivityAccountBinding by viewBinding(ActivityAccountBinding::inflate) private val binding: ActivityAccountBinding by viewBinding(ActivityAccountBinding::inflate)
@ -498,10 +499,7 @@ class AccountActivity :
view.setOnLongClickListener { view.setOnLongClickListener {
loadedAccount?.let { loadedAccount -> loadedAccount?.let { loadedAccount ->
val fullUsername = getFullUsername(loadedAccount) val fullUsername = getFullUsername(loadedAccount)
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager clipboard.copyTextTo(fullUsername, R.string.account_username_copied)
clipboard.setPrimaryClip(ClipData.newPlainText(null, fullUsername))
Snackbar.make(binding.root, getString(R.string.account_username_copied), Snackbar.LENGTH_SHORT)
.show()
} }
true true
} }

View File

@ -17,9 +17,6 @@
package app.pachli.components.search.fragments package app.pachli.components.search.fragments
import android.Manifest import android.Manifest
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
@ -55,6 +52,7 @@ import app.pachli.core.network.model.Attachment
import app.pachli.core.network.model.Poll import app.pachli.core.network.model.Poll
import app.pachli.core.network.model.Status import app.pachli.core.network.model.Status
import app.pachli.core.network.model.Status.Mention import app.pachli.core.network.model.Status.Mention
import app.pachli.core.ui.ClipboardUseCase
import app.pachli.interfaces.StatusActionListener import app.pachli.interfaces.StatusActionListener
import app.pachli.view.showMuteAccountDialog import app.pachli.view.showMuteAccountDialog
import app.pachli.viewdata.StatusViewData import app.pachli.viewdata.StatusViewData
@ -75,6 +73,9 @@ class SearchStatusesFragment : SearchFragment<StatusViewData>(), StatusActionLis
@Inject @Inject
lateinit var downloadUrlUseCase: DownloadUrlUseCase lateinit var downloadUrlUseCase: DownloadUrlUseCase
@Inject
lateinit var clipboard: ClipboardUseCase
override val data: Flow<PagingData<StatusViewData>> override val data: Flow<PagingData<StatusViewData>>
get() = viewModel.statusesFlow get() = viewModel.statusesFlow
@ -281,8 +282,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData>(), StatusActionLis
return@setOnMenuItemClickListener true return@setOnMenuItemClickListener true
} }
R.id.status_copy_link -> { R.id.status_copy_link -> {
val clipboard = requireActivity().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager statusUrl?.let { clipboard.copyTextTo(it) }
clipboard.setPrimaryClip(ClipData.newPlainText(null, statusUrl))
return@setOnMenuItemClickListener true return@setOnMenuItemClickListener true
} }
R.id.status_open_as -> { R.id.status_open_as -> {

View File

@ -17,21 +17,18 @@
package app.pachli.components.trending package app.pachli.components.trending
import android.content.ClipData
import android.content.ClipboardManager
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.core.view.AccessibilityDelegateCompat import androidx.core.view.AccessibilityDelegateCompat
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import app.pachli.R import app.pachli.R
import app.pachli.core.ui.accessibility.PachliRecyclerViewAccessibilityDelegate import app.pachli.core.ui.accessibility.PachliRecyclerViewAccessibilityDelegate
import app.pachli.core.ui.di.UseCaseEntryPoint
import app.pachli.view.PreviewCardView import app.pachli.view.PreviewCardView
import app.pachli.view.PreviewCardView.Target import app.pachli.view.PreviewCardView.Target
import dagger.hilt.android.EntryPointAccessors
/** /**
* Accessbility delete for [TrendingLinkViewHolder]. * Accessbility delete for [TrendingLinkViewHolder].
@ -44,6 +41,9 @@ internal class TrendingLinksAccessibilityDelegate(
private val recyclerView: RecyclerView, private val recyclerView: RecyclerView,
val listener: PreviewCardView.OnClickListener, val listener: PreviewCardView.OnClickListener,
) : PachliRecyclerViewAccessibilityDelegate(recyclerView) { ) : PachliRecyclerViewAccessibilityDelegate(recyclerView) {
private val useCaseEntryPoint = EntryPointAccessors.fromApplication<UseCaseEntryPoint>(context.applicationContext)
val clipboard = useCaseEntryPoint.clipboardUseCase
private val openLinkAction = AccessibilityActionCompat( private val openLinkAction = AccessibilityActionCompat(
app.pachli.core.ui.R.id.action_open_link, app.pachli.core.ui.R.id.action_open_link,
context.getString(R.string.action_open_link), context.getString(R.string.action_open_link),
@ -85,19 +85,7 @@ internal class TrendingLinksAccessibilityDelegate(
true true
} }
app.pachli.core.ui.R.id.action_copy_item -> { app.pachli.core.ui.R.id.action_copy_item -> {
val clipboard = ContextCompat.getSystemService( clipboard.copyTextTo(viewHolder.link.url)
context,
ClipboardManager::class.java,
) as ClipboardManager
val clip = ClipData.newPlainText("", viewHolder.link.url)
clipboard.setPrimaryClip(clip)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
Toast.makeText(
context,
context.getString(app.pachli.core.ui.R.string.item_copied),
Toast.LENGTH_SHORT,
).show()
}
true true
} }
app.pachli.core.ui.R.id.action_open_byline_account -> { app.pachli.core.ui.R.id.action_open_byline_account -> {

View File

@ -16,8 +16,6 @@
package app.pachli.fragment package app.pachli.fragment
import android.Manifest import android.Manifest
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.content.DialogInterface import android.content.DialogInterface
import android.content.Intent import android.content.Intent
@ -60,6 +58,7 @@ import app.pachli.core.network.model.Attachment
import app.pachli.core.network.model.Status import app.pachli.core.network.model.Status
import app.pachli.core.network.parseAsMastodonHtml import app.pachli.core.network.parseAsMastodonHtml
import app.pachli.core.network.retrofit.MastodonApi import app.pachli.core.network.retrofit.MastodonApi
import app.pachli.core.ui.ClipboardUseCase
import app.pachli.core.ui.extensions.getErrorString import app.pachli.core.ui.extensions.getErrorString
import app.pachli.interfaces.StatusActionListener import app.pachli.interfaces.StatusActionListener
import app.pachli.usecase.TimelineCases import app.pachli.usecase.TimelineCases
@ -95,6 +94,9 @@ abstract class SFragment<T : IStatusViewData> : Fragment(), StatusActionListener
@Inject @Inject
lateinit var downloadUrlUseCase: DownloadUrlUseCase lateinit var downloadUrlUseCase: DownloadUrlUseCase
@Inject
lateinit var clipboard: ClipboardUseCase
private var serverCanTranslate = false private var serverCanTranslate = false
protected abstract var pachliAccountId: Long protected abstract var pachliAccountId: Long
@ -296,9 +298,7 @@ abstract class SFragment<T : IStatusViewData> : Fragment(), StatusActionListener
return@setOnMenuItemClickListener true return@setOnMenuItemClickListener true
} }
R.id.status_copy_link -> { R.id.status_copy_link -> {
(requireActivity().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager).apply { statusUrl?.let { clipboard.copyTextTo(it) }
setPrimaryClip(ClipData.newPlainText(null, statusUrl))
}
return@setOnMenuItemClickListener true return@setOnMenuItemClickListener true
} }
R.id.status_open_as -> { R.id.status_open_as -> {

View File

@ -0,0 +1,67 @@
/*
* Copyright 2024 Pachli Association
*
* This file is a part of Pachli.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Pachli; if not,
* see <http://www.gnu.org/licenses>.
*/
package app.pachli.core.ui
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Build
import android.widget.Toast
import androidx.annotation.StringRes
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
/**
* Copies plain text to the clipboard as the primary clip.
*/
class ClipboardUseCase @Inject constructor(
@ApplicationContext val context: Context,
) {
private val clipboard: ClipboardManager by lazy {
ContextCompat.getSystemService(
context,
ClipboardManager::class.java,
) as ClipboardManager
}
/**
* Copies [text] to the clipboard as the primary clip, with optional
* [label]. If necessary displays a toast showing [message] to confirm
* copy is complete.
*
* @param text Text to copy.
* @param message Optional message to show after completion.
* @param label Optional user-visible label to associate with the copied
* text, see [ClipData.newPlainText].
*/
fun copyTextTo(
text: CharSequence,
@StringRes message: Int = R.string.item_copied,
label: CharSequence = "",
) {
clipboard.setPrimaryClip(ClipData.newPlainText(label, text))
notify(message)
}
private fun notify(@StringRes message: Int) {
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
Toast.makeText(context, context.getString(message), Toast.LENGTH_SHORT).show()
}
}
}

View File

@ -17,18 +17,15 @@
package app.pachli.core.ui.accessibility package app.pachli.core.ui.accessibility
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.os.Build
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ArrayAdapter import android.widget.ArrayAdapter
import android.widget.Toast
import androidx.core.content.ContextCompat
import app.pachli.core.ui.R import app.pachli.core.ui.R
import app.pachli.core.ui.databinding.SimpleListItem1CopyButtonBinding import app.pachli.core.ui.databinding.SimpleListItem1CopyButtonBinding
import app.pachli.core.ui.di.UseCaseEntryPoint
import dagger.hilt.android.EntryPointAccessors
/** /**
* An [ArrayAdapter] that shows a "copy" button next to each item. * An [ArrayAdapter] that shows a "copy" button next to each item.
@ -49,27 +46,16 @@ class ArrayAdapterWithCopyButton<T : CharSequence>(
fun onClick(position: Int) fun onClick(position: Int)
} }
private val useCaseEntryPoint = EntryPointAccessors.fromApplication<UseCaseEntryPoint>(context.applicationContext)
private val clipboard = useCaseEntryPoint.clipboardUseCase
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val binding = if (convertView == null) { val binding = if (convertView == null) {
SimpleListItem1CopyButtonBinding.inflate(LayoutInflater.from(context), parent, false).apply { SimpleListItem1CopyButtonBinding.inflate(LayoutInflater.from(context), parent, false).apply {
text1.setOnClickListener { listener.onClick(position) } text1.setOnClickListener { listener.onClick(position) }
copy.setOnClickListener { copy.setOnClickListener {
getItem(position)?.let { text -> getItem(position)?.let { clipboard.copyTextTo(it) }
val clipboard = ContextCompat.getSystemService(
context,
ClipboardManager::class.java,
) as ClipboardManager
val clip = ClipData.newPlainText("", text)
clipboard.setPrimaryClip(clip)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
Toast.makeText(
context,
context.getString(R.string.item_copied),
Toast.LENGTH_SHORT,
).show()
}
}
} }
} }
} else { } else {

View File

@ -0,0 +1,33 @@
/*
* Copyright 2024 Pachli Association
*
* This file is a part of Pachli.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Pachli; if not,
* see <http://www.gnu.org/licenses>.
*/
package app.pachli.core.ui.di
import app.pachli.core.ui.ClipboardUseCase
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
/**
* Entry point for use cases that need to be field-injected in to classes
* Hilt does not manage.
*/
@EntryPoint
@InstallIn(SingletonComponent::class)
interface UseCaseEntryPoint {
val clipboardUseCase: ClipboardUseCase
}

View File

@ -17,8 +17,6 @@
package app.pachli.feature.about package app.pachli.feature.about
import android.content.ClipData
import android.content.ClipboardManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.text.SpannableString import android.text.SpannableString
@ -28,9 +26,7 @@ import android.text.style.URLSpan
import android.text.util.Linkify import android.text.util.Linkify
import android.view.View import android.view.View
import android.widget.TextView import android.widget.TextView
import android.widget.Toast
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.core.content.ContextCompat.getSystemService
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@ -39,13 +35,18 @@ import app.pachli.core.common.extensions.hide
import app.pachli.core.common.extensions.show import app.pachli.core.common.extensions.show
import app.pachli.core.common.extensions.viewBinding import app.pachli.core.common.extensions.viewBinding
import app.pachli.core.common.util.versionName import app.pachli.core.common.util.versionName
import app.pachli.core.ui.ClipboardUseCase
import app.pachli.core.ui.NoUnderlineURLSpan import app.pachli.core.ui.NoUnderlineURLSpan
import app.pachli.feature.about.databinding.FragmentAboutBinding import app.pachli.feature.about.databinding.FragmentAboutBinding
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@AndroidEntryPoint @AndroidEntryPoint
class AboutFragment : Fragment(R.layout.fragment_about) { class AboutFragment : Fragment(R.layout.fragment_about) {
@Inject
lateinit var clipboard: ClipboardUseCase
private val viewModel: AboutFragmentViewModel by viewModels() private val viewModel: AboutFragmentViewModel by viewModels()
private val binding by viewBinding(FragmentAboutBinding::bind) private val binding by viewBinding(FragmentAboutBinding::bind)
@ -100,16 +101,7 @@ class AboutFragment : Fragment(R.layout.fragment_about) {
binding.copyDeviceInfo.setOnClickListener { binding.copyDeviceInfo.setOnClickListener {
val text = "$version\n\nDevice:\n\n$deviceInfo\n\nAccount:\n\n${binding.accountInfo.text}" val text = "$version\n\nDevice:\n\n$deviceInfo\n\nAccount:\n\n${binding.accountInfo.text}"
val clipboard = getSystemService(requireContext(), ClipboardManager::class.java) as ClipboardManager clipboard.copyTextTo(text, R.string.about_copied, "Pachli version information")
val clip = ClipData.newPlainText("Pachli version information", text)
clipboard.setPrimaryClip(clip)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
Toast.makeText(
requireContext(),
getString(R.string.about_copied),
Toast.LENGTH_SHORT,
).show()
}
} }
} }