Chazeon Talk

将 Windows Phone 短信迁移到 Android

Windows Phone 不是一个坏系统。本来就来的晚,还有很坏很坏的更新支持(大部分内建 Windows Phone 8 的手机无法升级到 Windows Phone 10)等一系列坏体验,还有微软慵懒的开发周期,实在是让人心寒。而无法安装购买便宜货的 APP 成为压倒骆驼的最后一根稻草,使得母上大人终于从 Lumia 630 出走,在我推荐换到了 Android 系统的 OnePlus X。

相比其他的消息软件而言,短信是最易于处理的,也是最难处理的。当然这是针对微信这些有大公司背景的软件而言的。微信这些软件虽然提供了软件内备份功能,但却由于外部不可定制,损失了诸如定期备份、以及自己定制内容的自由,以及在各个平台上都能阅读的可能性。这是个自由度或者说是可能性的问题,虽然一定程度上为我们提供了方便。

而短信,作为一种通用型功能的存在,只要保证可交换格式文件的存在,总是可以读的。而本文中的 VMSG 与 XML 就是这样的两种格式。而开发者也会围绕着这些开放的文件格式搭出各种各样的工具,实现各种各样奇奇怪怪的功能,即使没有人做,自己也可以做。这也算是使用这些开放格式的幸福感的源头。

本文尝试从 VMSG 与 XML 两种文件格式来一窥短信存储的形式,并在其中搭设桥梁,让文件的交换从可能变为现实。


Windows Phone 短信的导出

Windows 的设置迁移应用

Windows Phone 内建了「传输我的数据」应用,可以帮助我们完成在 Windows Phone 上面的大多数工作。

「传输我的数据」主界面

无可救药的蓝牙传输

在该应用中提供了使用蓝牙传输通讯录、短信、彩信、照片的选项,但令人诧异的是,在「导出到 SD 卡」那里还能找到 2800+ 短信,在这里每一次出现的短信数不稳定,某次只出现了 3 条信息,有时是 600 多条,这实在是太让人无语了,基本是处于一个不可用的状态。不过在正常配对的 Android 手机上已经正确申请了通讯录、消息权限。如果这个功能没有出现这样的错误的话,该功能恐怕还是挺方便的。

Windows Phone 数据传输蓝牙页面每次检测出来的短信数量不稳定,应该有 2800+ 条短信的手机有时候出来 600+ 条,有时候是 3 条。

存储到 TF 卡中

好吧既然蓝牙传输用不了,我们就用传统的办法,使用文件这一最最通用的传输模式。点右下角的三个点,选择「导出到 SD 卡」,然后选上「短信」一项,就可以正常备份到 TF 卡中了。

点击右下角「更多」弹出菜单选择「导出到 SD 卡」
载入完毕后,选择「短信」一项

备份与导出过程工作正常的话,很容易在 TF 卡根目录下类似 /backup/20160213090000/Sms/sms.vmsg 的位置找到本次备份的短信备份文件。

网上的说法

据说有人做过这事情,写了个程序读数据文件,然后输出通过豌豆荚导入回手机。嗯我不太想看论坛上那些个教程,尤其不想碰360手机助手、豌豆荚手机助手这些东西,界面丑,而且太不优雅。而且要删掉重复的文件不方便。

网上也有说通过联想的 SMS Backup & Restore 应用可以直接在 Android 上读这些个 VMSG 文件,不过这个应用是 Android 2.3 时代的东西,现在也找不到了,于是干脆捋起袖子自己读 VMSG 写程序转换成 XML 算了。

VMSG 文件格式

这么个 .vmsg 格式的文件网上也没有找到什么官方的文档,貌似也不是什么通用的格式,下面只好自己摸索了。

观察 sms.vmsg 发现用任何文本编辑器都可以打开它。然后很明显每一行是一个项目,而 BEGIN:END: 的行实际上标识着 VMSGVCARDVBODY 的开始和结束。文件中有多个 VMSG,每个 VMSG 代表一条消息的记录。而在每一行之内,由 : 分隔开属性的名称和值。

一个典型的消息记录是这样的

BEGIN:VMSG
VERSION: 1.1
BEGIN:VCARD
TEL:10086
END:VCARD
BEGIN:VBODY
X-BOX:INBOX
X-READ:READ
X-SIMID:1
X-LOCKED:UNLOCKED
X-TYPE:SMS
Date:2016/02/13 09:00:00
Subject;ENCODING=QUOTED-PRINTABLE;CHARSET=UTF-8:=E4=BD=A0=E5=A5=BD=EF=BC=8C=E4=B8=96=E7=95=8C
END:VBODY
END:VMSG

这么一坨代码给人观感很不好,于是我大概画了个示意图,展示这里面实际上有用的信息。

VMSG 数据结构:由 VCARD 和 VBODY 两个数据块组成,每一块包含几项属性
每一条消息是一个 VMSG,下面对 VMSG 分析如下:

MIME quoted-printable 数据表示

MIME quoted-printable 没有官方的中文译名,大致上说就是用 = 分隔的来用字符表示二进制数据的方案(编码方案)。我们更熟悉的一种方案是用 % 分隔的方案,用于编码网址,称百分号编码。当然我们还会熟悉字符值引用,HTML 中的  这样的表示方案,以及 Python 后台使用的 \x02f\x03f 这样的表示方案。而 MIME quoted-printable 这种方案我们不熟悉主要是用于邮件传递过程中。

其实也没什么高级的,大概规则就是每一个字节用等号后面两个 16 进制字符表示,换行保证行末是 = 即可。剩下的就相当于是把百分号分割方案中的 %= 代替了而已,UTF-8 编码之下一个汉字仍然用 3 个字节表示,如「哦」在该表示法下为 =E5=93=A6,在 Python 终端下为 b'\xe5\x93\xa6',在网址上出现是 %E5%93%A6。网友提供了在线的编码器解码器可供测试使用。

导入到 Android 手机

既然已经拿到了备份文件,下一步要考虑的就是如何找到一个合理的方案把这些消息传入到 Android 手机上。这里我们选择了 SMS Backup Restore 作为 Android 这边的接头方案。

SMS Backup Restore

这个 SMS Backup Restore 看是名不经传,不过最近才发现好像是大型商业在线备份解决方案公司为后台的。我说为什么这软件一直这么更新一直这么稳定。大公司就这么写个软件有个免费版赚点小钱的收费版了(相比买服务一年至少 60 刀的价格,免费版和收费版低廉的价格真是良心的不得了)。

因为使用了开放易读的 XML 作为文件交换格式,在我心目中这个 SMS Backup Restore 是 Android 手机上短信备份的事实标准。

SMS Backup Restore 最重要的功能就是定期备份。定期备份保证在正常情况下不需要干预,而出了问题总能恢复到小于备份周期的时间内的状态。这其实很接近那种正触发系统1

SMS Backup Restore 的另一个重要功能就是网盘自动上传,当然这个功能的支持需要单独安装网络支持插件。定期备份和备份完毕自动上传两个功能基本能保证短信安全无虞。

另外同一公司之前还有一个备份通话记录的应用,不过现在该功能已经集成到 SMS Backup Restore 里。

SMS Backup Restore 事实上官方网站的常见问题列表中提供了一系列操作系统间的消息转移教程的链接,许多用户提供了从包括 Windows Mobile、Symbian、iPhone 等操作系统的转移方案,足见这款软件在跨操作系统消息转移的方便程度,以及使用开放文件格式的优越性。

Nexus Root ToolkitBacon Root Toolkit 这些网友开发的极为方便的手机工具包也主动使用了 SMS Backup Restore 作为手机端工作的软件产生备份文件,足见其受认可程度程度。

SMS Backup Restore 主界面截图

XML 格式备份文件

根节点叫 <smses> 每一个子节点是一个 <sms>

根节点 <smses> 只有一个属性 count 记录消息数目。

具体的消息的属性都写在这个节点的属性里。自动输出的属性的一般形式如下

<smses count="4">
    <sms
        protocol="0"
        address="10086" contact_name="(Unknown)"
        date="1455070561121" date_sent="1455070425000"
        readable_date="2016年2月10日 10:16:01"
        type="1"
        subject="null"
        body="你好!世界"
        toa="null" sc_toa="null"
        service_center="+8613800100500"
        read="1"
        status="-1"
        locked="0" />
        ...
</smses>

大概介绍如下

因此不少属性是与 Windows Phone 中直接对应的。

深入挖掘:SmsMessage

我们参考 Android Developer 的文档,发现类似内容其实来源于 Android 的 android.telephony.SmsMessage 类。

深入挖掘:mmssms.db 数据库

出于对 toa、sc_toa 两个字段的好奇,我以此关键字进行搜索,结果少得可怜——不过在 Google Books 里有一本 Android Forensics2里的示例中恰好以 mmssms.db 做示范,结果发现之前提到的这些 XML 中 sms 元素的各个的属性名称与这个数据库里面的字段吻合得非常好,于是我对于这个信息存储文件进行深入挖掘。

这个数据库文件位于 /data/data/com.android.providers.telephony/databases/mmssms.db,是个 SQLite3 数据库,存储着彩信、短信的等一切相关的内容。使用 sqlite3 打开,找到了我们敢兴趣的 sms 表,用 .schema sms 命令列出表的格式,结果如下:

CREATE TABLE sms (
    _id INTEGER PRIMARY KEY,
    thread_id INTEGER,
    address TEXT,
    person INTEGER,
    date INTEGER,
    date_sent INTEGER DEFAULT 0,
    protocol INTEGER,
    read INTEGER DEFAULT 0,
    status INTEGER DEFAULT -1,
    type INTEGER,
    reply_path_present INTEGER,
    subject TEXT,
    body TEXT,
    service_center TEXT,
    locked INTEGER DEFAULT 0,
    sub_id INTEGER DEFAULT -1,

    phone_id INTEGER DEFAULT -1,

    error_code INTEGER DEFAULT 0,
    creator TEXT,
    seen INTEGER DEFAULT 0,
    priority INTEGER DEFAULT -1    );

发现与书中所描述的行为并不一致。这也就是说,不同版本的 Android4 间存储信息的内容已经有所演进,还可能增加了不少新的字段,而字段如 toasc_toa 就被省略了。难怪结果中统一都是 null,比如 date_sent 就已经是新添加的了。

深入挖掘:TextBasedSmsColumns 接口

这一部分数据库实际上对应于 android.provider.Telephony.TextBasedSmsColumns 的内容,具体的内容 Android Developers 的 Developer Reference 有详细的讲解。这里主要列出了 typestatus 的表述。

消息类型 type 的值

表示 含义
0 MESSAGE_TYPE_ALL 全部类型
1 MESSAGE_TYPE_INBOX 收件箱消息
2 MESSAGE_TYPE_SENT 已发消息
3 MESSAGE_TYPE_DRAFT 草稿
4 MESSAGE_TYPE_OUTBOX 发件箱
5 MESSAGE_TYPE_FAILED 收件箱消息
6 MESSAGE_TYPE_QUEUED 队列消息,稍后发送

消息状态 status 的值

表示 含义
0 STATUS_COMPLETE 完成
64 STATUS_FAILED 失败
-1 STATUS_NONE
32 STATUS_PENDING 等待

深入挖掘:MmsSms

android.provider.Telephony.MmsSms文档中记录了不同的消息协议代码

表示 含义
0 SMS_PROTO SMS 类型
1 MMS_PROTO MMS 类型

深入挖掘:源代码中 TelephonyProvider 仓库

在 Google 的 Android 源代码仓库android/platform/packages/providers/TelephonyProvider 部分可以找到深入的,关于这些模块是如何互相定义、调用的资料等。这一部分的文档描述并不是十分完整。

设计与实现细节

所用材料

本着哪个好用用哪个的原则,我选择用最适合干脏活的 Python 来实现本次的目的,事实证明,它的确胜任了本次工作,完全用它提供的标准库就够了:

设计细节

正则表达式非贪婪匹配

本来看到这么一个挺好的文件格式,觉得如果不是直接按行处理实在是没天理啊。按照以前做一个玩具 parser 的经验,好像用一个主循环加上几个递归函数会是非常合理的样子。

但是写来写去觉得神烦,最后还是去用正则表达式了。这里用到了 .*? 的非贪婪(懒惰)匹配。

另外开启 re.S 使得 . 可以匹配包括换行符内的任意字符。

双卡情况

在 Windows Phone 里两个 SIM 卡是完全隔离的,在主屏幕上也显示了两个应用。而 Android 手机到目前为止的绝大多数实现是两个 SIM 卡的区别只有在发送的时候才体现出来,会话都是在一起的。

这里我想到了唯一的区别就是有 service_center 字段的差异。中国移动对应的是 +8613800100595,而北京联通是 +8613010112500。VMSG 文件中记录了 simid12 由于知道这两张卡分别是哪一家的,这里就手动做了一个判断。

时间和日期

两个系统普遍都在使用 POSIX 的时间表示法。不过 VMSG 里使用的是一个精确到秒的可读记录法(如 2016/02/13 09:00:00),而 XML 中记录的普遍是用一个 13 位数(如 1455325200000)。这是由于 Android 里面普遍记录的是毫秒(从函数名 getTimestampMillis() 就可以看出来)。

Python 中可以直接使用 datetime.datetime.strptime() 从时间的自然表示法转换成 datetime 数据类型,而 datetimetimestamp() 方法可以直接获取 POSIX 表示的时间,不过单位是秒,也就是一个 10 位数(如 1455325200)。因此我直接暴力地在后面拼上 000 作为毫秒。

