1 of 54

How a Single ß Can Break Everything

Vladimir Jovanović

2 of 54

Trusted expertise in

AI-powered communication

The trusted AI assistant for communication and productivity

Trusted by 40M+ people and 50K+ organizations

Works where you do

across 500,000+

web and desktop apps

3 of 54

Grammarly + Coda + Superhuman

Coda, the maker of powerful AI productivity tools, and Superhuman, the AI-native email app, are now a part of Grammarly.��Together, we aim to revolutionize how teams operate—enabling them to stay focused, aligned, and achieve results quickly.

4 of 54

Story time

  • Username and profile picture
  • Username is alphanumeric and limited to 30 characters
  • There is a characters’ counter under the input field

5 of 54

Story time

  • Username and profile picture
  • Username is alphanumeric and limited to 30 characters
  • There is a characters’ counter under the input field
  • Profile picture is generated from the username

6 of 54

Changing requirements

  • Allow users to add emojis to usernames
  • if (newValue.length <= 30 && � newValue.all { it.isLetterOrDigit() || it.isWhitespace() }�)

7 of 54

Changing requirements

  • Allow users to add emojis to usernames
  • if (newValue.length <= 30 && � newValue.all { it.isLetterOrDigit() || it.isWhitespace() }�)

8 of 54

Changing requirements

  • Allow users to add emojis to usernames
  • if (newValue.length <= 30 && � newValue.all { it.isLetterOrDigit() || it.isWhitespace() }�)

9 of 54

Unicode

10 of 54

Unicode � ☐

11 of 54

Unicode � ☐

12 of 54

The Dark Ages Before Unicode

  • Every System Had Its Own Encoding:
    • ASCII: 128 characters (English only)
    • ISO-8859-1: 256 characters (Western European)
    • Shift-JIS: Japanese characters
    • GB2312: Simplified Chinese
    • KOI8-R: Cyrillic/Russian

13 of 54

The Dark Ages Before Unicode

  • Every System Had Its Own Encoding:
    • ASCII: 128 characters (English only)
    • ISO-8859-1: 256 characters (Western European)
    • Shift-JIS: Japanese characters
    • GB2312: Simplified Chinese
    • KOI8-R: Cyrillic/Russian
  • 0xC4
    • ISO-8859-1 -> Ä
    • KOI8-R -> д

14 of 54

Unicode's Big Idea - Code Points

  • Unicode's solution: assign every character a unique number called a code point.
    • U+0041 = 'A'
    • U+03A9 = 'Ω'
    • U+4E2D = '中'
    • U+1F680 = '🚀'
  • The "U+" Notation is the standard way to write Unicode code points
  • Always hexadecimal and always with U+ prefix

15 of 54

Unicode's Big Idea - Code Points

  • Unicode's solution: assign every character a unique number called a code point.
    • U+0041 = 'A'
    • U+03A9 = 'Ω'
    • U+4E2D = '中'
    • U+1F680 = '🚀'
  • The "U+" Notation is the standard way to write Unicode code points
  • Always hexadecimal and always with U+ prefix
  • Range: U+0000 to U+10FFFF (over 1 million possible characters)

16 of 54

From Code Points to Bytes

  • Code point = The “idea” of a character
  • UTF-8 and UTF-16 = Ways to encode that idea into bytes
  • Like the number 42 written as: 42, 101010, XLII
  • Two different strategies for encoding the same data

17 of 54

From Code Points to Bytes

  • Code point = The “idea” of a character
  • UTF-8 and UTF-16 = Ways to encode that idea into bytes
  • Like the number 42 written as: 42, 101010, XLII
  • Two different strategies for encoding the same data
  • Unicode ≠ UTF-8
    • Unicode defines the character set (e.g. U+0041 = 'A')
    • UTF-8 and UTF-16 are encoding formats

18 of 54

UTF-8 - The Web's Choice

  • UTF-8 Strategy: Use 1 to 4 bytes per character, with a clever self-describing system.
  • Why UTF-8 Won the Internet:
    • ASCII Compatibility: Characters U+0000 to U+007F use exactly 1 byte - identical to ASCII

19 of 54

UTF-8 - The Web's Choice

  • UTF-8 Strategy: Use 1 to 4 bytes per character, with a clever self-describing system.
  • Why UTF-8 Won the Internet:
    • ASCII Compatibility: Characters U+0000 to U+007F use exactly 1 byte - identical to ASCII
    • Self-Synchronizing: You can find character boundaries anywhere in the stream

