Repository: tuskyapp/Tusky (language: kotlin)

Issue:
emoji character count
[Issue text goes here].

* * * *
- Tusky Version: 23
- Android Version: 13
- Android Device: Xiaomi note 10 pro
- Mastodon instance (if applicable): mastodon.uno

- [x ] I searched or browsed the repo’s other issues to ensure this is not a duplicate.

When I use an emoji inside a toot it is counted as if I inserted 3, in other apps it is counted as a single character


Discussion hints:
nan

Pull request: correctly count emojis when composing a post
Thx to @evant for the help 

closes #4140

Test command: ./gradlew :app:testBlueDebugUnitTest --tests "com.keylesspalace.tusky.components.compose.ComposeTokenizerTest" --tests "com.keylesspalace.tusky.components.compose.ComposeActivityTest" --tests "com.keylesspalace.tusky.components.compose.StatusLengthTest"

Fix patch:
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt
index a87b60f587..0ee91e48f6 100644
--- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt
@@ -25,6 +25,7 @@ import android.content.pm.PackageManager
 import android.graphics.Bitmap
 import android.graphics.PorterDuff
 import android.graphics.PorterDuffColorFilter
+import android.icu.text.BreakIterator
 import android.net.Uri
 import android.os.Build
 import android.os.Bundle
@@ -1406,7 +1407,7 @@ class ComposeActivity :
          */
         @JvmStatic
         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().perceivedCharacterLength() - body.getSpans(0, body.length, URLSpan::class.java)
                 .fold(0) { acc, span ->
                     // Accumulate a count of characters to be *ignored* in the final length
                     acc + when (span) {
@@ -1419,15 +1420,25 @@ class ComposeActivity :
                         }
                         else -> {
                             // Expected to be negative if the URL length < maxUrlLength
-                            span.url.length - urlLength
+                            span.url.perceivedCharacterLength() - urlLength
                         }
                     }
                 }
 
             // Content warning text is treated as is, URLs or mentions there are not special
-            contentWarning?.let { length += it.length }
-
+            contentWarning?.let { length += it.toString().perceivedCharacterLength() }
             return length
         }
+
+        // String.length would count emojis as multiple characters but Mastodon counts them as 1, so we need this workaround
+        private fun String.perceivedCharacterLength(): Int {
+            val breakIterator = BreakIterator.getCharacterInstance()
+            breakIterator.setText(this)
+            var count = 0
+            while (breakIterator.next() != BreakIterator.DONE) {
+                count++
+            }
+            return count
+        }
     }
 }


Test patch:
diff --git a/app/src/test/java/com/keylesspalace/tusky/components/compose/ComposeActivity/ComposeActivityTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/compose/ComposeActivityTest.kt
similarity index 94%
rename from app/src/test/java/com/keylesspalace/tusky/components/compose/ComposeActivity/ComposeActivityTest.kt
rename to app/src/test/java/com/keylesspalace/tusky/components/compose/ComposeActivityTest.kt
index 451b5c09a0..f46550e047 100644
--- a/app/src/test/java/com/keylesspalace/tusky/components/compose/ComposeActivity/ComposeActivityTest.kt
+++ b/app/src/test/java/com/keylesspalace/tusky/components/compose/ComposeActivityTest.kt
@@ -15,7 +15,7 @@
  * see <http://www.gnu.org/licenses>.
  */
 
-package com.keylesspalace.tusky.components.compose.ComposeActivity
+package com.keylesspalace.tusky.components.compose
 
 import android.content.Intent
 import android.os.Looper.getMainLooper
@@ -24,8 +24,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
 import at.connyduck.calladapter.networkresult.NetworkResult
 import com.google.gson.Gson
 import com.keylesspalace.tusky.R
-import com.keylesspalace.tusky.components.compose.ComposeActivity
-import com.keylesspalace.tusky.components.compose.ComposeViewModel
 import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
 import com.keylesspalace.tusky.db.AccountEntity
 import com.keylesspalace.tusky.db.AccountManager