另外由于 Windows Phone 中不区别发送时间和接收时间(datedate_sent),于是在恢复过程中我直接将这两个设为同一个数。

将时间日期转换回自然可读模式的过程中,原本使用 datetimestrftime() 方法就足够了。然而这个函数偏偏不能接受 Unicode 字符,也就是无法写出原本的 2016年2月13日 09:00:00 的形式。所幸这个字段其实也不会被记录在数据库内,于是就干脆写了个英文的版本的上去,不折腾了。

去重

之前某一次恢复出过问题,导致部分消息出现了两次,由于短信消息条数过多(1500+),手动删除是一个不可接受的办法,于是趁着一次写程序可以顺便把重复的去掉。

原本打算直接用 Python 内建的 set 容器,不过 set 要求其元素是 hashable 的类型,而本次我们自己实现了数据类型,所以只能自己写一个去重的函数。

这一部分对自己写的类重写了等号 __eq__ 和不等号 __ne__ 方便后面的比较。

不过后来我发现实际上 SMS Backup Restore 里面已经有了不恢复重复消息的选项,可能根本不用我自己去写了。

实现

源代码如下,部分东西可以实现得更优雅,这里就不再去改了。

import re
import quopri
import xml.etree.ElementTree as ET
import datetime
import time

def findPropertyList(propertyName, string):
    return re.findall("%s:(.*?)\n|$" % (propertyName), string, re.S)

def findProperty(propertyName, string):
    return findPropertyList(propertyName, string)[0]

def findBlockList(blockName, string):
    return re.findall("BEGIN:%s\n(.*?)\nEND:%s" % (blockName, blockName), string, re.S)

def findBlock(blockName, string):
    return findBlockList(blockName, string)[0]

class VCard:
    def __init__(self, string):
        #self.tel = findProperty("TEL", string) 
        self.tel = re.findall("%s:(.*?)$" % ('TEL'), string, re.S)[0]

    def __eq__(self, other):
        return (isinstance(other, self.__class__)
            and self.__dict__ == other.__dict__)

    def __ne__(self, other):
        return not self.__eq__(other)

class VBody:
    def __init__(self, string):
        self.x_box    = findProperty("X-BOX",        string)
        self.x_read   = findProperty("X-READ",        string)
        self.x_simid  = findProperty("X-SIMID",        string)
        self.x_locked = findProperty("X-LOCKED",    string)
        self.x_type   = findProperty("X-TYPE",        string)
        self.date     = findProperty("Date",         string)

        # 2016/02/07 23:31:35
        self.datetime = datetime.datetime.strptime(self.date, "%Y/%m/%d %H:%M:%S")

        raw_data = re.findall("%s:(.*?)$" % ("Subject;ENCODING=QUOTED-PRINTABLE;CHARSET=UTF-8"), string, re.S)[0]
        self.data = quopri.decodestring(raw_data)
        self.decoded_data = self.data.decode("utf-8")

    def __eq__(self, other):
        return (isinstance(other, self.__class__)
            and self.__dict__ == other.__dict__)

    def __ne__(self, other):
        return not self.__eq__(other)

class VMsg():
    def __init__(self, string):
        self.version = findProperty("VERSION",     string)
        self.vcard   = VCard(findBlock("VCARD",    string))
        self.vbody   = VBody(findBlock("VBODY",    string))

    def __eq__(self, other):
        return (
            self.version == other.version and 
            self.vcard == other.vcard and
            self.vbody == other.vbody
        )

    def __ne__(self, other):
        return not self.__eq__(other)

    def toXml(self):
        thisSms = ET.Element("sms")
        thisSms.set("address", self.vcard.tel)
        thisSms.set("body", self.vbody.decoded_data)
        thisSms.set("locked", str(int(self.vbody == 'LOCKED')))
        thisSms.set("read", str(int(self.vbody.x_read == "READ")))
        thisSms.set("protocol", "0") 
        thisSms.set("toa", "null")
        thisSms.set("subject", "null")
        thisSms.set("sc_toa", "null")
        thisSms.set("contact_name", "(Unknown)")

        if self.vbody.x_box == "SENDBOX":
            thisSms.set("type", '2')
        elif self.vbody.x_box == "INBOX":
            thisSms.set("type", '1')

        if self.vbody.x_simid == "1": # ChinaMobile
            thisSms.set("service_center", "+8613800100500")
        elif self.vbody.x_simid == "2": # ChinaUnicom
            thisSms.set("service_center", "+8613010112500")

        thisSms.set("date_sent", str(int(self.vbody.datetime.timestamp())) + "000")
        thisSms.set("date", str(int(self.vbody.datetime.timestamp())) + "000")
        #dateformat = "%Y年%m月%d日 %H时%M分%S秒"
        thisSms.set("readable_date", self.vbody.datetime.strftime("%Y/%m/%d %H:%M:%S")) #!
        thisSms.set("status", "-1")

        return thisSms