20 of 54

UTF-8 - The Web's Choice

  • UTF-8 Strategy: Use 1 to 4 bytes per character, with a clever self-describing system.
  • Why UTF-8 Won the Internet:
    • ASCII Compatibility: Characters U+0000 to U+007F use exactly 1 byte - identical to ASCII
    • Self-Synchronizing: You can find character boundaries anywhere in the stream
  • Usage Statistics:
    • >99% of all websites use UTF-8
    • Default for JSON, XML, HTML5
    • Standard for most programming languages

21 of 54

UTF-16 - Android's Internal Choice

  • All String objects use UTF-16 internally
  • 2 or 4 bytes per character (surrogate pairs for some)
  • "🚀".length() == 2, not 1

22 of 54

UTF-16 - Android's Internal Choice

  • All String objects use UTF-16 internally
  • 2 or 4 bytes per character (surrogate pairs for some)
  • "🚀".length() == 2, not 1
  • Why UTF-16? Java chose it in 1995 when Unicode was smaller

23 of 54

UTF-16 - Android's Internal Choice

  • All String objects use UTF-16 internally
  • 2 or 4 bytes per character (surrogate pairs for some)
  • "🚀".length() == 2, not 1
  • Why UTF-16? Java chose it in 1995 when Unicode was smaller

24 of 54

UTF-16 - Android's Internal Choice

  • All String objects use UTF-16 internally
  • 2 or 4 bytes per character (surrogate pairs for some)
  • "🚀".length() == 2, not 1
  • Why UTF-16? Java chose it in 1995 when Unicode was smaller
  • The mismatch: Memory uses UTF-16, but I/O uses UTF-8
  • Constant conversion happening behind the scenes

25 of 54

UTF-8 vs UTF-16 - A Character Comparison

Character

Code Point

UTF-8 Bytes

UTF-16 Units

Description

A

U+0041

41

00 41

Basic ASCII

Ω

U+03A9

CE A9

03 A9

Greek letter

U+4E2D

E4 B8 AD

4E 2D

Chinese character

🚀

U+1F680

F0 9F 9A 80

D8 3D DE 80

Rocket emoji

26 of 54

UTF-8 vs UTF-16 - A Character Comparison

Character

Code Point

UTF-8 Bytes

UTF-16 Units

Description

A

U+0041

41

00 41

Basic ASCII

Ω

U+03A9

CE A9

03 A9

Greek letter

U+4E2D

E4 B8 AD

4E 2D

Chinese character

🚀

U+1F680

F0 9F 9A 80

D8 3D DE 80

Rocket emoji

  • Key Observations:
    • UTF-8: 1-4 bytes, ASCII-compatible
    • UTF-16: 2 or 4 bytes, consistent for most languages

27 of 54

Astral planes

Plane

Range

Name

Description

0

U+0000–U+FFFF

Basic Multilingual Plane (BMP)

Most common characters: Latin, Cyrillic, Arabic, Hebrew, CJK (common), symbols, punctuation, emoji (some)

1

U+10000–U+1FFFF

Supplementary Multilingual Plane (SMP)

Emoji, historic scripts (Linear B, Old Italic, etc.), musical notation, ancient languages

2

U+20000–U+2FFFF

Supplementary Ideographic Plane (SIP)

Rare and historic CJK ideographs

3

U+30000–U+3FFFF

Tertiary Ideographic Plane (TIP)

More rare CJK ideographs, added in Unicode 13+

4-13

U+40000–U+DFFFF

(Unassigned / Reserved)

Reserved for future use

14

U+E0000–U+EFFFF

Supplementary Special-purpose Plane (SSP)

Special characters like language tags, variation selectors

15

U+F0000–U+FFFFF

Private Use Area-A

For private use; not assigned by Unicode Consortium

16

U+100000–U+10FFFF

Private Use Area-B

For private use; not assigned by Unicode Consortium

28 of 54

Astral planes

Plane

Range

Name

Description

0

U+0000–U+FFFF

Basic Multilingual Plane (BMP)

Most common characters: Latin, Cyrillic, Arabic, Hebrew, CJK (common), symbols, punctuation, emoji (some)

1

U+10000–U+1FFFF

Supplementary Multilingual Plane (SMP)

Emoji, historic scripts (Linear B, Old Italic, etc.), musical notation, ancient languages

