Skip to content

Commit 130f10c

Browse files
fix: emoji not rendered issue (#2747)
1 parent b864c86 commit 130f10c

4 files changed

Lines changed: 87 additions & 2 deletions

File tree

Core/source/core/buf.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,18 @@ export class Buf extends Uint8Array {
166166
bytesLeftInChar--;
167167
}
168168
if (binaryChar && !bytesLeftInChar) {
169-
stringArray[i] = String.fromCharCode(parseInt(binaryChar, 2));
169+
const cp = parseInt(binaryChar, 2);
170+
// Valid Unicode range (0..0x10FFFF): use fromCodePoint so
171+
// supplementary-plane code points (e.g. emoji such as 😀 /
172+
// U+1F600, encoded as 4-byte UTF-8 sequences) round-trip
173+
// correctly; fromCharCode alone would silently truncate the high
174+
// bits and turn them into Private Use Area characters (which is
175+
// what caused https://github.com/FlowCrypt/flowcrypt-ios/issues/630).
176+
// For out-of-range code points produced by the legacy 5- and
177+
// 6-byte UTF-8 branches above (only reachable when non-UTF-8
178+
// binary data is fed through toUtfStr) fall back to fromCharCode
179+
// to preserve historical byte-compat behavior.
180+
stringArray[i] = cp <= 0x10ffff ? String.fromCodePoint(cp) : String.fromCharCode(cp);
170181
binaryChar = '';
171182
}
172183
}

Core/source/test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,23 @@ test('parseDecryptMsg unescaped special characters in encrypted text', async t =
282282
t.pass();
283283
});
284284

285+
// Regression test for https://github.com/FlowCrypt/flowcrypt-ios/issues/630
286+
// Ensures emoji / supplementary-plane Unicode scalars survive the full
287+
// encryptMsg -> parseDecryptMsg round-trip at the Core (JS) layer.
288+
// If this passes, any user-visible "emoji not rendered" issue lives outside
289+
// of Core (i.e. in the iOS bridge or the WKWebView HTML renderer).
290+
test('encryptMsg -> parseDecryptMsg preserves emoji / non-BMP unicode', async t => {
291+
const emojiText = 'Hello 😀 🙂 🔐 👩‍💻 é 汉';
292+
const expectedHtml = Xss.escape(emojiText).replace(/\n/g, '<br />');
293+
const { pubKeys, keys } = getKeypairs('rsa1');
294+
const { data: encryptedMsg } = await endpoints.encryptMsg({ pubKeys }, [Buffer.from(emojiText, 'utf8')]);
295+
expectData(encryptedMsg, 'armoredMsg');
296+
const { data: blocks, json: decryptJson } = await endpoints.parseDecryptMsg({ keys }, [encryptedMsg]);
297+
expect(decryptJson).to.deep.equal({ text: emojiText, replyType: 'encrypted' });
298+
expectData(blocks, 'msgBlocks', [{ rendered: true, frameColor: 'green', htmlContent: expectedHtml }]);
299+
t.pass();
300+
});
301+
285302
test('parseDecryptMsg - plain inline img', async t => {
286303
const mime = `MIME-Version: 1.0
287304
Date: Sat, 10 Aug 2019 10:45:56 +0000

FlowCrypt/Resources/generated/flowcrypt-ios-prod.js.txt

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

FlowCryptAppTests/Core/FlowCryptCoreTests.swift

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,63 @@ final class FlowCryptCoreTests: XCTestCase {
301301
XCTAssertNotNil(b.content.range(of: text)) // original text contained within the formatted html block
302302
}
303303

304+
// Regression test for https://github.com/FlowCrypt/flowcrypt-ios/issues/630
305+
// Verifies that emoji / non-BMP Unicode scalars survive the full
306+
// compose -> encrypt -> parseDecryptMsg round-trip through the JS core
307+
// (WKWebView bridge). If this passes, any user-visible emoji rendering
308+
// issue is located above the Core layer (i.e. in ThreadDetailWebNode /
309+
// WKWebView HTML rendering).
310+
func testEndToEndWithEmoji() async throws {
311+
let passphrase = "some pass phrase test"
312+
let email = "e2e-emoji@domain.com"
313+
// Mix BMP emoji, supplementary-plane emoji (surrogate pair in UTF-16),
314+
// ZWJ sequence, combining mark, and Chinese character.
315+
let text = "Hello 😀 🙂 🔐 👩‍💻 é 汉"
316+
let generateKeyRes = try await core.generateKey(
317+
passphrase: passphrase,
318+
variant: KeyVariant.curve25519,
319+
userIds: [UserId(email: email, name: "End to end emoji")]
320+
)
321+
let msg = SendableMsg(
322+
text: text,
323+
html: text,
324+
to: [email],
325+
cc: [],
326+
bcc: [],
327+
from: email,
328+
subject: "emoji subj 😀",
329+
replyToMsgId: nil,
330+
inReplyTo: nil,
331+
atts: [],
332+
pubKeys: [generateKeyRes.key.public],
333+
signingPrv: nil,
334+
password: nil
335+
)
336+
let mime = try await core.composeEmail(msg: msg, fmt: .encryptInline)
337+
let keys = try [Keypair(generateKeyRes.key, passPhrase: passphrase, source: "test")]
338+
let decrypted = try await core.parseDecryptMsg(
339+
encrypted: mime.mimeEncoded,
340+
keys: keys,
341+
msgPwd: nil,
342+
isMime: true,
343+
verificationPubKeys: []
344+
)
345+
XCTAssertEqual(decrypted.replyType, ReplyType.encrypted)
346+
// The plain text field must match byte-for-byte after round-trip.
347+
XCTAssertEqual(decrypted.text, text)
348+
XCTAssertEqual(decrypted.blocks.count, 1)
349+
let b = decrypted.blocks[0]
350+
XCTAssertEqual(b.type, MsgBlock.BlockType.plainHtml)
351+
XCTAssertNil(b.decryptErr)
352+
// Each emoji/unicode scalar must appear intact inside the rendered html block.
353+
for scalar in ["😀", "🙂", "🔐", "👩‍💻", "é", ""] {
354+
XCTAssertNotNil(
355+
b.content.range(of: scalar),
356+
"expected \(scalar) to survive round-trip inside rendered html block"
357+
)
358+
}
359+
}
360+
304361
func testDecryptErrMismatch() async throws {
305362
let key = TestData.k0
306363
let r = try await core.parseDecryptMsg(encrypted: TestData.mismatchEncryptedMsg.data(using: .utf8)!, keys: [key], msgPwd: nil, isMime: false, verificationPubKeys: [])

0 commit comments

Comments
 (0)