def processSmsData(smsRawData):
    vmsgList = []
    for vmsg in re.findall("BEGIN:VMSG\n(.*?)\nEND:VMSG", smsRawData, re.S):
        vmsgList.append(VMsg(vmsg))
    return vmsgList

fileIn = open("../data/sms.vmsg", "r", encoding = "utf-8")
fileInData = fileIn.read()

vmsgList = processSmsData(fileInData)

# find Dumplicate

def removeDump(vec):
    leftIndex = 0
    rightIndex = 1
    while leftIndex < len(vec) - 1:
        while rightIndex < len(vec):
            if vec[leftIndex] == vec[rightIndex]:
                vec.pop(rightIndex)
            else:
                rightIndex += 1
        leftIndex += 1
        rightIndex = leftIndex + 1

print(len(vmsgList))
removeDump(vmsgList)
print(len(vmsgList))

# to XML

smsTree = ET.parse("../data/base.xml")
root = smsTree.getroot()

for vmsg in vmsgList:
    root.append(vmsg.toXml())

print(int(root.get("count")), str(int(root.get("count")) + len(vmsgList)))
root.set("count", str(int(root.get("count")) + len(vmsgList)))

smsTree.write("../data/output.xml", "utf-8")

完成

把产生的文件拷贝到手机上并用 SMS Backup Restore 恢复即可。

不过氢OS(H2OS)上自带的短信应用好像有非常诡异的问题,就是它的对话时间不是自动更新为最新消息的时间,结果全显示成恢复时间了。其他的短信应用倒是没有这个问题。

总结、番外与吐槽

这么一次伟大的探险终于成功,接近了尾声。这么一篇文章没想到居然写了这么老长,写了这么长时间。然而还没完,总觉得还有更多的延伸可以做,最后再写一点。唉,简直是开始写实验报告什么的就停不下来的那种感觉。我倒真不是不愿意写实验报告了,就可惜没人给我付工资啊。

其他的备份软件与格式

除了看到 XML 格式的备份文件,作为另一数据交换格式巨头的 JSON 自然也会不甘示弱。在整个尝试过程中,我偶然检查了联想的乐同步Google Play | 酷市场)的数据文件,它就是以 JSON 格式存储的,字段简明易于理解,因此也有尝试的可能,在此不再做引用和讨论。不过它的恢复是以时间为依据,处理的时候可能需要进一步考虑。

事实上,只要不是使用什么奇葩的压缩的、二进制的、压缩的存储格式,或者是有什么索引与内容,我们都可以很方便从中下手。

元吐槽

元 (meta-) 吐槽是本文自创的说法,吐槽的是撰写本文过程当中的血与泪。元吐槽当中的「元」字类似于「模版元编程 (metaprogramming)」的「元」字。

元吐槽之一:Microsoft Visio

本次 Visio 居然只能支持 TTF 格式的字体而不支持 OTF 格式的字体。为什么同样是 Office 系列的软件就有着不同的待遇,真是令人费解。

元吐槽之二:<code> 标签

WordPress 写行内包含 <code> 标签的东西真是痛苦极了,可视化界面不支持,还要切换到源代码模式,而源代码模式也坑的很,按按钮那个办法实在是糟透了,还不如自己打进去。受不了了,最后还是转到 Markdown 来写。 不过 WordPress 的源码模式是省略了 <p> 标签的,转换来转换去这里还会出问题。


  1. 参见 刘心慈《三体 2:黑暗森林》之《中部 咒语 第 12 节》对摇篮系统的描述。 

  2. Andrew Hoog,Android Forensics: Investigation, Analysis, and Mobile Security for Google Android:298 页,第 7 章 

  3. SQLite3 可到此处下载相关工具,命令行中用 sqlite3 mmssms.db 打开数据库,这里简单提一用法:

    • .help 显示帮助
    • .tables 列出所有数据表
    • .fullschema 列出所有数据表的表头
    • .schema sms 列出名为 sms 的表的表头
    • select * from sms limit 1; 列出第一条记录
    • .mode line 改变数据的显示模式

     

  4. 本人在使用 Android 6.0.1