2

U+20000–U+2FFFF

Supplementary Ideographic Plane (SIP)

Rare and historic CJK ideographs

3

U+30000–U+3FFFF

Tertiary Ideographic Plane (TIP)

More rare CJK ideographs, added in Unicode 13+

4-13

U+40000–U+DFFFF

(Unassigned / Reserved)

Reserved for future use

14

U+E0000–U+EFFFF

Supplementary Special-purpose Plane (SSP)

Special characters like language tags, variation selectors

15

U+F0000–U+FFFFF

Private Use Area-A

For private use; not assigned by Unicode Consortium

16

U+100000–U+10FFFF

Private Use Area-B

For private use; not assigned by Unicode Consortium

29 of 54

Unicode Normalization

  • NFC (Normalization Form Composed): precomposed characters where possible�Example: é = U+00E9
  • NFD (Normalization Form Decomposed): base character plus combining mark�Example: e (U+0065) + ◌́ (U+0301) = é
  • Both look the same, but code points are different

30 of 54

Unicode Normalization

  • NFC (Normalization Form Composed): precomposed characters where possible�Example: é = U+00E9
  • NFD (Normalization Form Decomposed): base character plus combining mark�Example: e (U+0065) + ◌́ (U+0301) = é
  • Both look the same, but code points are different
  • Sorting, searching, and matching may fail without normalization
  • android.icu.text.Normalizer2

31 of 54

"🧑‍🧑‍🧒‍🧒".length == ?

32 of 54

"🧑‍🧑‍🧒‍🧒".length == 11

33 of 54

Why is "🧑‍🧑‍🧒‍🧒".length == 11?

🧑

🧑

🧒

🧒

=

🧑‍🧑‍🧒‍🧒

34 of 54

Why is "🧑‍🧑‍🧒‍🧒".length == 11?

Emoji

Name

Code Points

UTF-16 units

🧑

Adult emoji

U+1F9D1

2

Zero Width Joiner

U+200D

1

🧑

Adult emoji

U+1F9D1

2

Zero Width Joiner

U+200D

1

🧒

Child emoji

U+1F9D2

2

Zero Width Joiner

U+200D

1

🧒

Child emoji

U+1F9D2

2

11

35 of 54

Emoji Modifiers & Variation Selectors

Emoji

Code Points

Description

👍

U+1F44D

Thumbs up

👍🏿

U+1F44D + U+1F3FF

Thumbs up + dark skin tone modifier

U+2764

Default heart (platform varies)

❤︎

U+2764 + U+FE0E

Text-style heart

❤️

U+2764 + U+FE0F

Emoji-style heart

👩‍⚕️

U+1F469 + U+200D + U+2695 + U+FE0F

Woman + ZWJ + Medical symbol + Variation Selector

36 of 54

Emoji Modifiers & Variation Selectors

Emoji

Code Points

Description

👍

U+1F44D

Thumbs up

👍🏿

U+1F44D + U+1F3FF

Thumbs up + dark skin tone modifier

U+2764

Default heart (platform varies)

❤︎

U+2764 + U+FE0E

Text-style heart

❤️

U+2764 + U+FE0F

Emoji-style heart

👩‍⚕️

U+1F469 + U+200D + U+2695 + U+FE0F

Woman + ZWJ + Medical symbol + Variation Selector

37 of 54

Counting Grapheme Clusters

  • Use import android.icu.text.BreakIterator
  • Don’t use import java.text.BreakIterator

38 of 54

Counting Grapheme Clusters

class GetGraphemeClusterCount {

operator fun invoke(text: String): Int {

val boundary = BreakIterator.getCharacterInstance()

boundary.setText(text)

var count = 0

var start = boundary.first()

while (start != BreakIterator.DONE) {

val end = boundary.next()

if (end != BreakIterator.DONE) {

count++

}

start = end

}

return count

}

}

39 of 54

Be Careful With Regex

  • Dot matches one UTF-16 code unit, not one grapheme cluster
  • \w, \b are ASCII-centric, may fail with non-English text
  • Can cause broken characters or validation errors

40 of 54

Using Regex Correctly

  • Use \X
  • Use Unicode properties: \p{L} (letters), \p{M} (marks), \p{N} (numbers)

41 of 54

Getting first grapheme cluster

