package internal import ( "bytes" "database/sql" "encoding/base64" "encoding/xml" "fmt" "io" "log/slog" "os" "os/exec" "path/filepath" "runtime" "sort" "strconv" "strings" "sync" "time" ) type SMSBackup struct { XMLName xml.Name `xml:"smses"` Count int `xml:"count,attr"` Messages []SMSEntry `xml:"sms"` MMS []MMSEntry `xml:"mms"` Calls []CallEntry `xml:"call"` } type SMSEntry struct { Address string `xml:"address,attr"` Date string `xml:"date,attr"` Type string `xml:"type,attr"` Body string `xml:"body,attr"` Read string `xml:"read,attr"` ThreadID string `xml:"thread_id,attr"` Subject string `xml:"subject,attr"` Protocol string `xml:"protocol,attr"` TOA string `xml:"toa,attr"` SCTOA string `xml:"sc_toa,attr"` ServiceCenter string `xml:"service_center,attr"` Status string `xml:"status,attr"` SubID string `xml:"sub_id,attr"` ReadableDate string `xml:"readable_date,attr"` ContactName string `xml:"contact_name,attr"` } type MMSEntry struct { Address string `xml:"address,attr"` Date string `xml:"date,attr"` Type string `xml:"msg_box,attr"` Read string `xml:"read,attr"` ThreadID string `xml:"thread_id,attr"` Subject string `xml:"sub,attr"` TrID string `xml:"tr_id,attr"` ContentType string `xml:"ct_t,attr"` ReadReport string `xml:"rr,attr"` ReadStatus string `xml:"read_status,attr"` MessageID string `xml:"m_id,attr"` MessageSize string `xml:"m_size,attr"` MessageType string `xml:"m_type,attr"` SimSlot string `xml:"sim_slot,attr"` ReadableDate string `xml:"readable_date,attr"` ContactName string `xml:"contact_name,attr"` Parts []MMSPart `xml:"parts>part"` Addrs []MMSAddr `xml:"addrs>addr"` Body string `xml:"body,attr"` } type MMSPart struct { Seq string `xml:"seq,attr"` ContentType string `xml:"ct,attr"` Name string `xml:"name,attr"` Charset string `xml:"chset,attr"` CL string `xml:"cl,attr"` Text string `xml:"text,attr"` Data string `xml:"data,attr"` } type MMSAddr struct { Address string `xml:"address,attr"` Type string `xml:"type,attr"` Charset string `xml:"charset,attr"` } type CallEntry struct { Number string `xml:"number,attr"` Duration string `xml:"duration,attr"` Date string `xml:"date,attr"` Type string `xml:"type,attr"` Presentation string `xml:"presentation,attr"` SubscriptionID string `xml:"subscription_id,attr"` ReadableDate string `xml:"readable_date,attr"` ContactName string `xml:"contact_name,attr"` } type ParseResult struct { Messages []Message Calls []CallLog } func ParseSMSBackup(r io.Reader) (ParseResult, error) { var backup SMSBackup decoder := xml.NewDecoder(r) err := decoder.Decode(&backup) if err != nil { return ParseResult{}, err } var result ParseResult // Parse SMS messages for _, sms := range backup.Messages { msg, err := convertSMSEntry(sms) if err != nil { slog.Error("Error parsing SMS", "error", err) continue } result.Messages = append(result.Messages, msg) } // Parse MMS messages for _, mms := range backup.MMS { msg, err := convertMMSEntry(mms) if err != nil { slog.Error("Error parsing MMS", "error", err) continue } result.Messages = append(result.Messages, msg) } // Parse call logs for _, call := range backup.Calls { callLog, err := convertCallEntry(call) if err != nil { slog.Error("Error parsing call log", "error", err) continue } result.Calls = append(result.Calls, callLog) } return result, nil } func convertSMSEntry(sms SMSEntry) (Message, error) { dateMs, err := strconv.ParseInt(sms.Date, 10, 64) if err != nil { return Message{}, err } msgType, _ := strconv.Atoi(sms.Type) read := sms.Read == "1" threadID, _ := strconv.Atoi(sms.ThreadID) protocol, _ := strconv.Atoi(sms.Protocol) status, _ := strconv.Atoi(sms.Status) subID, _ := strconv.Atoi(sms.SubID) // Normalize the phone number to remove formatting differences normalizedAddress := normalizePhoneNumber(sms.Address) // For SMS, the address is the single phone number addresses := []string{} if normalizedAddress != "" { addresses = append(addresses, normalizedAddress) } // For received SMS messages, the sender is the address var sender string if msgType == 1 && normalizedAddress != "" { sender = normalizedAddress } return Message{ Address: normalizedAddress, Body: sms.Body, Type: msgType, Date: time.Unix(dateMs/1000, 0), Read: read, ThreadID: threadID, Subject: normalizeNullString(sms.Subject), Protocol: protocol, Status: status, ServiceCenter: sms.ServiceCenter, SubID: subID, ContactName: sms.ContactName, Sender: sender, Addresses: addresses, }, nil } func convertMMSEntry(mms MMSEntry) (Message, error) { dateMs, err := strconv.ParseInt(mms.Date, 10, 64) if err != nil { return Message{}, err } msgType, _ := strconv.Atoi(mms.Type) read := mms.Read == "1" threadID, _ := strconv.Atoi(mms.ThreadID) readReport, _ := strconv.Atoi(mms.ReadReport) readStatus, _ := strconv.Atoi(mms.ReadStatus) messageSize, _ := strconv.Atoi(mms.MessageSize) messageType, _ := strconv.Atoi(mms.MessageType) simSlot, _ := strconv.Atoi(mms.SimSlot) // Normalize the phone number to remove formatting differences normalizedAddress := normalizePhoneNumber(mms.Address) // Extract all addresses from MMS and find the sender (type 137 = FROM) // Include ALL addresses to keep group conversations consistent addressMap := make(map[string]bool) var senderAddress string var firstAddress string for _, addr := range mms.Addrs { if addr.Address != "" { // Normalize each address to prevent duplicates due to formatting normalizedAddr := normalizePhoneNumber(addr.Address) if normalizedAddr != "" { addressMap[normalizedAddr] = true // Remember the first address we encounter if firstAddress == "" { firstAddress = normalizedAddr } // Type 137 (0x89) = FROM (sender in Android MMS) // For received messages, this tells us who sent it addrType, _ := strconv.Atoi(addr.Type) if addrType == 137 { senderAddress = normalizedAddr } } } } // If no type 137 sender was found for a received message, use the first address // or the single address for 1-on-1 conversations if msgType == 1 && senderAddress == "" { if len(addressMap) == 1 && firstAddress != "" { // 1-on-1 conversation: the single address is definitely the sender senderAddress = firstAddress } else if len(addressMap) > 1 && firstAddress != "" { // Group conversation without explicit sender: use first address as best guess senderAddress = firstAddress } } // Convert map to sorted, deduplicated slice addresses := make([]string, 0, len(addressMap)) for addr := range addressMap { addresses = append(addresses, addr) } // Sort addresses for consistency sort.Strings(addresses) // Determine the primary address field for conversation grouping var primaryAddress string if len(addresses) >= 3 { // Group MMS (3+ participants) - join all normalized addresses to create a consistent group identifier primaryAddress = strings.Join(addresses, ",") } else if len(addresses) > 0 { // MMS with 1-2 addresses - use the normalized address primaryAddress = normalizedAddress } else { // Fallback to normalized mms.Address if no addresses found in mms.Addrs primaryAddress = normalizedAddress } // For received messages, store the sender in the Sender field // This allows us to display who sent each message in the UI var sender string if msgType == 1 && senderAddress != "" { // Received message - store the sender address sender = senderAddress } msg := Message{ Address: primaryAddress, Type: msgType, Date: time.Unix(dateMs/1000, 0), Read: read, ThreadID: threadID, Subject: normalizeNullString(mms.Subject), ContentType: mms.ContentType, ReadReport: readReport, ReadStatus: readStatus, MessageID: mms.MessageID, MessageSize: messageSize, MessageType: messageType, SimSlot: simSlot, ContactName: mms.ContactName, Sender: sender, Addresses: addresses, } // Extract body text and media from parts var bodyText string for _, part := range mms.Parts { // Skip SMIL content - it's presentation metadata, not actual message content if isSMILContentType(part.ContentType) { continue } // Check for VCF (vCard) files - these are text/* but should be treated as media attachments if isVCardContentType(part.ContentType) && part.Data != "" { if msg.MediaType == "" { // Only store first media item data, err := base64.StdEncoding.DecodeString(part.Data) if err == nil { msg.MediaType = part.ContentType msg.MediaData = data } } continue } // Check for media - media parts often have text="null" which should be ignored if part.ContentType != "" && part.Data != "" && !isTextContentType(part.ContentType) { // This is media content (image, video, audio, etc.) if msg.MediaType == "" { // Only store first media item data, err := base64.StdEncoding.DecodeString(part.Data) if err == nil { // Store all media as-is (including HEIC images in original format) msg.MediaType = part.ContentType msg.MediaData = data } } } else if part.Text != "" && normalizeNullString(part.Text) != "" { // This is actual text content (not "null") bodyText += part.Text + " " } } if bodyText != "" { msg.Body = strings.TrimSpace(bodyText) } // Extract group name from RCS proto: tr_id if available // Use it as the subject if the current subject is empty or starts with "proto:" if mms.TrID != "" && strings.HasPrefix(mms.TrID, "proto:") { groupName := extractGroupNameFromTrID(mms.TrID) if groupName != "" { // Only use the extracted name if subject is empty or also starts with "proto:" if msg.Subject == "" || strings.HasPrefix(mms.Subject, "proto:") { msg.Subject = groupName } } } return msg, nil } // normalizeNullString converts the string "null" to an empty string func normalizeNullString(s string) string { if strings.TrimSpace(strings.ToLower(s)) == "null" { return "" } return s } // isTextContentType checks if a content type is text-based func isTextContentType(contentType string) bool { ct := strings.ToLower(strings.TrimSpace(contentType)) return strings.HasPrefix(ct, "text/") || ct == "application/xml" || ct == "application/json" } // isSMILContentType checks if a content type is SMIL markup func isSMILContentType(contentType string) bool { ct := strings.ToLower(strings.TrimSpace(contentType)) return ct == "application/smil" || strings.HasPrefix(ct, "application/smil+") || strings.Contains(ct, "smil") } // isSMILMarkup checks if the body text is SMIL (Synchronized Multimedia Integration Language) markup // which is MMS presentation metadata and should not be displayed to users func isSMILMarkup(body string) bool { trimmed := strings.TrimSpace(body) return strings.HasPrefix(trimmed, "