RCS extraction via MMS content provider — no root/exploit needed
Discovery: Google Messages writes ALL RCS messages to content://mms/
as MMS records. Message body in content://mms/{id}/part (ct=text/plain).
RCS metadata (group name, SIP URI) protobuf-encoded in tr_id field.
Sender addresses in content://mms/{id}/addr.
Tested on Pixel 10 Pro Fold, Android 16, Feb 2026 patch — works at
UID 2000 with zero exploits, zero root, zero Shizuku.
New methods:
- read_rcs_via_mms(): extract RCS+MMS with body, addresses, metadata
- read_rcs_only(): filter to RCS messages only (proto: in tr_id)
- read_rcs_threads(): unique conversation threads with latest message
- backup_rcs_to_xml(): full SMS+MMS+RCS backup in SMS Backup & Restore XML
Fixed _content_query() Windows quoting (single quotes for sort/where).
New routes: /rcs-via-mms, /rcs-only, /rcs-threads, /backup-rcs-xml
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
30551a3c53
commit
6c8e9235c9
@ -298,13 +298,15 @@ class RCSTools:
|
|||||||
|
|
||||||
def _content_query(self, uri: str, projection: str = '', where: str = '',
|
def _content_query(self, uri: str, projection: str = '', where: str = '',
|
||||||
sort: str = '', limit: int = 0) -> List[Dict[str, str]]:
|
sort: str = '', limit: int = 0) -> List[Dict[str, str]]:
|
||||||
|
# Build shell command — use single quotes for sort/where to avoid
|
||||||
|
# Windows double-quote stripping issues
|
||||||
cmd = f'shell content query --uri {uri}'
|
cmd = f'shell content query --uri {uri}'
|
||||||
if projection:
|
if projection:
|
||||||
cmd += f' --projection {projection}'
|
cmd += f' --projection {projection}'
|
||||||
if where:
|
if where:
|
||||||
cmd += f' --where "{where}"'
|
cmd += f" --where '{where}'"
|
||||||
if sort:
|
if sort:
|
||||||
cmd += f' --sort "{sort}"'
|
cmd += f" --sort '{sort}'"
|
||||||
output = self._run_adb(cmd, timeout=30)
|
output = self._run_adb(cmd, timeout=30)
|
||||||
rows = self._parse_content_query(output)
|
rows = self._parse_content_query(output)
|
||||||
if limit > 0:
|
if limit > 0:
|
||||||
@ -659,6 +661,121 @@ class RCSTools:
|
|||||||
row['date_formatted'] = self._format_ts(int(row['date']) * 1000)
|
row['date_formatted'] = self._format_ts(int(row['date']) * 1000)
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
def read_rcs_via_mms(self, thread_id: Optional[int] = None, limit: int = 200) -> List[Dict[str, Any]]:
|
||||||
|
"""Read RCS messages via the MMS content provider.
|
||||||
|
|
||||||
|
DISCOVERY: Google Messages writes ALL RCS messages to content://mms/
|
||||||
|
as MMS records. The message body is in content://mms/{id}/part
|
||||||
|
(ct=text/plain). RCS metadata (group name, SIP conference URI) is
|
||||||
|
protobuf-encoded in the tr_id field. Sender addresses are in
|
||||||
|
content://mms/{id}/addr.
|
||||||
|
|
||||||
|
This works on ANY Android with ADB access (UID 2000) — no root,
|
||||||
|
no exploits, no Shizuku needed. Tested on Pixel 10 Pro Fold,
|
||||||
|
Android 16, February 2026 patch.
|
||||||
|
"""
|
||||||
|
# Get MMS entries (which include RCS messages synced by Google Messages)
|
||||||
|
where = f'thread_id={thread_id}' if thread_id else ''
|
||||||
|
mms_rows = self._content_query(
|
||||||
|
MMS_URI,
|
||||||
|
projection='_id:thread_id:date:msg_box:sub:text_only:tr_id',
|
||||||
|
where=where,
|
||||||
|
sort='date DESC',
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
messages = []
|
||||||
|
for row in mms_rows:
|
||||||
|
mms_id = row.get('_id')
|
||||||
|
if not mms_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
msg = {
|
||||||
|
'_id': mms_id,
|
||||||
|
'thread_id': row.get('thread_id'),
|
||||||
|
'date': row.get('date'),
|
||||||
|
'msg_box': row.get('msg_box'),
|
||||||
|
'direction': 'incoming' if row.get('msg_box') == '1' else 'outgoing',
|
||||||
|
'tr_id': row.get('tr_id', ''),
|
||||||
|
'is_rcs': False,
|
||||||
|
'body': '',
|
||||||
|
'addresses': [],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if this is an RCS message (tr_id starts with "proto:")
|
||||||
|
tr_id = row.get('tr_id', '') or ''
|
||||||
|
if tr_id.startswith('proto:'):
|
||||||
|
msg['is_rcs'] = True
|
||||||
|
msg['protocol_name'] = 'RCS'
|
||||||
|
# Try to decode group/conversation name from protobuf
|
||||||
|
msg['rcs_metadata'] = tr_id[:100]
|
||||||
|
else:
|
||||||
|
msg['protocol_name'] = 'MMS'
|
||||||
|
|
||||||
|
# Get message body from parts
|
||||||
|
parts = self._content_query(
|
||||||
|
f'content://mms/{mms_id}/part',
|
||||||
|
projection='_id:ct:text',
|
||||||
|
)
|
||||||
|
for p in parts:
|
||||||
|
if p.get('ct') == 'text/plain' and p.get('text'):
|
||||||
|
msg['body'] = p['text']
|
||||||
|
break
|
||||||
|
|
||||||
|
# Get sender/recipient addresses
|
||||||
|
addrs = self._content_query(
|
||||||
|
f'content://mms/{mms_id}/addr',
|
||||||
|
projection='address:type',
|
||||||
|
)
|
||||||
|
for a in addrs:
|
||||||
|
addr = a.get('address', '')
|
||||||
|
addr_type = a.get('type', '')
|
||||||
|
if addr and addr != 'insert-address-token':
|
||||||
|
msg['addresses'].append({'address': addr, 'type': addr_type})
|
||||||
|
# Type 137 = FROM, 151 = BCC/self, 130 = TO
|
||||||
|
if addr_type == '137':
|
||||||
|
msg['sender'] = addr
|
||||||
|
|
||||||
|
# Format timestamp (MMS dates are in seconds, not ms)
|
||||||
|
if msg['date']:
|
||||||
|
try:
|
||||||
|
ts = int(msg['date'])
|
||||||
|
if ts < 10000000000: # seconds
|
||||||
|
ts *= 1000
|
||||||
|
msg['date_ms'] = ts
|
||||||
|
msg['date_formatted'] = self._format_ts(ts)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
messages.append(msg)
|
||||||
|
|
||||||
|
return messages
|
||||||
|
|
||||||
|
def read_rcs_only(self, limit: int = 200) -> List[Dict[str, Any]]:
|
||||||
|
"""Read ONLY RCS messages (filter MMS entries with proto: in tr_id)."""
|
||||||
|
all_mms = self.read_rcs_via_mms(limit=limit * 2)
|
||||||
|
return [m for m in all_mms if m.get('is_rcs')][:limit]
|
||||||
|
|
||||||
|
def read_rcs_threads(self) -> List[Dict[str, Any]]:
|
||||||
|
"""Get unique RCS conversation threads with latest message."""
|
||||||
|
all_rcs = self.read_rcs_via_mms(limit=5000)
|
||||||
|
threads = {}
|
||||||
|
for msg in all_rcs:
|
||||||
|
tid = msg.get('thread_id')
|
||||||
|
if tid and tid not in threads:
|
||||||
|
threads[tid] = {
|
||||||
|
'thread_id': tid,
|
||||||
|
'latest_message': msg.get('body', '')[:100],
|
||||||
|
'latest_date': msg.get('date_formatted', ''),
|
||||||
|
'is_rcs': msg.get('is_rcs', False),
|
||||||
|
'direction': msg.get('direction'),
|
||||||
|
'addresses': msg.get('addresses', []),
|
||||||
|
'message_count': 0,
|
||||||
|
}
|
||||||
|
if tid in threads:
|
||||||
|
threads[tid]['message_count'] += 1
|
||||||
|
return list(threads.values())
|
||||||
|
|
||||||
def read_conversations(self, limit: int = 100) -> List[Dict[str, Any]]:
|
def read_conversations(self, limit: int = 100) -> List[Dict[str, Any]]:
|
||||||
rows = self._content_query(MMS_SMS_CONVERSATIONS_URI, limit=limit)
|
rows = self._content_query(MMS_SMS_CONVERSATIONS_URI, limit=limit)
|
||||||
return rows
|
return rows
|
||||||
@ -1654,6 +1771,98 @@ class RCSTools:
|
|||||||
# §10 DATABASE BACKUP & CLONE
|
# §10 DATABASE BACKUP & CLONE
|
||||||
# ══════════════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def backup_rcs_to_xml(self) -> Dict[str, Any]:
|
||||||
|
"""Backup all RCS messages to SMS Backup & Restore compatible XML.
|
||||||
|
|
||||||
|
Reads RCS messages from the MMS content provider (where Google Messages
|
||||||
|
syncs them as MMS records), extracts the plaintext body from parts,
|
||||||
|
and writes them in SMS Backup & Restore XML format.
|
||||||
|
|
||||||
|
Works on any Android with ADB — no root, no exploits.
|
||||||
|
"""
|
||||||
|
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||||
|
# Get ALL messages via MMS provider (includes both MMS and RCS)
|
||||||
|
all_msgs = self.read_rcs_via_mms(limit=10000)
|
||||||
|
rcs_msgs = [m for m in all_msgs if m.get('is_rcs')]
|
||||||
|
all_sms = self.read_sms_database(limit=10000)
|
||||||
|
|
||||||
|
# Build XML
|
||||||
|
total = len(all_sms) + len(all_msgs)
|
||||||
|
root = ET.Element('smses', count=str(total), backup_date=str(self._ts_ms()),
|
||||||
|
type='full', autarch_version='2.3')
|
||||||
|
|
||||||
|
# Add SMS messages
|
||||||
|
for msg in all_sms:
|
||||||
|
attrs = {
|
||||||
|
'protocol': str(msg.get('protocol', '0') or '0'),
|
||||||
|
'address': str(msg.get('address', '') or ''),
|
||||||
|
'date': str(msg.get('date', '') or ''),
|
||||||
|
'type': str(msg.get('type', '1') or '1'),
|
||||||
|
'body': str(msg.get('body', '') or ''),
|
||||||
|
'read': str(msg.get('read', '1') or '1'),
|
||||||
|
'status': str(msg.get('status', '-1') or '-1'),
|
||||||
|
'locked': str(msg.get('locked', '0') or '0'),
|
||||||
|
'date_sent': str(msg.get('date_sent', '0') or '0'),
|
||||||
|
'readable_date': str(msg.get('date_formatted', '') or ''),
|
||||||
|
'contact_name': str(msg.get('contact_name', '(Unknown)') or '(Unknown)'),
|
||||||
|
'msg_protocol': 'SMS',
|
||||||
|
}
|
||||||
|
ET.SubElement(root, 'sms', **attrs)
|
||||||
|
|
||||||
|
# Add RCS/MMS messages
|
||||||
|
for msg in all_msgs:
|
||||||
|
# Get sender address
|
||||||
|
sender = msg.get('sender', '')
|
||||||
|
if not sender and msg.get('addresses'):
|
||||||
|
for a in msg['addresses']:
|
||||||
|
if a.get('address') and a['address'] != 'insert-address-token':
|
||||||
|
sender = a['address']
|
||||||
|
break
|
||||||
|
ts_ms = msg.get('date_ms', msg.get('date', '0'))
|
||||||
|
msg_type = '1' if msg.get('direction') == 'incoming' else '2'
|
||||||
|
attrs = {
|
||||||
|
'protocol': '0',
|
||||||
|
'address': sender,
|
||||||
|
'date': str(ts_ms),
|
||||||
|
'type': msg_type,
|
||||||
|
'body': str(msg.get('body', '') or ''),
|
||||||
|
'read': '1',
|
||||||
|
'status': '-1',
|
||||||
|
'locked': '0',
|
||||||
|
'date_sent': str(ts_ms),
|
||||||
|
'readable_date': str(msg.get('date_formatted', '') or ''),
|
||||||
|
'contact_name': '(Unknown)',
|
||||||
|
'msg_protocol': 'RCS' if msg.get('is_rcs') else 'MMS',
|
||||||
|
'thread_id': str(msg.get('thread_id', '') or ''),
|
||||||
|
}
|
||||||
|
# Add RCS metadata if available
|
||||||
|
if msg.get('is_rcs') and msg.get('rcs_metadata'):
|
||||||
|
attrs['rcs_tr_id'] = msg['rcs_metadata']
|
||||||
|
# Add all addresses as comma-separated for group chats
|
||||||
|
if len(msg.get('addresses', [])) > 1:
|
||||||
|
attrs['group_addresses'] = ','.join(
|
||||||
|
a['address'] for a in msg['addresses']
|
||||||
|
if a.get('address') and a['address'] != 'insert-address-token'
|
||||||
|
)
|
||||||
|
ET.SubElement(root, 'sms', **attrs)
|
||||||
|
|
||||||
|
# Write to file
|
||||||
|
backup_path = self._backups_dir / f'rcs_backup_{timestamp}.xml'
|
||||||
|
tree = ET.ElementTree(root)
|
||||||
|
ET.indent(tree, space=' ')
|
||||||
|
tree.write(str(backup_path), encoding='unicode', xml_declaration=True)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'ok': True,
|
||||||
|
'path': str(backup_path),
|
||||||
|
'sms_count': len(all_sms),
|
||||||
|
'mms_count': len(all_msgs) - len(rcs_msgs),
|
||||||
|
'rcs_count': len(rcs_msgs),
|
||||||
|
'total': total,
|
||||||
|
'message': f'Backup saved: {len(all_sms)} SMS + {len(rcs_msgs)} RCS + '
|
||||||
|
f'{len(all_msgs) - len(rcs_msgs)} MMS = {total} total',
|
||||||
|
}
|
||||||
|
|
||||||
def full_backup(self, fmt: str = 'json') -> Dict[str, Any]:
|
def full_backup(self, fmt: str = 'json') -> Dict[str, Any]:
|
||||||
"""Complete SMS/MMS/RCS backup."""
|
"""Complete SMS/MMS/RCS backup."""
|
||||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||||
|
|||||||
@ -162,6 +162,38 @@ def mms():
|
|||||||
return jsonify({'ok': True, 'messages': msgs, 'count': len(msgs)})
|
return jsonify({'ok': True, 'messages': msgs, 'count': len(msgs)})
|
||||||
|
|
||||||
|
|
||||||
|
@rcs_tools_bp.route('/rcs-via-mms')
|
||||||
|
@login_required
|
||||||
|
def rcs_via_mms():
|
||||||
|
thread_id = request.args.get('thread_id')
|
||||||
|
tid = int(thread_id) if thread_id else None
|
||||||
|
limit = int(request.args.get('limit', 200))
|
||||||
|
msgs = _get_rcs().read_rcs_via_mms(tid, limit)
|
||||||
|
rcs_count = sum(1 for m in msgs if m.get('is_rcs'))
|
||||||
|
return jsonify({'ok': True, 'messages': msgs, 'count': len(msgs), 'rcs_count': rcs_count})
|
||||||
|
|
||||||
|
|
||||||
|
@rcs_tools_bp.route('/rcs-only')
|
||||||
|
@login_required
|
||||||
|
def rcs_only():
|
||||||
|
limit = int(request.args.get('limit', 200))
|
||||||
|
msgs = _get_rcs().read_rcs_only(limit)
|
||||||
|
return jsonify({'ok': True, 'messages': msgs, 'count': len(msgs)})
|
||||||
|
|
||||||
|
|
||||||
|
@rcs_tools_bp.route('/rcs-threads')
|
||||||
|
@login_required
|
||||||
|
def rcs_threads():
|
||||||
|
threads = _get_rcs().read_rcs_threads()
|
||||||
|
return jsonify({'ok': True, 'threads': threads, 'count': len(threads)})
|
||||||
|
|
||||||
|
|
||||||
|
@rcs_tools_bp.route('/backup-rcs-xml', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def backup_rcs_xml():
|
||||||
|
return jsonify(_get_rcs().backup_rcs_to_xml())
|
||||||
|
|
||||||
|
|
||||||
@rcs_tools_bp.route('/drafts')
|
@rcs_tools_bp.route('/drafts')
|
||||||
@login_required
|
@login_required
|
||||||
def drafts():
|
def drafts():
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user