siemens-sms-parser
Version:
Extract SMS messages from SMS.dat and .smo/.smi SMS Archive files on Siemens mobile phones.
1,289 lines (1,168 loc) • 52.4 kB
JavaScript
import {playIMelody} from "./imelody.js";
const bytesEqual = (a, b) =>
a.length === b.length && a.every((v, i) => v === b[i]);
const bytesToHex = bytes =>
[...bytes].map(b => b.toString().padStart(2, '0')).join('');
const byteToBooleansLSBFirst = byte => {
if (byte < 0 || byte > 255)
throw new RangeError('Input must be a 1‑byte integer (0–255)');
const bits = new Array(8);
for (let i = 0; i < 8; i++) bits[i] = Boolean((byte >> i) & 1);
return bits;
};
function convertBlobToBase64String(imageBlob) {
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onloadend = () => resolve(fileReader.result.split(',')[1]);
fileReader.onerror = () => reject(fileReader.error);
fileReader.readAsDataURL(imageBlob);
});
}
function convertUint8ArrayToBase64(uint8ArrayInput) {
let binaryString = '';
for (let byteIndex = 0; byteIndex < uint8ArrayInput.length; byteIndex++) {
binaryString += String.fromCharCode(uint8ArrayInput[byteIndex]);
}
return btoa(binaryString);
}
function convertBase64ToUint8Array(base64Input) {
const binaryString = atob(base64Input);
const outputArray = new Uint8Array(binaryString.length);
for (let charIndex = 0; charIndex < binaryString.length; charIndex++) {
outputArray[charIndex] = binaryString.charCodeAt(charIndex);
}
return outputArray;
}
function* iterateBits(uint8Array) {
for (let byteIndex = 0; byteIndex < uint8Array.length; byteIndex++) {
const currentByteValue = uint8Array[byteIndex];
for (let bitPosition = 7; bitPosition >= 0; bitPosition--) {
yield (currentByteValue >> bitPosition) & 1;
}
}
}
export const formatTimestampToIsoWithOffset = function (dateOrTimestamp, customOffsetMinutes) {
const inputDate = dateOrTimestamp instanceof Date
? new Date(dateOrTimestamp.getTime())
: new Date(dateOrTimestamp);
const utcMillis = inputDate.getTime(); // absolute moment in time
let offsetMinutes;
let localDate;
if (typeof customOffsetMinutes === 'number') {
offsetMinutes = customOffsetMinutes;
localDate = new Date(utcMillis + offsetMinutes * 60_000); // shift into requested zone
} else {
localDate = inputDate; // keep host zone
offsetMinutes = -localDate.getTimezoneOffset(); // JS offset sign is opposite of ISO-8601
}
const pad = (value, length = 2) => String(Math.abs(value)).padStart(length, '0');
const year = localDate.getUTCFullYear();
const month = pad(localDate.getUTCMonth() + 1);
const day = pad(localDate.getUTCDate());
const hours = pad(localDate.getUTCHours());
const minutes = pad(localDate.getUTCMinutes());
const seconds = pad(localDate.getUTCSeconds());
const sign = offsetMinutes >= 0 ? '+' : '-';
const absOffset = Math.abs(offsetMinutes);
const offsetHours = pad(Math.floor(absOffset / 60));
const offsetRemaining = pad(absOffset % 60);
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds} ${sign}${offsetHours}:${offsetRemaining}`;
};
/* encoding detection (identical logic) */
const alphaBits = d =>
(d & 0xc0) === 0
? (d & 0x0c) === 8
? 16
: (d & 0x0c) === 4
? 8
: 7
: (d & 0xc0) === 0xc0
? (d & 0x30) === 0x20
? 16
: (d & 0x30) === 0x30
? 8
: 7
: 7;
const _bcdNibbleToChar = n =>
n <= 9
? String(n)
: ['*', '#', 'A', 'B', 'C', 'F'][n - 10] /* 0xF = filler */;
const semiPhone = bcd => {
const digits = [];
for (const byte of bcd) {
digits.push(byte & 0x0f, (byte >> 4) & 0x0f);
}
let s = digits.map(_bcdNibbleToChar).join('');
return s.endsWith('F') ? s.slice(0, -1) : s;
};
const bcdByteToNumber = b =>
((b >> 4) & 0x0F) * 10 + (b & 0x0F); // 0x21 → 12
const tzDecode = tzByte => {
const high = (tzByte >> 4) & 0x0F; // tens digit + sign
const low = tzByte & 0x0F; // units digit
const sign = (high & 0x8) ? '-' : '+'; // bit 3 set ⇒ negative
const qh = (high & 0x7) * 10 + low; // quarter-hours (BCD)
const hh = String(Math.floor(qh / 4)).padStart(2, '0');
const mm = String((qh % 4) * 15).padStart(2, '0');
return `${sign}${hh}:${mm}`;
};
const sevenByteTimeStampToDateAndTimezoneOffset = bytes7 => {
if (bytes7.every(b => b === 0x00)) return undefined;
const s = bytes7.map(b => ((b & 0x0F) << 4) | (b >> 4));
const [yy, mo, dd, hh, mi, ss] = s.slice(0, 6).map(bcdByteToNumber);
const tzByte = s[6];
const high = (tzByte >> 4) & 0x0F;
const low = tzByte & 0x0F;
const quarterHours = (high & 0x7) * 10 + low;
const timeZoneOffsetMinutes = quarterHours * 15 * ((high & 0x8) ? -1 : 1);
const timeZoneString = tzDecode(tzByte);
const utcMillis = Date.UTC(2000 + yy, mo - 1, dd, hh, mi, ss) - timeZoneOffsetMinutes * 60_000;
const date = new Date(utcMillis);
// *** step 3 (optional): stash the original printable string
date.originalTimestamp =
`20${String(yy).padStart(2, '0')}-` +
`${String(mo).padStart(2, '0')}-` +
`${String(dd).padStart(2, '0')} ` +
`${String(hh).padStart(2, '0')}:` +
`${String(mi).padStart(2, '0')}:` +
`${String(ss).padStart(2, '0')} ` +
tzDecode(tzByte);
return {date, timeZoneOffsetMinutes};
};
const DEF = [
'@', '£', '$', '¥', 'è', 'é', 'ù', 'ì', 'ò', 'Ç', '\n', 'Ø', 'ø', '\r', 'Å', 'å',
'\u0081', '_', '\u0082', '\u0083', '\u0084', '\u0085', '\u0086', '\u0087',
'\u0088', '\u0089', '\u008a', '\u001b', 'Æ', 'æ', 'ß', 'É',
' ', '!', '"', '#', '¤', '%', '&', "'", '(', ')', '*', '+',
',', '-', '.', '/', '0', '1', '2', '3', '4', '5', '6', '7',
'8', '9', ':', ';', '<', '=', '>', '?', '¡', 'A', 'B', 'C', 'D',
'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P',
'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'Ä', 'Ö',
'Ñ', 'Ü', '§', '¿', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h',
'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't',
'u', 'v', 'w', 'x', 'y', 'z', 'ä'
];
const EXT = new Map([
[0x0A, '\f'], [0x14, '^'], [0x28, '{'], [0x29, '}'],
[0x2F, '\\'], [0x3C, '['], [0x3D, '~'], [0x3E, ']'],
[0x40, '|'], [0x65, '€']
]);
const sevenBitDecode = (bytes, bitPos, septets) => {
let out = '',
esc = false;
for (let i = 0; i < septets; i++) {
const bytePos = Math.floor(bitPos / 8);
const b1 = bytes[bytePos] ?? 0;
const b2 = bytes[bytePos + 1] ?? 0;
const v = ((b1 >> (bitPos % 8)) | (b2 << (8 - (bitPos % 8)))) & 0x7f;
bitPos += 7;
if (esc) {
out += EXT.get(v) || '�';
esc = false;
} else if (v === 0x1b) esc = true;
else out += DEF[v] ?? '�';
}
return out;
};
const ucs2Decode = (bytes, skipOct) =>
new TextDecoder('utf-16be').decode(bytes.subarray(skipOct));
const octetDecode = (bytes, skipOct) =>
String.fromCharCode(...bytes.subarray(skipOct));
/* Remove trailing 0xFF */
const trimTrailingFFs = buf => {
let end = buf.length;
while (end >= 1 && buf[end - 1] === 0xff) end--;
return buf.subarray(0, end);
};
class ByteCursor {
constructor(bytes) {
this.b = bytes;
this.i = 0;
}
take(n) {
if (this.i + n > this.b.length)
throw new RangeError('Attempt to read past end of buffer');
const s = this.b.subarray(this.i, this.i + n);
this.i += n;
return s;
}
takeByte() {
if (this.i >= this.b.length)
throw new RangeError('Attempt to read past end of buffer');
return this.b[this.i++];
}
peek(n) {
return this.b.subarray(this.i, this.i + n);
}
remaining() {
return this.b.length - this.i;
}
}
const TextAlignment = Object.freeze({
Left: 0b00,
Center: 0b01,
Right: 0b10,
LanguageDependent: 0b11
});
const FontSize = Object.freeze({
Normal: 0b00,
Large: 0b01,
Small: 0b10,
Reserved: 0b11
});
class TextFormatting {
/**
* Start position of the text formatting. Set to the number of characters after the formatting shall be applied from the beginning of the SM data.
* This octet shall be coded as an integer value in the range 0 (beginning of the SM data) to the maximum number of characters included in the SM data of one single SM or one segment of a concatenated SM.
*/
position;
/**
* Text formatting length. Gives the number of formatted characters or sets a default text formatting.
* This octet shall be coded as an integer value in the range 1 to the maximum number of characters for which the formatting applies in one single SM or one segment of a concatenated SM.
* A text formatting length value of 0 indicates that the text format shall be used as a default text format for the current SM. The default text format shall be used for all text in a concatenated SM unless temporarily overridden by a text formatting IE with a non-zero text format length field.
* It shall be possible to re-define the default text formatting to be applied to all subsequent text in the current SM by sending a new Text Format IE with text format length zero.
* Conflicting overlapping text formatting instructions shall be resolved by applying the formatting instructions in their sequential order.
*/
length;
/**
* Formatting mode value coded as following:
* Bit 7 Bit 6 Bit 5 Bit 4 Bit 3 Bit 2 Bit 1 Bit 0
*
* Bit 1 Bit 0 *Alignment
* 0 0 Left
* 0 1 Center
* 1 0 Right
* 1 1 Language dependent (default)
* *in case formatting text is inserted on the same line as previous non formatting text or with a different mode value,
* the alignment value shall be set to the same value as the previous formatted predefined object.
* Alignment may affect object placement.
* Bit 3 Bit 2 Font Size
* 0 0 Normal (default)
* 0 1 Large
* 1 0 Small
* 1 1 reserved
* Bit 4 Style bold
* 1 Bold on
* 0 Bold off
* Bit 5 Style Italic
* 1 Italic on
* 0 Italic off
*
* Bit 6 Style Underlined
* 1 Underlined on
* 0 Underlined off
* Bit 7 Style Strikethrough
* 1 Strikethrough on
* 0 Strikethrough off
* If bit 4,5,6 and 7 are set to 0, it will mean normal style (default).
*/
formattingMode;
/**
* This Octet may be omitted by setting the IED length accordingly.
* Bits 0..3 define the Text Foreground Colour
* Bits 4..7 define the Text Background Colour
* Each colour is defined in a semi octet according to the table below. The actual colours displayed may vary between ME's depending on the display device used.
* The colour values defined are simple primary and secondary colours plus four levels of grey. Bright colours have a higher intensity than dark colours.
* Nibble Value Colour
* (msb…lsb)
* 0000 Black
* 0001 Dark Grey
* 0010 Dark Red
* 0011 Dark Yellow
* 0100 Dark Green
* 0101 Dark Cyan
* 0110 Dark Blue
* 0111 Dark Magenta
* 1000 Grey
* 1001 White
* 1010 Bright Red
* 1011 Bright Yellow
* 1100 Bright Green
* 1101 Bright Cyan
* 1110 Bright Blue
* 1111 Bright Magenta
*/
foregroundColor;
backgroundColor;
constructor(position, length, formattingMode, foregroundColor = undefined, backgroundColor = undefined) {
this.position = position;
this.length = length;
this.formattingMode = formattingMode;
this.foregroundColor = foregroundColor;
this.backgroundColor = backgroundColor;
}
#nibbleToColor = {
'0x00': {name: 'black', htmlHex: '#000000'},
'0x01': {name: 'dark-grey', htmlHex: '#555555'},
'0x02': {name: 'dark-red', htmlHex: '#800000'},
'0x03': {name: 'dark-yellow', htmlHex: '#808000'},
'0x04': {name: 'dark-green', htmlHex: '#008000'},
'0x05': {name: 'dark-cyan', htmlHex: '#008080'},
'0x06': {name: 'dark-blue', htmlHex: '#000080'},
'0x07': {name: 'dark-magenta', htmlHex: '#800080'},
'0x08': {name: 'grey', htmlHex: '#AAAAAA'},
'0x09': {name: 'white', htmlHex: '#FFFFFF'},
'0x0A': {name: 'bright-red', htmlHex: '#FF0000'},
'0x0B': {name: 'bright-yellow', htmlHex: '#FFFF00'},
'0x0C': {name: 'bright-green', htmlHex: '#00FF00'},
'0x0D': {name: 'bright-cyan', htmlHex: '#00FFFF'},
'0x0E': {name: 'bright-blue', htmlHex: '#0000FF'},
'0x0F': {name: 'bright-Magenta', htmlHex: '#FF00FF'}
};
getTextAlignment() {
return this.formattingMode & 0b0000_0011;
}
getFontSize() {
return (this.formattingMode & 0b0000_1100) >> 2;
}
isBold() {
return (this.formattingMode & (1 << 4)) !== 0;
}
isItalic() {
return (this.formattingMode & (1 << 5)) !== 0;
}
isUnderlined() {
return (this.formattingMode & (1 << 6)) !== 0;
}
isStrikethrough() {
return (this.formattingMode & (1 << 7)) !== 0;
}
getForegroundColor() {
if (this.foregroundColor === undefined) {
return undefined;
}
return this.#nibbleToColor[this.foregroundColor];
}
getBackgroundColor() {
if (this.backgroundColor === undefined) {
return undefined;
}
return this.#nibbleToColor[this.backgroundColor];
}
}
class PredefinedAnimation {
position;
animationNumber;
constructor(position, animationNumber) {
this.position = position;
this.animationNumber = animationNumber;
Object.freeze(this);
}
}
class PredefinedSound {
position;
soundNumber;
constructor(position, soundNumber) {
this.position = position;
this.soundNumber = soundNumber;
Object.freeze(this);
}
}
function putPictureDataOnContext(context, pictureData, sideLength) {
const imageData = context.createImageData(sideLength, sideLength);
let i = 0;
for (const pixelBit of iterateBits(pictureData)) {
imageData.data[i] = pixelBit ? 0 : 255; // R value
imageData.data[i + 1] = pixelBit ? 0 : 255; // G value
imageData.data[i + 2] = pixelBit ? 0 : 255; // B value
imageData.data[i + 3] = 255; // A value
i += 4;
}
context.putImageData(imageData, 0, 0);
}
class Picture {
constructor(position, pictureData, sideLength) {
this.position = position;
this.pictureData = pictureData;
this.sideLength = sideLength;
Object.freeze(this);
}
renderOnCanvas(canvas) {
const context = canvas.getContext('2d');
putPictureDataOnContext(context, this.pictureData, this.sideLength);
}
readAsDataUrl() {
if (typeof document === 'undefined') {
return '';
}
const canvas = Object.assign(document.createElement('canvas'), {
width: this.sideLength,
height: this.sideLength
});
this.renderOnCanvas(canvas);
return canvas.toDataURL('image/png');
}
}
function renderAnimationOnCanvas(canvas, animationData, sideLength) {
let frames = []
const numberOfFrames = 4;
const totalBytes = animationData.length;
for (let frameIndex = 0; frameIndex < numberOfFrames; frameIndex++) {
const startByte = frameIndex * totalBytes / numberOfFrames;
const endByte = (frameIndex + 1) * totalBytes / numberOfFrames;
const frameData = animationData.subarray(startByte, endByte);
const canvas = document.createElement('canvas');
putPictureDataOnContext(canvas.getContext('2d'), frameData, sideLength)
frames.push(canvas);
}
const framesPerSecond = 3;
const millisecondsPerFrame = 1000 / framesPerSecond;
let currentFrameIndex = 0;
let previousTimestamp = 0;
const visibleContext = canvas.getContext('2d');
function renderNextFrame(highResolutionTimestamp) {
if (highResolutionTimestamp - previousTimestamp >= millisecondsPerFrame) {
visibleContext.clearRect(0, 0, canvas.width, canvas.height);
visibleContext.drawImage(frames[currentFrameIndex], 0, 0);
currentFrameIndex = (currentFrameIndex + 1) % numberOfFrames;
previousTimestamp = highResolutionTimestamp;
}
requestAnimationFrame(renderNextFrame);
}
requestAnimationFrame(renderNextFrame);
}
class Animation {
constructor(position, animationData, sideLength) {
this.position = position;
this.animationData = animationData;
this.sideLength = sideLength;
Object.freeze(this);
}
}
class LargePicture extends Picture {
constructor(position, pictureData) {
super(position, pictureData, 32);
}
}
class SmallPicture extends Picture {
constructor(position, pictureData) {
super(position, pictureData, 16);
}
}
class IMelody {
position;
iMelodyString;
constructor(position, iMelodyString) {
this.position = position;
this.iMelodyString = iMelodyString;
Object.freeze(this);
}
}
class UserData {
referenceNumber = undefined; // integer (0-255 or 0-65535)
segmentsTotal = undefined; // integer 1-255
sequenceNumber = undefined; // integer 1-255
encoding = undefined; // 'GSM-7' | 'ASCII' | 'UCS-2'
text = undefined; // string
length = undefined; // non-negative integer
errors=[];
/** @type Array.<TextFormatting> */
textFormattings = [];
/** @type Array.<PredefinedAnimation> */
predefinedAnimations = [];
/** @type Array.<PredefinedSound> */
predefinedSounds = [];
pictures = [];
/** @type Array.<IMelody> */
iMelodies = [];
/** @type Array.<Animation> */
animations = [];
constructor() {
Object.seal(this); // ban undeclared props, keep mutability
}
}
class UserDataDecoder {
#cursor;
#decodedUserData;
decode(userData, udhiPresent, bitsPerChar) {
this.#cursor = new ByteCursor(userData);
this.#decodedUserData = new UserData();
//TP‑User‑Data‑Length = total length of the TP‑User‑Data field including the Header
const udl = this.#cursor.takeByte();
let udhl = 0;
if (udhiPresent) {
udhl = this.#cursor.takeByte();
let headerBytesRead = 0;
while (headerBytesRead < udhl) {
const bytesRead = this.readInformationElement();
headerBytesRead += bytesRead;
}
}
let smData = this.#cursor.take(this.#cursor.remaining());
const headerOctetCount = udhiPresent ? udhl + 1 : 0;
switch (bitsPerChar) {
case 16:
this.#decodedUserData.encoding = 'UCS-2';
this.#decodedUserData.text = ucs2Decode(smData, 0);
this.#decodedUserData.length = (udl - headerOctetCount) / 2
break;
case 8:
this.#decodedUserData.encoding = 'ASCII';
this.#decodedUserData.text = octetDecode(smData, 0);
this.#decodedUserData.ength = (udl - headerOctetCount) / 2
break;
case 7:
this.#decodedUserData.encoding = 'GSM-7';
const bitOffset = (7 - (headerOctetCount % 7)) % 7; // 0-6 pad bits
const headerSeptetCount = (headerOctetCount * 8 + bitOffset) / 7; // always integer
this.#decodedUserData.length = udl - headerSeptetCount;
this.#decodedUserData.text = sevenBitDecode(smData, bitOffset, this.#decodedUserData.length);
break;
default:
throw new Error(`Unknown number of bits: ${bitsPerChar}`);
}
return this.#decodedUserData;
}
readInformationElement() {
const iei = this.#cursor.takeByte();
const iedl = this.#cursor.takeByte();
let bytesRead = 2;
switch (iei) {
case 0x00: //Concatenated short messages, 8-bit reference number
case 0x08: //Concatenated short message, 16-bit reference number
const referenceOctets = iei === 0x08 ? 2 : 1;
if ((iei === 0x00 && iedl !== 0x03) || (iei === 0x08 && iedl !== 0x04)) {
this.#decodedUserData.errors.push(`Unexpected concatenated short message IEI length: ${iei}/${iedl}`);
this.#cursor.take(iedl);
bytesRead += iedl;
break;
}
const refBytes = this.#cursor.take(referenceOctets);
bytesRead += referenceOctets;
this.#decodedUserData.referenceNumber = referenceOctets === 2
? (refBytes[0] << 8) | refBytes[1]
: refBytes[0];
this.#decodedUserData.segmentsTotal = this.#cursor.takeByte();
this.#decodedUserData.sequenceNumber = this.#cursor.takeByte();
bytesRead += 2;
break;
case 0x0A: //Text Formatting
if (iedl !== 0x03 && iedl !== 0x04) {
this.#decodedUserData.errors.push(`Unexpected text formatting IEI length: ${iei}/${iedl}`);
this.#cursor.take(iedl);
bytesRead += iedl;
break;
}
const startPosition = this.#cursor.takeByte();
const length = this.#cursor.takeByte();
const formattingMode = this.#cursor.takeByte();
let foregroundColor, backgroundColor;
if (iedl === 0x04) {
const color = this.#cursor.takeByte();
foregroundColor = color & 0x0F;
backgroundColor = (color >> 4) & 0x0F;
}
bytesRead += iedl;
this.#decodedUserData.textFormattings.push(new TextFormatting(startPosition, length, formattingMode, foregroundColor, backgroundColor));
break;
case 0x0B: //Predefined sound
if (iedl !== 0x02) {
this.#decodedUserData.errors.push(`Unexpected predefined sound IEI length: ${iei}/${iedl}`);
this.#cursor.take(iedl);
bytesRead += iedl;
break;
}
let predefinedSound = new PredefinedSound(this.#cursor.takeByte(), this.#cursor.takeByte());
this.#decodedUserData.predefinedSounds.push(predefinedSound);
bytesRead += 2;
break;
case 0x0C: // iMelody
const iMelodyPosition = this.#cursor.takeByte();
const iMelody = this.#cursor.take(iedl-1)
const iMelodyString = new TextDecoder('ascii').decode(iMelody);
this.#decodedUserData.iMelodies.push(new IMelody(iMelodyPosition, iMelodyString))
bytesRead += iedl;
break;
case 0x0D: //predefined animation
if (iedl !== 0x02) {
this.#decodedUserData.errors.push(`Unexpected concatenated short message IEI length: ${iei}/${iedl}`);
this.#cursor.take(iedl);
bytesRead += iedl;
break;
}
let predefinedAnimation = new PredefinedAnimation(this.#cursor.takeByte(), this.#cursor.takeByte());
bytesRead += 2;
this.#decodedUserData.predefinedAnimations.push(predefinedAnimation);
break;
case 0x0E: //Large Animation (16*16 times 4 = 32*4 =128 bytes)
case 0x0F: //Small Animation (8*8 times 4 = 8*4 =32 bytes)
let animation = new Animation(this.#cursor.takeByte(), this.#cursor.take(iedl - 1), iei === 0x0E ? 16 : 8);
this.#decodedUserData.animations.push(animation);
bytesRead += iedl;
break;
case 0x10: //Large Picture (32*32 = 128 bytes)
case 0x11: //Small Picture (16*16 = 32 bytes)
const picturePosition = this.#cursor.takeByte();
const pictureData = this.#cursor.take(iedl - 1)
let picture;
if (iei === 0x10) {
picture = new LargePicture(picturePosition, pictureData);
} else {
picture = new SmallPicture(picturePosition, pictureData);
}
this.#decodedUserData.pictures.push(picture);
bytesRead += iedl;
break;
default:
this.#decodedUserData.errors.push(`Message contains an unsupported Information Element: ${iei.toString(16).padStart(2, '0')}`);
this.#cursor.take(iedl);
bytesRead += iedl; // skip unknown IE
}
return bytesRead;
}
}
export class PDUDecoder {
#cursor;
decode(u8) {
const buf = trimTrailingFFs(
u8 instanceof Uint8Array ? u8 : Uint8Array.from(u8)
);
if (buf.length < 2) return undefined;
this.#cursor = new ByteCursor(buf);
const smsCenterLength = this.#cursor.takeByte();
const smsCenterType = this.#cursor.takeByte();
let smsCenterNumber = '';
if (smsCenterLength > 1) {
const smsCenterRaw = this.#cursor.take(smsCenterLength - 1)
smsCenterNumber = semiPhone(smsCenterRaw);
}
const firstOctet = this.#cursor.peek(1)[0];
const messageType = firstOctet & 3;
if (messageType === 2) return this.#statusReport(smsCenterType, smsCenterNumber);
const decodedPdu = this.#decodePduFromFirstOctet();
return {
...decodedPdu,
smsCenterType,
smsCenterNumber,
};
}
decodeSmsDat(u8) {
const folderFlag = u8[0]; //01 = inbox read, 03 = inbox unread, 05 = outbox sent, 07 = outbox unsent
const decoded = this.decode(u8.subarray(1));
if (decoded === undefined) return undefined;
return {
...decoded,
folder: folderFlag,
format: 'SMS.dat'
};
}
#statusReport(scaType, scaNumber) {
const mr = this.#cursor.takeByte();
const recipientLen = this.#cursor.takeByte();
this.#cursor.takeByte(); // TOA
const recipient = semiPhone(this.#cursor.take(Math.ceil(recipientLen / 2)));
const dateAndTimeZoneOffset = sevenByteTimeStampToDateAndTimezoneOffset(this.#cursor.take(7));
const dischargeDateAndTimeZoneOffset = sevenByteTimeStampToDateAndTimezoneOffset(this.#cursor.take(7));
const status = this.#cursor.takeByte();
return {
type: 'STATUS_REPORT',
smsCenterType: scaType,
smsCenterNumber: scaNumber,
messageRef: mr,
recipient,
dateAndTimeZoneOffset,
dischargeDateAndTimeZoneOffset,
status
};
}
#decodePduFromFirstOctet() {
const firstOctet = this.#cursor.takeByte(); // consume FO
const firstOctetBits = byteToBooleansLSBFirst(firstOctet);
const isSubmit = firstOctetBits[0]; // TP-MTI bit 1
const isCommandOrStatusReport = firstOctetBits[1]; // TP-MTI bit 2
const rejectDuplicatesOrMoreMessagesToSend = firstOctetBits[2]; //TP-RD
const loopPrevention = firstOctetBits[3];
const validityPeriodFormat = firstOctetBits[3]; //TP-VPF bit 1
const validityPeriodFollowsInSubmit = firstOctetBits[4]; //TP-VPF bit 2
const statusReportStatus = firstOctetBits[5]; //TP-SRR
const udhiPresent = firstOctetBits[6]; //TP-UDHI
const replyPath = firstOctetBits[7]; //TP-RP
/**
* The Message Reference field (TP-MR) is used in all messages on the submission side with exception of
* the SMS-SUBMIT-REPORT (that is in SMS-SUBMIT, SMS-COMMAND and SMS-STATUS-REPORT).
* It is a single-octet value which is incremented each time a new message is submitted or a new SMS-COMMAND is sent.
* If the message submission fails, the mobile phone should repeat the submission with the same TP-MR value and
* with the TP-RD bit set to 1.
*/
let messageRef;
if (isSubmit) messageRef = this.#cursor.takeByte();
const addrLen = this.#cursor.takeByte();
const addrToa = this.#cursor.takeByte();
const addrRaw = this.#cursor.take(Math.ceil(addrLen / 2));
const isAlpha = (addrToa & 0x70) === 0x50;
let phone;
if (isAlpha) {
const alphaRaw = sevenBitDecode(addrRaw, 0, addrLen);
phone = alphaRaw.replace(/@+$/, '');
} else {
phone = semiPhone(addrRaw);
}
const pid = this.#cursor.takeByte(); //TP-PID
const dcs = this.#cursor.takeByte(); //TP-DCS
const bitsPerChar = alphaBits(dcs);
let dateAndTimeZoneOffset;
/**
* An SMS-SUBMIT TPDU may contain a TP-VP parameter which limits the time period for which the SMSC would attempt
* to deliver the message. However, the validity period is usually limited globally by the SMSC configuration parameter
* — often to 48 or 72 hours. The Validity Period format is defined by the Validity Period Format field:
* TP-VPF TP-VP format TP-VP length
* 0 0 TP-VP not present 0
* 0 1 Enhanced format 7
* 1 0 Relative format 1
* 1 1 Absolute format 7
*
*
* Relative format
* Relative Validity Period Values TP-VP value Validity period Possible validity periods
* 0–143 (TP-VP + 1) x 5 minutes 5, 10, 15 minutes ... 11:55, 12:00 hours
* 144–167 (12 + (TP-VP - 143) / 2 ) hours 12:30, 13:00, ... 23:30, 24:00 hours
* 168–196 (TP-VP - 166) days 2, 3, 4, ... 30 days
* 197–255 (TP-VP - 192) weeks 5, 6, 7, ... 63 weeks
*/
let validityPeriod;
if (isSubmit) {
if (validityPeriodFollowsInSubmit) {
//this only supports relative format, enhanced and absolute formats are 7 bytes
validityPeriod = this.#cursor.takeByte();
}
} else {
dateAndTimeZoneOffset = sevenByteTimeStampToDateAndTimezoneOffset(this.#cursor.take(7));
}
const udBody = this.#cursor.take(this.#cursor.remaining()); // rest of buffer
const userData = new UserDataDecoder().decode(udBody, udhiPresent, bitsPerChar);
const common = {
firstOctet,
udhiPresent,
pid,
dcs,
classDesc: dcs & 0x10 ? `class ${(dcs & 3)}` : '',
...userData,
};
return isSubmit
? { ...common, type: 'Outgoing', recipient: phone, messageRef, validityPeriod }
: { ...common, type: 'Incoming', sender: phone, dateAndTimeZoneOffset };
}
}
const FileFormats = Object.freeze({
SL4x: {
signature: Uint8Array.from([0x0b, 0x0b, 0x00, 0x00, 0x00]),
segmentStatusOffset: 5,
smsCOffset: 6
},
x45_C55: {
signature: Uint8Array.from([0x0b, 0x0b, 0x01, 0x01, 0x00]),
smsPartsOffset: 5,
smsTypeOffset: 7,
smsStatusOffset: 8,
timestampOffset: 9,
segmentStatusOffset: 16,
smsCOffset: 17
},
ME45_2: {
signature: Uint8Array.from([0x0b, 0x0b, 0x01, 0x0b, 0x00]),
smsPartsOffset: 5,
smsTypeOffset: 7,
smsStatusOffset: 8,
timestampOffset: 9,
segmentStatusOffset: 16,
smsCOffset: 17
},
X55_X65_X75: {
signature: Uint8Array.from([0x0b, 0x0b, 0x02, 0x0c, 0x00]),
smsPartsOffset: 5,
smsTypeOffset: 7,
smsStatusOffset: 8,
timestampOffset: 9,
segmentStatusOffset: 17,
smsCOffset: 18
}
});
export class SMSDecoder {
decode(buf) {
const b = buf instanceof Uint8Array ? buf : Uint8Array.from(buf);
if (b.length <= 5) throw new Error('File too short');
let cursor = new ByteCursor(b);
const signature = cursor.take(5);
let formatName;
for (const [entryFormatName, formatEntry] of Object.entries(FileFormats)) {
if (bytesEqual(signature, formatEntry.signature)) {
formatName = entryFormatName;
break;
}
}
if (formatName === undefined)
throw new Error(
`Unknown file format. First 5 bytes: ${bytesToHex(signature)}`
);
const format = FileFormats[formatName];
const segmentsTotal = format.smsPartsOffset ? cursor.takeByte() : 0;
const segmentsStored = format.smsPartsOffset ? cursor.takeByte() : 0;
const smsType = format.smsTypeOffset ? cursor.takeByte() : undefined;
const smsStatus = format.smsStatusOffset ? cursor.takeByte() : undefined;
const dateAndTimeZoneOffset = format.timestampOffset
? sevenByteTimeStampToDateAndTimezoneOffset(cursor.take(7))
: undefined;
if (format.segmentStatusOffset - format.timestampOffset > 7)
cursor.take(format.segmentStatusOffset - format.timestampOffset - 7); // waste byte
let parsingResult;
const htmlRenderer = new HTMLRenderer();
for (let part = 0; part < segmentsTotal; part++) {
let pdu;
if (cursor.remaining() < 176) {
console.warn(`Segment ${part + 1} incomplete – decoding anyway`);
pdu = cursor.take(cursor.remaining());
} else {
pdu = cursor.take(176);
}
if (format.segmentStatusOffset) {
// first byte is segment status – strip it
pdu = pdu.subarray(1);
}
const decodedPdu = new PDUDecoder().decode(pdu);
if (decodedPdu === undefined) continue;
if (parsingResult === undefined) {
parsingResult = {
...decodedPdu,
format: formatName,
segmentsTotal,
segmentsStored,
html: htmlRenderer.renderSegment(decodedPdu),
};
if (dateAndTimeZoneOffset !== undefined) parsingResult.dateAndTimeZoneOffset = dateAndTimeZoneOffset;
if (smsType !== undefined) parsingResult.smsType = smsType;
if (smsStatus !== undefined) parsingResult.smsStatus = smsStatus;
} else {
parsingResult.text += decodedPdu.text;
parsingResult.html += htmlRenderer.renderSegment(decodedPdu);
parsingResult.length += decodedPdu.length;
}
}
return parsingResult;
}
}
function mergeSegments(segments) {
const multipartBuckets = new Map(); // key → {meta, parts[]}
const concatenatedMessages = [];
for (const segment of segments) {
if (segment.referenceNumber === undefined || segment.segmentsTotal === 1) { // plain single‑segment SMS
segment.segmentsTotal = 1
segment.segmentsStored = 1
concatenatedMessages.push(segment);
continue;
}
// Use sender/recipient + ref as bucket key
const peer = segment.sender ?? segment.recipient;
const dir = segment.sender ? 'IN' : 'OUT';
const key = `${dir}:${peer}:${segment.referenceNumber}`;
let bucket = multipartBuckets.get(key);
if (bucket === undefined) {
bucket = {
first: segment,
total: segment.segmentsTotal,
parts: new Array(segment.segmentsTotal).fill(undefined)
};
multipartBuckets.set(key, bucket);
}
if (bucket.parts[segment.sequenceNumber - 1] === undefined) {
bucket.parts[segment.sequenceNumber - 1] = segment;
} else {
console.warn(`Duplicate segment ${segment.sequenceNumber} of ${segment.referenceNumber} from ${peer}`);
}
}
// Assemble buckets whose part list is complete
for (const {first, total, parts} of multipartBuckets.values()) {
const merged = {...first}; // shallow clone
merged.referenceNumber = first.referenceNumber;
merged.totalSegments = total;
merged.sequenceNumber = 1;
merged.segmentsStored = 0;
merged.legnth = 0;
merged.text = '';
merged.html = '';
merged.errors = [];
for (const part of parts) {
if (part === undefined) {
merged.text += '<missing segment>';
} else {
merged.segmentsStored++;
merged.length += part.length;
merged.text += part.text;
merged.html += part.html;
merged.errors = [...merged.errors, ...part.errors];
}
}
concatenatedMessages.push(merged);
}
return concatenatedMessages;
}
export class SMSDatParser {
decode(buf) {
const b = buf instanceof Uint8Array ? buf : Uint8Array.from(buf);
if (b.length <= 178) throw new Error('File too short');
let cursor = new ByteCursor(b);
const NSG_EMPTY = Uint8Array.from([0xff, 0xff]);
const EXPECTED_HEADER = Uint8Array.from([0x11, 0x11]);
const htmlRenderer = new HTMLRenderer();
const segments = [];
let messageIndex = 0;
while (cursor.remaining() >= 2) {
const hdr = cursor.take(2);
if (bytesEqual(hdr, NSG_EMPTY)) continue;
if (!bytesEqual(hdr, EXPECTED_HEADER))
throw new Error(`Invalid PDU header: ${bytesToHex(hdr)}`);
if (cursor.remaining() < 176)
console.warn('Incomplete PDU record in SMS.dat, attempting a partial read');
const pdu = cursor.take(176);
const decoded = new PDUDecoder().decodeSmsDat(pdu);
if (decoded !== undefined) {
decoded.messageIndex = messageIndex;
decoded.html = htmlRenderer.renderSegment(decoded);
segments.push(decoded);
}
messageIndex++;
}
return mergeSegments(segments);
}
}
const predefinedAnimations = [
'I am ironic, flirty',
'I am glad',
'I am sceptic',
'I am sad',
'WOW!',
'I am crying',
'I am winking',
'I am laughing',
'I am indifferent',
'In love/Kissing',
'I am confused',
'Tongue hanging out',
'I am angry',
'Wearing glasses',
'Devil'
];
const predefinedAnimationUrls = [
new URL('./img/predefined-animations/1.webp', import.meta.url).href,
new URL('./img/predefined-animations/2.webp', import.meta.url).href,
new URL('./img/predefined-animations/3.webp', import.meta.url).href,
new URL('./img/predefined-animations/4.webp', import.meta.url).href,
new URL('./img/predefined-animations/5.webp', import.meta.url).href,
new URL('./img/predefined-animations/6.webp', import.meta.url).href,
new URL('./img/predefined-animations/7.webp', import.meta.url).href,
new URL('./img/predefined-animations/8.webp', import.meta.url).href,
new URL('./img/predefined-animations/9.webp', import.meta.url).href,
new URL('./img/predefined-animations/10.webp', import.meta.url).href,
new URL('./img/predefined-animations/11.webp', import.meta.url).href,
new URL('./img/predefined-animations/12.webp', import.meta.url).href,
new URL('./img/predefined-animations/13.webp', import.meta.url).href,
new URL('./img/predefined-animations/14.webp', import.meta.url).href,
new URL('./img/predefined-animations/15.webp', import.meta.url).href,
];
const predefinedSoundUrls = [
new URL('./predefined-sounds/1.webm', import.meta.url).href,
new URL('./predefined-sounds/2.webm', import.meta.url).href,
new URL('./predefined-sounds/3.webm', import.meta.url).href,
new URL('./predefined-sounds/4.webm', import.meta.url).href,
new URL('./predefined-sounds/5.webm', import.meta.url).href,
new URL('./predefined-sounds/6.webm', import.meta.url).href,
new URL('./predefined-sounds/7.webm', import.meta.url).href,
new URL('./predefined-sounds/8.webm', import.meta.url).href,
new URL('./predefined-sounds/9.webm', import.meta.url).href,
new URL('./predefined-sounds/10.webm', import.meta.url).href,
];
const playButtonIcon = new URL('./img/play-button-icon.svg', import.meta.url).href;
export class HTMLRenderer {
#escapeHtml(raw) {
return raw.replace(/[&<>"']/g, ch => ({
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": ''',
}[ch]));
}
renderSegment(segment) {
let insertions = [];
for (let newlineIndex = segment.text.indexOf('\n'); newlineIndex !== -1; newlineIndex = segment.text.indexOf('\n', newlineIndex + 1)) {
insertions.push({ position: newlineIndex + 1, text: '<br>' });
}
insertions.push(...this.#getTextFormattingInsertions(segment));
for (const predefinedSound of segment.predefinedSounds) {
let text;
if (predefinedSound.soundNumber > 9) {
text = '<Incorrect predefined sound>';
} else {
text = `<a class="predefined-sound" onclick="this.querySelector('audio').play()" href="javascript:void(0)">
<img style="width:13px;" src="${playButtonIcon}" alt="Play Predefined Sound">
<audio preload="auto">
<source src="${predefinedSoundUrls[predefinedSound.soundNumber]}" type="audio/webm; codecs=opus">
</audio>
</a>`;
}
insertions.push({
position: predefinedSound.position,
text,
});
}
for (const predefinedAnimation of segment.predefinedAnimations) {
let text;
if (predefinedAnimation.animationNumber >= predefinedAnimations.length) {
text = '<Incorrect predefined animation>';
} else {
const imageUrl = predefinedAnimationUrls[predefinedAnimation.animationNumber];
text = `<img class="predefined-animation" style="image-rendering: pixelated;" src="${imageUrl}" alt="${predefinedAnimations[predefinedAnimation.animationNumber]}">`;
}
insertions.push({
position: predefinedAnimation.position,
text,
});
}
if (segment.iMelodies.length > 0) {
if (typeof window !== 'undefined' && window.playIMelody === undefined) window.playIMelody = playIMelody;
}
for (const iMelody of segment.iMelodies) {
const encoded = encodeURIComponent(iMelody.iMelodyString);
insertions.push({
position: iMelody.position,
text: `<a class="i-melody" data-i-melody="${encoded}" onclick="playIMelody(decodeURIComponent(this.dataset.iMelody)); return" href="javascript:void(0)"><img style="width:13px;" src="${playButtonIcon}" alt="Play iMelody"></a>`,});
}
for (const picture of segment.pictures) {
let pictureType;
if (picture instanceof LargePicture) {
pictureType = 'large';
} else {
pictureType = 'small';
}
insertions.push({
position: picture.position,
text: `<img
style="image-rendering: pixelated;"
class="picture picture-${pictureType}"
src="${picture.readAsDataUrl()}"
alt="User Picture"
>`,
});
}
for (const animation of segment.animations) {
insertions.push({
position: animation.position,
text: `<canvas
width="${animation.sideLength}"
height="${animation.sideLength}"
class="animation"
style="image-rendering: pixelated;"
data-animation="${convertUint8ArrayToBase64(animation.animationData)}"
></canvas>`,
});
}
insertions = insertions.sort((a, b) => a.position - b.position);
// Build output while preserving positions
let lastIndex = 0;
const htmlParts = [];
for (const { position, text } of insertions) {
htmlParts.push(this.#escapeHtml(segment.text.slice(lastIndex, position)));
htmlParts.push(text);
lastIndex = position;
}
htmlParts.push(this.#escapeHtml(segment.text.slice(lastIndex)));
return htmlParts.join('');
}
calculateTextFormattingClassAndStyle(textFormatting) {
const classes = [];
const styles = [];
if (textFormatting.length === 0) return { classes, styles };
const alignment = textFormatting.getTextAlignment()
if (alignment !== TextAlignment.LanguageDependent) {
let alignmentCssValue;
switch (alignment) {
case TextAlignment.Left:
alignmentCssValue = 'left';
break;
case TextAlignment.Right:
alignmentCssValue = 'right';
break;
case TextAlignment.Center:
alignmentCssValue = 'center';
break;
}
classes.push(`text-alignment-${alignmentCssValue}`);
styles.push(`text-align:${alignmentCssValue}`);
}
const fontSize = textFormatting.getFontSize();
if (fontSize !== FontSize.Normal) {
let fontSizeCssValue;
let fontSizeClass;
switch (fontSize) {
case FontSize.Small:
fontSizeCssValue = '8px';
fontSizeClass = 'small';
break;
case FontSize.Large:
fontSizeCssValue = '24px';
fontSizeClass = 'large';
break;
default:
fontSizeClass = 'unknown';
console.warn(`Unknown font size: ${textFormatting.getFontSize()}`);
}
classes.push(`font-size-${fontSizeClass}`);
if (fontSizeCssValue !== undefined) {
styles.push(`font-size:${fontSizeCssValue}`);
}
}
if (textFormatting.isBold()) {
classes.push('text-bold');
styles.push('font-weight: bold');
}
if (textFormatting.isItalic()) {
classes.push('text-italic');
styles.push('font-style: italic');
}
if (textFormatting.isUnderlined()) {
classes.push('text-underline');
if (textFormatting.isStrikethrough()) {
classes.push('text-strikethrough');
styles.push('text-decoration: line-through underline');
} else {
styles.push('text-decoration: underline');
}
} else if (textFormatting.isStrikethrough()) {
classes.push('text-strikethrough');
styles.push('text-decoration: line-through');
}
const foregroundColor = textFormatting.getForegroundColor();
if (foregroundColor !== undefined) {
classes.push(`text-color-${foregroundColor.name}`);
styles.push(`color: ${foregroundColor.htmlHex}`);
}
const backgroundColor = textFormatting.getBackgroundColor();
if (backgroundColor !== undefined) {
classes.push(`background-color-${backgroundColor.name}`);
styles.push(`background-color: ${backgroundColor.htmlHex}`);
}
return { classes, styles };
}
#getTextFormattingInsertions(segment) {
if (segment.textFormattings.length === 0) return [];
let textByCharacter = segment.text.split('');
let styleByCharacter = [];
let classByCharacter = [];
for (const character of textByCharacter) {
styleByCharacter.push("");
classByCharacter.push("");
}
for (const textFormatting of segment.textFormattings) {
const {classes, styles} = this.calculateTextFormattingClassAndStyle(textFormatting);
if (classes.length === 0) continue;
const finalCharacterIndex = textFormatting.position + textFormatting.length;
for (let characterIndex = textFormatting.position; characterIndex < finalCharacterIndex; characterIndex++) {
// per the spec, execute the formatting directives in the order they are encountered
// and override the previous directive if there is an overalp
classByCharacter[characterIndex] = classes.join(' ');
styleByCharacter[characterIndex] = styles.join('; ')
}
}
const insertions = [];
let lastClass = '';
let spanOpen = false;
for (let characterIndex=0; characterIndex < styleByCharacter.length;