Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion Core/source/core/buf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,18 @@ export class Buf extends Uint8Array {
bytesLeftInChar--;
}
if (binaryChar && !bytesLeftInChar) {
stringArray[i] = String.fromCharCode(parseInt(binaryChar, 2));
const cp = parseInt(binaryChar, 2);
// Valid Unicode range (0..0x10FFFF): use fromCodePoint so
// supplementary-plane code points (e.g. emoji such as 😀 /
// U+1F600, encoded as 4-byte UTF-8 sequences) round-trip
// correctly; fromCharCode alone would silently truncate the high
// bits and turn them into Private Use Area characters (which is
// what caused https://github.com/FlowCrypt/flowcrypt-ios/issues/630).
// For out-of-range code points produced by the legacy 5- and
// 6-byte UTF-8 branches above (only reachable when non-UTF-8
// binary data is fed through toUtfStr) fall back to fromCharCode
// to preserve historical byte-compat behavior.
stringArray[i] = cp <= 0x10ffff ? String.fromCodePoint(cp) : String.fromCharCode(cp);
binaryChar = '';
}
}
Expand Down
17 changes: 17 additions & 0 deletions Core/source/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,23 @@ test('parseDecryptMsg unescaped special characters in encrypted text', async t =
t.pass();
});

// Regression test for https://github.com/FlowCrypt/flowcrypt-ios/issues/630
// Ensures emoji / supplementary-plane Unicode scalars survive the full
// encryptMsg -> parseDecryptMsg round-trip at the Core (JS) layer.
// If this passes, any user-visible "emoji not rendered" issue lives outside
// of Core (i.e. in the iOS bridge or the WKWebView HTML renderer).
test('encryptMsg -> parseDecryptMsg preserves emoji / non-BMP unicode', async t => {
const emojiText = 'Hello 😀 🙂 🔐 👩‍💻 é 汉';
const expectedHtml = Xss.escape(emojiText).replace(/\n/g, '<br />');
const { pubKeys, keys } = getKeypairs('rsa1');
const { data: encryptedMsg } = await endpoints.encryptMsg({ pubKeys }, [Buffer.from(emojiText, 'utf8')]);
expectData(encryptedMsg, 'armoredMsg');
const { data: blocks, json: decryptJson } = await endpoints.parseDecryptMsg({ keys }, [encryptedMsg]);
expect(decryptJson).to.deep.equal({ text: emojiText, replyType: 'encrypted' });
expectData(blocks, 'msgBlocks', [{ rendered: true, frameColor: 'green', htmlContent: expectedHtml }]);
t.pass();
});

test('parseDecryptMsg - plain inline img', async t => {
const mime = `MIME-Version: 1.0
Date: Sat, 10 Aug 2019 10:45:56 +0000
Expand Down
2 changes: 1 addition & 1 deletion FlowCrypt/Resources/generated/flowcrypt-ios-prod.js.txt

Large diffs are not rendered by default.

57 changes: 57 additions & 0 deletions FlowCryptAppTests/Core/FlowCryptCoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,63 @@ final class FlowCryptCoreTests: XCTestCase {
XCTAssertNotNil(b.content.range(of: text)) // original text contained within the formatted html block
}

// Regression test for https://github.com/FlowCrypt/flowcrypt-ios/issues/630
// Verifies that emoji / non-BMP Unicode scalars survive the full
// compose -> encrypt -> parseDecryptMsg round-trip through the JS core
// (WKWebView bridge). If this passes, any user-visible emoji rendering
// issue is located above the Core layer (i.e. in ThreadDetailWebNode /
// WKWebView HTML rendering).
func testEndToEndWithEmoji() async throws {
let passphrase = "some pass phrase test"
let email = "e2e-emoji@domain.com"
// Mix BMP emoji, supplementary-plane emoji (surrogate pair in UTF-16),
// ZWJ sequence, combining mark, and Chinese character.
let text = "Hello 😀 🙂 🔐 👩‍💻 é 汉"
let generateKeyRes = try await core.generateKey(
passphrase: passphrase,
variant: KeyVariant.curve25519,
userIds: [UserId(email: email, name: "End to end emoji")]
)
let msg = SendableMsg(
text: text,
html: text,
to: [email],
cc: [],
bcc: [],
from: email,
subject: "emoji subj 😀",
replyToMsgId: nil,
inReplyTo: nil,
atts: [],
pubKeys: [generateKeyRes.key.public],
signingPrv: nil,
password: nil
)
let mime = try await core.composeEmail(msg: msg, fmt: .encryptInline)
let keys = try [Keypair(generateKeyRes.key, passPhrase: passphrase, source: "test")]
let decrypted = try await core.parseDecryptMsg(
encrypted: mime.mimeEncoded,
keys: keys,
msgPwd: nil,
isMime: true,
verificationPubKeys: []
)
XCTAssertEqual(decrypted.replyType, ReplyType.encrypted)
// The plain text field must match byte-for-byte after round-trip.
XCTAssertEqual(decrypted.text, text)
XCTAssertEqual(decrypted.blocks.count, 1)
let b = decrypted.blocks[0]
XCTAssertEqual(b.type, MsgBlock.BlockType.plainHtml)
XCTAssertNil(b.decryptErr)
// Each emoji/unicode scalar must appear intact inside the rendered html block.
for scalar in ["😀", "🙂", "🔐", "👩‍💻", "é", "汉"] {
XCTAssertNotNil(
b.content.range(of: scalar),
"expected \(scalar) to survive round-trip inside rendered html block"
)
}
}

func testDecryptErrMismatch() async throws {
let key = TestData.k0
let r = try await core.parseDecryptMsg(encrypted: TestData.mismatchEncryptedMsg.data(using: .utf8)!, keys: [key], msgPwd: nil, isMime: false, verificationPubKeys: [])
Expand Down
Loading