@@ -252,7 +250,21 @@ class ComposeActivityTest {
     fun whenTextContainsNoUrl_everyCharacterIsCounted() {
         val content = "This is test content please ignore thx "
         insertSomeTextInContent(content)
-        assertEquals(activity.calculateTextLength(), content.length)
+        assertEquals(content.length, activity.calculateTextLength())
+    }
+
+    @Test
+    fun whenTextContainsEmoji_emojisAreCountedAsOneCharacter() {
+        val content = "Test 😜"
+        insertSomeTextInContent(content)
+        assertEquals(6, activity.calculateTextLength())
+    }
+
+    @Test
+    fun whenTextContainsUrlWithEmoji_ellipsizedUrlIsCountedCorrectly() {
+        val content = "https://🤪.com"
+        insertSomeTextInContent(content)
+        assertEquals(InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL, activity.calculateTextLength())
     }
 
     @Test
@@ -260,7 +272,7 @@ class ComposeActivityTest {
         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 additionalContent = "Check out this @image #search result: "
         insertSomeTextInContent(additionalContent + url)
-        assertEquals(activity.calculateTextLength(), additionalContent.length + InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL)
+        assertEquals(additionalContent.length + InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL, activity.calculateTextLength())
     }
 
     @Test
@@ -269,7 +281,7 @@ class ComposeActivityTest {
         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 additionalContent = " Check out this @image #search result: "
         insertSomeTextInContent(shortUrl + additionalContent + url)
-        assertEquals(activity.calculateTextLength(), additionalContent.length + (InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL * 2))
+        assertEquals(additionalContent.length + (InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL * 2), activity.calculateTextLength())
     }
 
     @Test
@@ -277,7 +289,7 @@ class ComposeActivityTest {
         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 additionalContent = " Check out this @image #search result: "
         insertSomeTextInContent(url + additionalContent + url)
-        assertEquals(activity.calculateTextLength(), additionalContent.length + (InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL * 2))
+        assertEquals(additionalContent.length + (InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL * 2), activity.calculateTextLength())
     }
 
     @Test
@@ -289,7 +301,7 @@ class ComposeActivityTest {
         setupActivity()
         shadowOf(getMainLooper()).idle()
         insertSomeTextInContent(additionalContent + url)
-        assertEquals(activity.calculateTextLength(), additionalContent.length + customUrlLength)
+        assertEquals(additionalContent.length + customUrlLength, activity.calculateTextLength())
     }
 
     @Test
@@ -301,7 +313,7 @@ class ComposeActivityTest {
         setupActivity()
         shadowOf(getMainLooper()).idle()
         insertSomeTextInContent(additionalContent + url)
-        assertEquals(activity.calculateTextLength(), additionalContent.length + customUrlLength)
+        assertEquals(additionalContent.length + customUrlLength, activity.calculateTextLength())
     }
 
     @Test
@@ -314,7 +326,7 @@ class ComposeActivityTest {
         setupActivity()
         shadowOf(getMainLooper()).idle()
         insertSomeTextInContent(shortUrl + additionalContent + url)
-        assertEquals(activity.calculateTextLength(), additionalContent.length + (customUrlLength * 2))
+        assertEquals(additionalContent.length + (customUrlLength * 2), activity.calculateTextLength())
     }
 
     @Test
@@ -327,7 +339,7 @@ class ComposeActivityTest {
         setupActivity()
         shadowOf(getMainLooper()).idle()
         insertSomeTextInContent(shortUrl + additionalContent + url)
-        assertEquals(activity.calculateTextLength(), additionalContent.length + (customUrlLength * 2))
+        assertEquals(additionalContent.length + (customUrlLength * 2), activity.calculateTextLength())
     }
 
     @Test
@@ -339,7 +351,7 @@ class ComposeActivityTest {
         setupActivity()
         shadowOf(getMainLooper()).idle()
         insertSomeTextInContent(url + additionalContent + url)
-        assertEquals(activity.calculateTextLength(), additionalContent.length + (customUrlLength * 2))
+        assertEquals(additionalContent.length + (customUrlLength * 2), activity.calculateTextLength())
     }
 
     @Test
@@ -351,7 +363,7 @@ class ComposeActivityTest {
         setupActivity()
         shadowOf(getMainLooper()).idle()
         insertSomeTextInContent(url + additionalContent + url)
-        assertEquals(activity.calculateTextLength(), additionalContent.length + (customUrlLength * 2))
+        assertEquals(additionalContent.length + (customUrlLength * 2), activity.calculateTextLength())
     }
 
     @Test
diff --git a/app/src/test/java/com/keylesspalace/tusky/components/compose/ComposeTokenizer/ComposeTokenizerTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/compose/ComposeTokenizerTest.kt
similarity index 96%
rename from app/src/test/java/com/keylesspalace/tusky/components/compose/ComposeTokenizer/ComposeTokenizerTest.kt
rename to app/src/test/java/com/keylesspalace/tusky/components/compose/ComposeTokenizerTest.kt
index 04c692bce0..3c801dcbb2 100644
--- a/app/src/test/java/com/keylesspalace/tusky/components/compose/ComposeTokenizer/ComposeTokenizerTest.kt
+++ b/app/src/test/java/com/keylesspalace/tusky/components/compose/ComposeTokenizerTest.kt
@@ -13,9 +13,8 @@
  * You should have received a copy of the GNU General Public License along with Tusky; if not,
  * see <http://www.gnu.org/licenses>. */
 
-package com.keylesspalace.tusky.components.compose.ComposeTokenizer
+package com.keylesspalace.tusky.components.compose
 
-import com.keylesspalace.tusky.components.compose.ComposeTokenizer
 import org.junit.Assert
 import org.junit.Test
 import org.junit.runner.RunWith
diff --git a/app/src/test/java/com/keylesspalace/tusky/components/compose/ComposeActivity/StatusLengthTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/compose/StatusLengthTest.kt
similarity index 90%
rename from app/src/test/java/com/keylesspalace/tusky/components/compose/ComposeActivity/StatusLengthTest.kt
rename to app/src/test/java/com/keylesspalace/tusky/components/compose/StatusLengthTest.kt
index 81c40a556f..5f80963052 100644
--- a/app/src/test/java/com/keylesspalace/tusky/components/compose/ComposeActivity/StatusLengthTest.kt
+++ b/app/src/test/java/com/keylesspalace/tusky/components/compose/StatusLengthTest.kt
@@ -15,29 +15,29 @@
  * see <http://www.gnu.org/licenses>.
  */
 
-package com.keylesspalace.tusky.components.compose.ComposeActivity
+package com.keylesspalace.tusky.components.compose
 
 import com.keylesspalace.tusky.SpanUtilsTest
-import com.keylesspalace.tusky.components.compose.ComposeActivity
 import com.keylesspalace.tusky.util.highlightSpans
 import org.junit.Assert.assertEquals
 import org.junit.Test
 import org.junit.runner.RunWith
-import org.junit.runners.Parameterized
+import org.robolectric.ParameterizedRobolectricTestRunner
 
-@RunWith(Parameterized::class)
+@RunWith(ParameterizedRobolectricTestRunner::class)
 class StatusLengthTest(
     private val text: String,
     private val expectedLength: Int
 ) {
     companion object {
-        @Parameterized.Parameters(name = "{0}")
+        @ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
         @JvmStatic
         fun data(): Iterable<Any> {
             return listOf(
                 arrayOf("", 0),
                 arrayOf(" ", 1),
                 arrayOf("123", 3),
+                arrayOf("🫣", 1),
                 // "@user@server" should be treated as "@user"
                 arrayOf("123 @example@example.org", 12),
                 // URLs under 23 chars are treated as 23 chars