private fun getFirstGraphemeCluster(text: String): String {

if (text.isEmpty()) return ""

val boundary = BreakIterator.getCharacterInstance()

boundary.setText(text)

val start = boundary.first()

val end = boundary.next()

return if (start != BreakIterator.DONE && end != BreakIterator.DONE) {

text.substring(start, end)

} else {

""

}

}

42 of 54

Back to the story

43 of 54

Back to the story

fun getInitials(username: String): String {

val words = username.splitIntoWords()

return when {

words.isEmpty() -> ""

words.size == 1 -> getFirstGraphemeCluster(words[0])

else -> getFirstGraphemeCluster(words[0]) +� getFirstGraphemeCluster(words[1])

}

}

44 of 54

Back to the story

fun getInitials(username: String): String {

val words = username.splitIntoWords()

return when {

words.isEmpty() -> ""

words.size == 1 -> getFirstGraphemeCluster(words[0])

else -> getFirstGraphemeCluster(words[0]) +� getFirstGraphemeCluster(words[1])

}.uppercase()

}

45 of 54

Things go bad 😳

require(getGraphemeClusterCount(initials) <= 2) {

"Profile image cannot display more than 2 grapheme clusters: '$initials'"

}

Profile image cannot display more than 2 grapheme clusters:

46 of 54

Things go bad 😳

require(getGraphemeClusterCount(initials) <= 2) {

"Profile image cannot display more than 2 grapheme clusters: '$initials'"

}

Profile image cannot display more than 2 grapheme clusters: SSH

47 of 54

Back to getInitials

fun getInitials(username: String): String {

val words = username.splitIntoWords()

return when {

words.isEmpty() -> ""

words.size == 1 -> getFirstGraphemeCluster(words[0])

else -> getFirstGraphemeCluster(words[0]) +� getFirstGraphemeCluster(words[1])

}.uppercase()

}

48 of 54

uppercase method

49 of 54

ßmart hacker

  • 'ß'.uppercase() == "SS"
  • ffi, ffl, ff, ʼn
  • 'ẞ'.lowercase() == "ß"
  • 'Σ'.lowercase() == "σ"
  • "ςσ".uppercase() == "ΣΣ"
  • 'ʃ'.lowercase() == "s"

50 of 54

ßmart hacker

  • 'ß'.uppercase() == "SS"
  • ffi, ffl, ff, ʼn
  • 'ẞ'.lowercase() == "ß"
  • 'Σ'.lowercase() == "σ"
  • "ςσ".uppercase() == "ΣΣ"
  • 'ʃ'.lowercase() == "s"

  • Z̵͙͉̎a̵̩͛͛l̵͎̓g̶͚̺̊͠o̷̫̊
  • 漢字, 𓂀, ,

51 of 54

Everyone Gets Tripped Up by Unicode

  • iOS “Rainbow Flag 🏳️‍0🌈” crash: white flag + 0 + rainbow + variation selector caused iMessages to crash
  • Android “Black Dot ⚫” bug: tapping a seemingly harmless black dot crashed WhatsApp and other apps due to thousands of hidden invisible Unicode characters

52 of 54

Everyone Gets Tripped Up by Unicode

  • iOS “Rainbow Flag 🏳️‍0🌈” crash: white flag + 0 + rainbow + variation selector caused iMessages to crash
  • Android “Black Dot ⚫” bug: tapping a seemingly harmless black dot crashed WhatsApp and other apps due to thousands of hidden invisible Unicode characters
  • Unicode is complex, powerful, and worth mastering

53 of 54

T̸̛̙̞͙͔̪͕̝̀͐̀̍͠͝͝h̵̨͍̻̺̝̓̈́̏̋͊̔͋a̸̢̧̨̼̜̖̗̖̠͇͎͐̾͐͛͊̆̽͗̓̈͠ń̴̨̝͇̦̝̺͙̉̽k̸̹̣̯̠̖͔̺̻̱̘͎̍͗̓͊͋̀ ̶̛͓͕̻͈̺̍̋y̵̮̪͕̖̺̭̓͊̇̏̓̉͌͋ͅơ̵̢͖̗̟̪̯͚̤̞̗̈́̆͐̿͛͆͑́̓u̵̯̳̭͚͖͇͐̀͐̍̈́̆͛͒̑̑͋̔̕͠͠!̸̧̳̩̅͝

54 of 54

Vladimir Jovanović�https://vladimirj.dev� @vladimirj.dev‬

Questions