fix: Calculate length of posts and polls with emojis correctly (#315)
Mastodon counts post lengths by considering emojis to be single characters, no matter how many unicode code points they are composed of. So "😜" has length 1. Pachli was using `String.length`, which considers "😜" as length 2. Correct the calculation by using a BreakIterator to count the characters in the string, which treats multi-character emojis as a length 1. Poll options had a similar problem, exacerbated by the Mastodon web UI also having the same problem, see https://github.com/mastodon/mastodon/issues/28336. Fix that by creating `MastodonLengthFilter`, an `InputFilter` that does the right thing for regular text that may contain emojis. See also https://github.com/tuskyapp/Tusky/pull/4152, which has the fix for status length but not polls. --------- Co-authored-by: Konrad Pozniak <opensource@connyduck.at>
This commit is contained in:
parent
239f5cb274
commit
098983f401
|
@ -872,7 +872,7 @@
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||||
<location
|
<location
|
||||||
file="src/main/java/app/pachli/components/account/AccountActivity.kt"
|
file="src/main/java/app/pachli/components/account/AccountActivity.kt"
|
||||||
line="484"
|
line="492"
|
||||||
column="9"/>
|
column="9"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -2082,7 +2082,7 @@
|
||||||
errorLine2=" ~~~~~~~">
|
errorLine2=" ~~~~~~~">
|
||||||
<location
|
<location
|
||||||
file="src/main/java/app/pachli/components/account/AccountActivity.kt"
|
file="src/main/java/app/pachli/components/account/AccountActivity.kt"
|
||||||
line="372"
|
line="380"
|
||||||
column="29"/>
|
column="29"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -2093,7 +2093,7 @@
|
||||||
errorLine2=" ~~~~~~~">
|
errorLine2=" ~~~~~~~">
|
||||||
<location
|
<location
|
||||||
file="src/main/java/app/pachli/components/account/AccountActivity.kt"
|
file="src/main/java/app/pachli/components/account/AccountActivity.kt"
|
||||||
line="375"
|
line="383"
|
||||||
column="29"/>
|
column="29"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -2104,7 +2104,7 @@
|
||||||
errorLine2=" ~~~~~~~">
|
errorLine2=" ~~~~~~~">
|
||||||
<location
|
<location
|
||||||
file="src/main/java/app/pachli/components/account/AccountActivity.kt"
|
file="src/main/java/app/pachli/components/account/AccountActivity.kt"
|
||||||
line="381"
|
line="389"
|
||||||
column="21"/>
|
column="21"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -2115,7 +2115,7 @@
|
||||||
errorLine2=" ~~~~~~~">
|
errorLine2=" ~~~~~~~">
|
||||||
<location
|
<location
|
||||||
file="src/main/java/app/pachli/components/account/AccountActivity.kt"
|
file="src/main/java/app/pachli/components/account/AccountActivity.kt"
|
||||||
line="382"
|
line="390"
|
||||||
column="21"/>
|
column="21"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -2126,7 +2126,7 @@
|
||||||
errorLine2=" ~~~~~~~">
|
errorLine2=" ~~~~~~~">
|
||||||
<location
|
<location
|
||||||
file="src/main/java/app/pachli/components/account/AccountActivity.kt"
|
file="src/main/java/app/pachli/components/account/AccountActivity.kt"
|
||||||
line="384"
|
line="392"
|
||||||
column="21"/>
|
column="21"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -2137,7 +2137,7 @@
|
||||||
errorLine2=" ~~~~~~~">
|
errorLine2=" ~~~~~~~">
|
||||||
<location
|
<location
|
||||||
file="src/main/java/app/pachli/components/account/AccountActivity.kt"
|
file="src/main/java/app/pachli/components/account/AccountActivity.kt"
|
||||||
line="394"
|
line="402"
|
||||||
column="21"/>
|
column="21"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -2159,7 +2159,7 @@
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~">
|
errorLine2=" ~~~~~~~~~~~~~~~~~">
|
||||||
<location
|
<location
|
||||||
file="src/main/java/app/pachli/components/preference/AccountPreferencesFragment.kt"
|
file="src/main/java/app/pachli/components/preference/AccountPreferencesFragment.kt"
|
||||||
line="318"
|
line="309"
|
||||||
column="29"/>
|
column="29"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -2170,7 +2170,7 @@
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~">
|
errorLine2=" ~~~~~~~~~~~~~~~~~">
|
||||||
<location
|
<location
|
||||||
file="src/main/java/app/pachli/components/preference/AccountPreferencesFragment.kt"
|
file="src/main/java/app/pachli/components/preference/AccountPreferencesFragment.kt"
|
||||||
line="324"
|
line="315"
|
||||||
column="25"/>
|
column="25"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -2324,7 +2324,7 @@
|
||||||
errorLine2=" ~~~~~~~~~">
|
errorLine2=" ~~~~~~~~~">
|
||||||
<location
|
<location
|
||||||
file="src/main/java/app/pachli/BaseActivity.kt"
|
file="src/main/java/app/pachli/BaseActivity.kt"
|
||||||
line="105"
|
line="103"
|
||||||
column="13"/>
|
column="13"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -3138,7 +3138,7 @@
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
|
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
|
||||||
<location
|
<location
|
||||||
file="src/main/java/app/pachli/MainActivity.kt"
|
file="src/main/java/app/pachli/MainActivity.kt"
|
||||||
line="725"
|
line="722"
|
||||||
column="17"/>
|
column="17"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -3149,7 +3149,7 @@
|
||||||
errorLine2=" ~~~~~~~">
|
errorLine2=" ~~~~~~~">
|
||||||
<location
|
<location
|
||||||
file="src/main/java/app/pachli/MainActivity.kt"
|
file="src/main/java/app/pachli/MainActivity.kt"
|
||||||
line="728"
|
line="725"
|
||||||
column="21"/>
|
column="21"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -3158,20 +3158,20 @@
|
||||||
message="Access to `private` method `secondaryDrawerItem` of class `MainActivityKt` requires synthetic accessor"
|
message="Access to `private` method `secondaryDrawerItem` of class `MainActivityKt` requires synthetic accessor"
|
||||||
errorLine1=" secondaryDrawerItem {"
|
errorLine1=" secondaryDrawerItem {"
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
|
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
|
||||||
|
<location
|
||||||
|
file="src/main/java/app/pachli/MainActivity.kt"
|
||||||
|
line="730"
|
||||||
|
column="17"/>
|
||||||
|
</issue>
|
||||||
|
|
||||||
|
<issue
|
||||||
|
id="SyntheticAccessor"
|
||||||
|
message="Access to `private` method `setOnClick` of class `MainActivityKt` requires synthetic accessor"
|
||||||
|
errorLine1=" onClick = {"
|
||||||
|
errorLine2=" ~~~~~~~">
|
||||||
<location
|
<location
|
||||||
file="src/main/java/app/pachli/MainActivity.kt"
|
file="src/main/java/app/pachli/MainActivity.kt"
|
||||||
line="733"
|
line="733"
|
||||||
column="17"/>
|
|
||||||
</issue>
|
|
||||||
|
|
||||||
<issue
|
|
||||||
id="SyntheticAccessor"
|
|
||||||
message="Access to `private` method `setOnClick` of class `MainActivityKt` requires synthetic accessor"
|
|
||||||
errorLine1=" onClick = {"
|
|
||||||
errorLine2=" ~~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/java/app/pachli/MainActivity.kt"
|
|
||||||
line="736"
|
|
||||||
column="21"/>
|
column="21"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -3182,7 +3182,7 @@
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
|
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
|
||||||
<location
|
<location
|
||||||
file="src/main/java/app/pachli/MainActivity.kt"
|
file="src/main/java/app/pachli/MainActivity.kt"
|
||||||
line="741"
|
line="738"
|
||||||
column="17"/>
|
column="17"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -3193,7 +3193,7 @@
|
||||||
errorLine2=" ~~~~~~~">
|
errorLine2=" ~~~~~~~">
|
||||||
<location
|
<location
|
||||||
file="src/main/java/app/pachli/MainActivity.kt"
|
file="src/main/java/app/pachli/MainActivity.kt"
|
||||||
line="744"
|
line="741"
|
||||||
column="21"/>
|
column="21"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -3204,7 +3204,7 @@
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~">
|
errorLine2=" ~~~~~~~~~~~~~~~~~">
|
||||||
<location
|
<location
|
||||||
file="src/main/java/app/pachli/MainActivity.kt"
|
file="src/main/java/app/pachli/MainActivity.kt"
|
||||||
line="751"
|
line="748"
|
||||||
column="21"/>
|
column="21"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -3215,7 +3215,7 @@
|
||||||
errorLine2=" ~~~~~~~">
|
errorLine2=" ~~~~~~~">
|
||||||
<location
|
<location
|
||||||
file="src/main/java/app/pachli/MainActivity.kt"
|
file="src/main/java/app/pachli/MainActivity.kt"
|
||||||
line="754"
|
line="751"
|
||||||
column="25"/>
|
column="25"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -3226,7 +3226,7 @@
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~">
|
errorLine2=" ~~~~~~~~~~~~~~~~~">
|
||||||
<location
|
<location
|
||||||
file="src/main/java/app/pachli/MainActivity.kt"
|
file="src/main/java/app/pachli/MainActivity.kt"
|
||||||
line="763"
|
line="760"
|
||||||
column="17"/>
|
column="17"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -3237,7 +3237,7 @@
|
||||||
errorLine2=" ~~~~~~~">
|
errorLine2=" ~~~~~~~">
|
||||||
<location
|
<location
|
||||||
file="src/main/java/app/pachli/MainActivity.kt"
|
file="src/main/java/app/pachli/MainActivity.kt"
|
||||||
line="766"
|
line="763"
|
||||||
column="21"/>
|
column="21"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -3248,7 +3248,7 @@
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
|
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
|
||||||
<location
|
<location
|
||||||
file="src/main/java/app/pachli/MainActivity.kt"
|
file="src/main/java/app/pachli/MainActivity.kt"
|
||||||
line="779"
|
line="776"
|
||||||
column="17"/>
|
column="17"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -3259,7 +3259,7 @@
|
||||||
errorLine2=" ~~~~~~~">
|
errorLine2=" ~~~~~~~">
|
||||||
<location
|
<location
|
||||||
file="src/main/java/app/pachli/MainActivity.kt"
|
file="src/main/java/app/pachli/MainActivity.kt"
|
||||||
line="783"
|
line="780"
|
||||||
column="21"/>
|
column="21"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -3270,7 +3270,7 @@
|
||||||
errorLine2=" ~~~~~~~">
|
errorLine2=" ~~~~~~~">
|
||||||
<location
|
<location
|
||||||
file="src/main/java/app/pachli/MainActivity.kt"
|
file="src/main/java/app/pachli/MainActivity.kt"
|
||||||
line="908"
|
line="905"
|
||||||
column="17"/>
|
column="17"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -3281,7 +3281,7 @@
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~">
|
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||||
<location
|
<location
|
||||||
file="src/main/java/app/pachli/MainActivity.kt"
|
file="src/main/java/app/pachli/MainActivity.kt"
|
||||||
line="910"
|
line="907"
|
||||||
column="17"/>
|
column="17"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -3292,7 +3292,7 @@
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~">
|
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||||
<location
|
<location
|
||||||
file="src/main/java/app/pachli/MainActivity.kt"
|
file="src/main/java/app/pachli/MainActivity.kt"
|
||||||
line="921"
|
line="918"
|
||||||
column="17"/>
|
column="17"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -3904,7 +3904,7 @@
|
||||||
errorLine2=" ~~~~~~~~">
|
errorLine2=" ~~~~~~~~">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/layout/activity_account.xml"
|
file="src/main/res/layout/activity_account.xml"
|
||||||
line="242"
|
line="243"
|
||||||
column="22"/>
|
column="22"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -3915,7 +3915,7 @@
|
||||||
errorLine2=" ~~~~~~~~">
|
errorLine2=" ~~~~~~~~">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/layout/activity_account.xml"
|
file="src/main/res/layout/activity_account.xml"
|
||||||
line="278"
|
line="279"
|
||||||
column="26"/>
|
column="26"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -3926,7 +3926,7 @@
|
||||||
errorLine2=" ~~~~~~~~">
|
errorLine2=" ~~~~~~~~">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/layout/activity_account.xml"
|
file="src/main/res/layout/activity_account.xml"
|
||||||
line="305"
|
line="306"
|
||||||
column="26"/>
|
column="26"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -3937,7 +3937,7 @@
|
||||||
errorLine2=" ~~~~~~~~">
|
errorLine2=" ~~~~~~~~">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/layout/activity_account.xml"
|
file="src/main/res/layout/activity_account.xml"
|
||||||
line="320"
|
line="321"
|
||||||
column="26"/>
|
column="26"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -3948,7 +3948,7 @@
|
||||||
errorLine2=" ~~~~~~~~">
|
errorLine2=" ~~~~~~~~">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/layout/activity_account.xml"
|
file="src/main/res/layout/activity_account.xml"
|
||||||
line="347"
|
line="348"
|
||||||
column="26"/>
|
column="26"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -3959,7 +3959,7 @@
|
||||||
errorLine2=" ~~~~~~~~">
|
errorLine2=" ~~~~~~~~">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/layout/activity_account.xml"
|
file="src/main/res/layout/activity_account.xml"
|
||||||
line="378"
|
line="379"
|
||||||
column="26"/>
|
column="26"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -3970,7 +3970,7 @@
|
||||||
errorLine2=" ~~~~~~~~">
|
errorLine2=" ~~~~~~~~">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/layout/activity_account.xml"
|
file="src/main/res/layout/activity_account.xml"
|
||||||
line="408"
|
line="409"
|
||||||
column="26"/>
|
column="26"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
|
|
@ -28,6 +28,7 @@ import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
|
import android.text.InputFilter
|
||||||
import android.text.Spanned
|
import android.text.Spanned
|
||||||
import android.text.style.URLSpan
|
import android.text.style.URLSpan
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
|
@ -74,6 +75,7 @@ import app.pachli.components.compose.dialog.showAddPollDialog
|
||||||
import app.pachli.components.compose.view.ComposeOptionsListener
|
import app.pachli.components.compose.view.ComposeOptionsListener
|
||||||
import app.pachli.components.compose.view.ComposeScheduleView
|
import app.pachli.components.compose.view.ComposeScheduleView
|
||||||
import app.pachli.components.instanceinfo.InstanceInfoRepository
|
import app.pachli.components.instanceinfo.InstanceInfoRepository
|
||||||
|
import app.pachli.core.common.string.mastodonLength
|
||||||
import app.pachli.core.database.model.AccountEntity
|
import app.pachli.core.database.model.AccountEntity
|
||||||
import app.pachli.core.navigation.ComposeActivityIntent
|
import app.pachli.core.navigation.ComposeActivityIntent
|
||||||
import app.pachli.core.navigation.ComposeActivityIntent.ComposeOptions
|
import app.pachli.core.navigation.ComposeActivityIntent.ComposeOptions
|
||||||
|
@ -1323,6 +1325,7 @@ class ComposeActivity :
|
||||||
* (https://docs.joinmastodon.org/user/posting/#mentions)
|
* (https://docs.joinmastodon.org/user/posting/#mentions)
|
||||||
* - Hashtags are always treated as their actual length, including the "#"
|
* - Hashtags are always treated as their actual length, including the "#"
|
||||||
* (https://docs.joinmastodon.org/user/posting/#hashtags)
|
* (https://docs.joinmastodon.org/user/posting/#hashtags)
|
||||||
|
* - Emojis are treated as a single character
|
||||||
*
|
*
|
||||||
* Content warning text is always treated as its full length, URLs and other entities
|
* Content warning text is always treated as its full length, URLs and other entities
|
||||||
* are not treated differently.
|
* are not treated differently.
|
||||||
|
@ -1332,9 +1335,8 @@ class ComposeActivity :
|
||||||
* @param urlLength the number of characters attributed to URLs
|
* @param urlLength the number of characters attributed to URLs
|
||||||
* @return the effective status length
|
* @return the effective status length
|
||||||
*/
|
*/
|
||||||
@JvmStatic
|
|
||||||
fun statusLength(body: Spanned, contentWarning: Spanned?, urlLength: Int): Int {
|
fun statusLength(body: Spanned, contentWarning: Spanned?, urlLength: Int): Int {
|
||||||
var length = body.length - body.getSpans(0, body.length, URLSpan::class.java)
|
var length = body.toString().mastodonLength() - body.getSpans(0, body.length, URLSpan::class.java)
|
||||||
.fold(0) { acc, span ->
|
.fold(0) { acc, span ->
|
||||||
// Accumulate a count of characters to be *ignored* in the final length
|
// Accumulate a count of characters to be *ignored* in the final length
|
||||||
acc + when (span) {
|
acc + when (span) {
|
||||||
|
@ -1347,15 +1349,50 @@ class ComposeActivity :
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
// Expected to be negative if the URL length < maxUrlLength
|
// Expected to be negative if the URL length < maxUrlLength
|
||||||
span.url.length - urlLength
|
span.url.mastodonLength() - urlLength
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Content warning text is treated as is, URLs or mentions there are not special
|
// Content warning text is treated as is, URLs or mentions there are not special
|
||||||
contentWarning?.let { length += it.length }
|
contentWarning?.let { length += it.toString().mastodonLength() }
|
||||||
|
|
||||||
return length
|
return length
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [InputFilter] that uses the "Mastodon" length of a string, where emojis always
|
||||||
|
* count as a single character.
|
||||||
|
*
|
||||||
|
* Unlike [InputFilter.LengthFilter] the source input is not trimmed if it is
|
||||||
|
* too long, it's just rejected.
|
||||||
|
*/
|
||||||
|
class MastodonLengthFilter(private val maxLength: Int) : InputFilter {
|
||||||
|
override fun filter(
|
||||||
|
source: CharSequence?,
|
||||||
|
start: Int,
|
||||||
|
end: Int,
|
||||||
|
dest: Spanned?,
|
||||||
|
dstart: Int,
|
||||||
|
dend: Int,
|
||||||
|
): CharSequence? {
|
||||||
|
val destRemovedLength = dest?.subSequence(dstart, dend).toString().mastodonLength()
|
||||||
|
val available = maxLength - dest.toString().mastodonLength() + destRemovedLength
|
||||||
|
val sourceLength = source?.subSequence(start, end).toString().mastodonLength()
|
||||||
|
|
||||||
|
// Not enough space to insert the new text
|
||||||
|
if (sourceLength > available) return REJECT
|
||||||
|
|
||||||
|
return ALLOW
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Filter result allowing the changes */
|
||||||
|
val ALLOW = null
|
||||||
|
|
||||||
|
/** Filter result preventing the changes */
|
||||||
|
const val REJECT = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,13 +16,13 @@
|
||||||
|
|
||||||
package app.pachli.components.compose.dialog
|
package app.pachli.components.compose.dialog
|
||||||
|
|
||||||
import android.text.InputFilter
|
|
||||||
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 androidx.core.widget.doOnTextChanged
|
import androidx.core.widget.doOnTextChanged
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import app.pachli.R
|
import app.pachli.R
|
||||||
|
import app.pachli.components.compose.ComposeActivity.Companion.MastodonLengthFilter
|
||||||
import app.pachli.databinding.ItemAddPollOptionBinding
|
import app.pachli.databinding.ItemAddPollOptionBinding
|
||||||
import app.pachli.util.BindingHolder
|
import app.pachli.util.BindingHolder
|
||||||
import app.pachli.util.visible
|
import app.pachli.util.visible
|
||||||
|
@ -45,7 +45,7 @@ class AddPollOptionsAdapter(
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemAddPollOptionBinding> {
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemAddPollOptionBinding> {
|
||||||
val binding = ItemAddPollOptionBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
val binding = ItemAddPollOptionBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||||
val holder = BindingHolder(binding)
|
val holder = BindingHolder(binding)
|
||||||
binding.optionEditText.filters = arrayOf(InputFilter.LengthFilter(maxOptionLength))
|
binding.optionEditText.filters = arrayOf(MastodonLengthFilter(maxOptionLength))
|
||||||
|
|
||||||
binding.optionEditText.doOnTextChanged { s, _, _, _ ->
|
binding.optionEditText.doOnTextChanged { s, _, _, _ ->
|
||||||
val pos = holder.bindingAdapterPosition
|
val pos = holder.bindingAdapterPosition
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
package app.pachli
|
package app.pachli
|
||||||
|
|
||||||
import android.text.Spannable
|
import app.pachli.core.testing.fakes.FakeSpannable
|
||||||
import app.pachli.util.highlightSpans
|
import app.pachli.util.highlightSpans
|
||||||
import org.junit.Assert
|
import org.junit.Assert
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
@ -127,55 +127,4 @@ class SpanUtilsTest {
|
||||||
Assert.assertEquals(expectedEndIndex, span.end)
|
Assert.assertEquals(expectedEndIndex, span.end)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class FakeSpannable(private val text: String) : Spannable {
|
|
||||||
val spans = mutableListOf<BoundedSpan>()
|
|
||||||
|
|
||||||
override fun setSpan(what: Any?, start: Int, end: Int, flags: Int) {
|
|
||||||
spans.add(BoundedSpan(what, start, end))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun <T : Any> getSpans(start: Int, end: Int, type: Class<T>): Array<T> {
|
|
||||||
return spans.filter { it.start >= start && it.end <= end && type.isInstance(it.span) }
|
|
||||||
.map { it.span }
|
|
||||||
.toTypedArray() as Array<T>
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun removeSpan(what: Any?) {
|
|
||||||
spans.removeIf { span -> span.span == what }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun toString(): String {
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
|
|
||||||
override val length: Int
|
|
||||||
get() = text.length
|
|
||||||
|
|
||||||
class BoundedSpan(val span: Any?, val start: Int, val end: Int)
|
|
||||||
|
|
||||||
override fun nextSpanTransition(start: Int, limit: Int, type: Class<*>?): Int {
|
|
||||||
throw NotImplementedError()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getSpanEnd(tag: Any?): Int {
|
|
||||||
throw NotImplementedError()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getSpanFlags(tag: Any?): Int {
|
|
||||||
throw NotImplementedError()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun get(index: Int): Char {
|
|
||||||
throw NotImplementedError()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun subSequence(startIndex: Int, endIndex: Int): CharSequence {
|
|
||||||
throw NotImplementedError()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getSpanStart(tag: Any?): Int {
|
|
||||||
throw NotImplementedError()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -238,6 +238,49 @@ class ComposeActivityTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun whenTextContainsEmoji_emojisAreCountedAsOneCharacter() {
|
||||||
|
val content = "Test 😜"
|
||||||
|
rule.launch()
|
||||||
|
rule.getScenario().onActivity {
|
||||||
|
insertSomeTextInContent(it, content)
|
||||||
|
assertEquals(6, it.calculateTextLength())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun whenTextContainsConesecutiveEmoji_emojisAreCountedAsSeparateCharacters() {
|
||||||
|
val content = "Test 😜😜"
|
||||||
|
rule.launch()
|
||||||
|
rule.getScenario().onActivity {
|
||||||
|
insertSomeTextInContent(it, content)
|
||||||
|
assertEquals(7, it.calculateTextLength())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun whenTextContainsUrlWithEmoji_ellipsizedUrlIsCountedCorrectly() {
|
||||||
|
val content = "https://🤪.com"
|
||||||
|
rule.launch()
|
||||||
|
rule.getScenario().onActivity {
|
||||||
|
insertSomeTextInContent(it, content)
|
||||||
|
assertEquals(
|
||||||
|
InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL,
|
||||||
|
it.calculateTextLength(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun whenTextContainsNonEnglishCharacters_lengthIsCountedCorrectly() {
|
||||||
|
val content = "こんにちは. General Kenobi" // "Hello there. General Kenobi"
|
||||||
|
rule.launch()
|
||||||
|
rule.getScenario().onActivity {
|
||||||
|
insertSomeTextInContent(it, content)
|
||||||
|
assertEquals(21, it.calculateTextLength())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun whenTextContainsUrl_onlyEllipsizedURLIsCounted() {
|
fun whenTextContainsUrl_onlyEllipsizedURLIsCounted() {
|
||||||
val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM:"
|
val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM:"
|
||||||
|
|
|
@ -0,0 +1,169 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2023 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.components.compose
|
||||||
|
|
||||||
|
import app.pachli.components.compose.ComposeActivity.Companion.MastodonLengthFilter
|
||||||
|
import app.pachli.components.compose.ComposeActivity.Companion.MastodonLengthFilter.Companion.ALLOW
|
||||||
|
import app.pachli.components.compose.ComposeActivity.Companion.MastodonLengthFilter.Companion.REJECT
|
||||||
|
import app.pachli.core.testing.fakes.FakeSpannable
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.junit.runners.Parameterized
|
||||||
|
import org.junit.runners.Parameterized.Parameters
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parameters to pass to [MastodonLengthFilter.filter] and the expected result.
|
||||||
|
*/
|
||||||
|
data class TestData(
|
||||||
|
val source: CharSequence?,
|
||||||
|
val start: Int,
|
||||||
|
val end: Int,
|
||||||
|
val dest: String,
|
||||||
|
val dstart: Int,
|
||||||
|
val dend: Int,
|
||||||
|
/** The expected result */
|
||||||
|
val want: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
@RunWith(Parameterized::class)
|
||||||
|
class MastodonLengthFilterTest(private val testData: TestData) {
|
||||||
|
companion object {
|
||||||
|
@Parameters(name = "{0}")
|
||||||
|
@JvmStatic
|
||||||
|
fun data(): Iterable<TestData> {
|
||||||
|
return listOf(
|
||||||
|
// Inserting 9 characters to empty string
|
||||||
|
TestData(
|
||||||
|
"some text",
|
||||||
|
0,
|
||||||
|
9,
|
||||||
|
"",
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
ALLOW,
|
||||||
|
),
|
||||||
|
// Appending 1 character to length 5 string
|
||||||
|
TestData(
|
||||||
|
"6",
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
"12345",
|
||||||
|
0,
|
||||||
|
5,
|
||||||
|
ALLOW,
|
||||||
|
),
|
||||||
|
// Replacing 1 character in middle of length 5 string
|
||||||
|
TestData(
|
||||||
|
"x",
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
"12345",
|
||||||
|
2,
|
||||||
|
3,
|
||||||
|
ALLOW,
|
||||||
|
),
|
||||||
|
// Replacing all characters in length 5 string
|
||||||
|
TestData(
|
||||||
|
"xxxxx",
|
||||||
|
0,
|
||||||
|
4,
|
||||||
|
"12345",
|
||||||
|
0,
|
||||||
|
4,
|
||||||
|
ALLOW,
|
||||||
|
),
|
||||||
|
// Deleting characters with a zero-length replacement
|
||||||
|
TestData(
|
||||||
|
"",
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
"12345",
|
||||||
|
0,
|
||||||
|
3,
|
||||||
|
ALLOW,
|
||||||
|
),
|
||||||
|
// Appending a 2 character emoji to a 9 character string
|
||||||
|
TestData(
|
||||||
|
"😜",
|
||||||
|
0,
|
||||||
|
2,
|
||||||
|
"123456789",
|
||||||
|
9,
|
||||||
|
9,
|
||||||
|
ALLOW,
|
||||||
|
),
|
||||||
|
// Extending a 10 character string by 1
|
||||||
|
TestData(
|
||||||
|
"x",
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
"1234567890",
|
||||||
|
10,
|
||||||
|
10,
|
||||||
|
REJECT,
|
||||||
|
),
|
||||||
|
// Extending a 9 character string by 2
|
||||||
|
TestData(
|
||||||
|
"xx",
|
||||||
|
0,
|
||||||
|
2,
|
||||||
|
"123456789",
|
||||||
|
9,
|
||||||
|
9,
|
||||||
|
REJECT,
|
||||||
|
),
|
||||||
|
// Replacing 2 characters in a 10 character string with 3
|
||||||
|
TestData(
|
||||||
|
"xxx",
|
||||||
|
0,
|
||||||
|
3,
|
||||||
|
"1234567890",
|
||||||
|
5,
|
||||||
|
7,
|
||||||
|
REJECT,
|
||||||
|
),
|
||||||
|
// Appending 2 x 2 character emojis to a 9 character string
|
||||||
|
TestData(
|
||||||
|
"😜😜",
|
||||||
|
0,
|
||||||
|
3,
|
||||||
|
"123456789",
|
||||||
|
9,
|
||||||
|
9,
|
||||||
|
REJECT,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun filter_matchesExpectations() {
|
||||||
|
val filter = MastodonLengthFilter(10)
|
||||||
|
assertThat(
|
||||||
|
filter.filter(
|
||||||
|
testData.source,
|
||||||
|
testData.start,
|
||||||
|
testData.end,
|
||||||
|
FakeSpannable(testData.dest),
|
||||||
|
testData.dstart,
|
||||||
|
testData.dend,
|
||||||
|
),
|
||||||
|
).isEqualTo(testData.want)
|
||||||
|
}
|
||||||
|
}
|
|
@ -17,7 +17,7 @@
|
||||||
|
|
||||||
package app.pachli.components.compose
|
package app.pachli.components.compose
|
||||||
|
|
||||||
import app.pachli.SpanUtilsTest
|
import app.pachli.core.testing.fakes.FakeSpannable
|
||||||
import app.pachli.util.highlightSpans
|
import app.pachli.util.highlightSpans
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
@ -53,7 +53,7 @@ class StatusLengthTest(
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun statusLength_matchesExpectations() {
|
fun statusLength_matchesExpectations() {
|
||||||
val spannedText = SpanUtilsTest.FakeSpannable(text)
|
val spannedText = FakeSpannable(text)
|
||||||
highlightSpans(spannedText, 0)
|
highlightSpans(spannedText, 0)
|
||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
|
@ -64,10 +64,10 @@ class StatusLengthTest(
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun statusLength_withCwText_matchesExpectations() {
|
fun statusLength_withCwText_matchesExpectations() {
|
||||||
val spannedText = SpanUtilsTest.FakeSpannable(text)
|
val spannedText = FakeSpannable(text)
|
||||||
highlightSpans(spannedText, 0)
|
highlightSpans(spannedText, 0)
|
||||||
|
|
||||||
val cwText = SpanUtilsTest.FakeSpannable(
|
val cwText = FakeSpannable(
|
||||||
"a @example@example.org #hashtagmention and http://example.org URL",
|
"a @example@example.org #hashtagmention and http://example.org URL",
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package app.pachli.core.common.string
|
package app.pachli.core.common.string
|
||||||
|
|
||||||
import android.text.Spanned
|
import android.text.Spanned
|
||||||
|
import java.text.BreakIterator
|
||||||
import java.util.Random
|
import java.util.Random
|
||||||
|
|
||||||
private const val POSSIBLE_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
private const val POSSIBLE_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||||
|
@ -44,6 +45,20 @@ fun String.isLessThanOrEqual(other: String): Boolean {
|
||||||
return this == other || isLessThan(other)
|
return this == other || isLessThan(other)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return the "Mastodon" length of a string. [String.length] counts emojis as
|
||||||
|
* multiple characters, but Mastodon treats them as a single character.
|
||||||
|
*/
|
||||||
|
fun String.mastodonLength(): Int {
|
||||||
|
val breakIterator = BreakIterator.getCharacterInstance()
|
||||||
|
breakIterator.setText(this)
|
||||||
|
var count = 0
|
||||||
|
while (breakIterator.next() != BreakIterator.DONE) {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
fun Spanned.trimTrailingWhitespace(): Spanned {
|
fun Spanned.trimTrailingWhitespace(): Spanned {
|
||||||
var i = length
|
var i = length
|
||||||
do {
|
do {
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<issues format="6" by="lint 8.1.2" type="baseline" client="gradle" dependencies="false" name="AGP (8.1.2)" variant="all" version="8.1.2">
|
<issues format="6" by="lint 8.2.0" type="baseline" client="gradle" dependencies="false" name="AGP (8.2.0)" variant="all" version="8.2.0">
|
||||||
|
|
||||||
<issue
|
<issue
|
||||||
id="InvalidPackage"
|
id="InvalidPackage"
|
||||||
|
@ -71,6 +71,17 @@
|
||||||
file="$GRADLE_USER_HOME/caches/modules-2/files-2.1/org.robolectric/utils/4.11.1/12828864aac49e8ebcf2de03d3d33919ca8a1b01/utils-4.11.1.jar"/>
|
file="$GRADLE_USER_HOME/caches/modules-2/files-2.1/org.robolectric/utils/4.11.1/12828864aac49e8ebcf2de03d3d33919ca8a1b01/utils-4.11.1.jar"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
<issue
|
||||||
|
id="NewApi"
|
||||||
|
message="Call requires API level 24 (current min is 23): `java.util.Collection#removeIf`"
|
||||||
|
errorLine1=" spans.removeIf { span -> span.span == what }"
|
||||||
|
errorLine2=" ~~~~~~~~">
|
||||||
|
<location
|
||||||
|
file="src/main/kotlin/app/pachli/core/testing/fakes/FakeSpannable.kt"
|
||||||
|
line="36"
|
||||||
|
column="15"/>
|
||||||
|
</issue>
|
||||||
|
|
||||||
<issue
|
<issue
|
||||||
id="NewApi"
|
id="NewApi"
|
||||||
message="Call requires API level 24 (current min is 23): `java.util.Map#getOrDefault`"
|
message="Call requires API level 24 (current min is 23): `java.util.Map#getOrDefault`"
|
||||||
|
|
|
@ -0,0 +1,71 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2023 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.testing.fakes
|
||||||
|
|
||||||
|
import android.text.Spannable
|
||||||
|
|
||||||
|
class FakeSpannable(private val text: String) : Spannable {
|
||||||
|
val spans = mutableListOf<BoundedSpan>()
|
||||||
|
|
||||||
|
override fun setSpan(what: Any?, start: Int, end: Int, flags: Int) {
|
||||||
|
spans.add(BoundedSpan(what, start, end))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun <T : Any> getSpans(start: Int, end: Int, type: Class<T>): Array<T> {
|
||||||
|
return spans.filter { it.start >= start && it.end <= end && type.isInstance(it.span) }
|
||||||
|
.map { it.span }
|
||||||
|
.toTypedArray() as Array<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun removeSpan(what: Any?) {
|
||||||
|
spans.removeIf { span -> span.span == what }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toString(): String {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
override val length: Int
|
||||||
|
get() = text.length
|
||||||
|
|
||||||
|
class BoundedSpan(val span: Any?, val start: Int, val end: Int)
|
||||||
|
|
||||||
|
override fun nextSpanTransition(start: Int, limit: Int, type: Class<*>?): Int {
|
||||||
|
throw NotImplementedError()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getSpanEnd(tag: Any?): Int {
|
||||||
|
throw NotImplementedError()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getSpanFlags(tag: Any?): Int {
|
||||||
|
throw NotImplementedError()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun get(index: Int): Char {
|
||||||
|
throw NotImplementedError()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun subSequence(startIndex: Int, endIndex: Int): CharSequence {
|
||||||
|
return text.subSequence(startIndex, endIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getSpanStart(tag: Any?): Int {
|
||||||
|
throw NotImplementedError()
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue