Photoshop投屏到Android APP方案解析

Adobe为设计界提供了一大堆强有力的生产力工具,无比贴心,但对于开发者却不是那么友好,没有一份有条理的开发者文档和示例,就像一个穿着裤衩播新闻的主持人。话虽如此,Photoshop还是有一份相当简陋的SDK和文档在这里
PS Play是腾讯ISUX发布的一款Photoshop协作工具,支持在移动设备上对Photoshop内的文稿进行预览,今天我们将尝试剖析它的实现原理。

设备发现

如前面文章所述,PS Play一定也是用了mDNS这种成本很小的方案来进行设备发现,但这里的问题是我们并没有在文档的任何地方发现mDNS协议所依赖的服务类型字段。既然文档里找不到,那么当然要请出二营长的意大利炮Wireshark来分析一下这里面有什么猫腻。
启动Photoshop,打开远程连接选项,然后启动Wireshark,选中活动的网卡,设置过滤条件为ipaddr == 30.10.216.89 && mdns,过滤源IP为我的电脑且协议为MDNS的报文,应用过滤条件之后果然发现一大波报文被捕获到了。由于Mac电脑中的Bonjour服务也会使用MDNS进行设备发现,存在许多噪音,所以接下来我们就要对这个大波报文进行拷打,啊不是,进行查找,设置匹配字符串为photoshop,果然发现了Info匹配的一条报文,其中的moxun._photoshopserver._tcp.local就是我们中出的叛徒要寻找的服务类型字段了。

wireshark

获取到服务类型字段之后,使用Android系统提供的NsdManager类,注册类型为_photoshopserver.tcp.的监听器,就可以发现并获取到Photoshop对应的NsdServiceInfo,进一步进行解析后便可得到Photoshop服务对应的IP和端口。

建立连接

tech_stack
上图是Photoshop远程数据传递的层级图,我们的原始数据需要通过一个加密层加密之后,再通过Socket传递给Photoshop,同样的,Photoshop回传的数据也是经过加密的,需要通过加密层解密之后,还原出原始的数据。
再上一个步骤中,我们已经获取到了Photoshop服务的IP和端口,建立一个对应的Socket连接即可完成数据通路连接的过程。

加解密

每一次与PS的交互都需要对报文进行加解密。使用PBKDF2算法,传入用户输入的密码和固定的saltAdobe Photoshop,迭代1000次后导出一个长度为24byte的密钥,使用该密钥对原始数据进行3DES加解密。

与Photoshop交互

获取当前文稿的ID

在对文稿进行操作前,需要先获取到该文稿的ID值,构建一段JavaScript

var docID = '0';
if (app.documents.length) {
docID = activeDocument.id;
}
'documentID' + String.fromCharCode(13) + docID

加密后写入Socket,PS收到消息后便会回复对应的ID。

订阅事件

订阅事件的目的是告诉PS在某些事件发生后主动通知APP,例如文稿切换,新建和关闭等,此处我们需要订阅以下几个事件:closedDocumentnewDocumentViewCreatedcurrentDocumentChangedactiveViewChangeddocumentChanged
同样地,构建一个JavaScript字符串

var idNS = stringIDToTypeID('networkEventSubscribe');
var desc1 = new ActionDescriptor();
desc1.putClass(stringIDToTypeID('eventIDAttr'), stringIDToTypeID('${EVENT_ID}'));
executeAction(idNS, desc1, DialogModes.NO);
'subscribeSuccess' + String.fromCharCode(13) + 'YES';

加密后写入Socket,完成事件订阅。

接收消息

每一次与PS的交互都以成对的消息来表示,PS的消息格式如下:
msg
例如,在发送了获取docID的请求后,PS会回复一个ContentType为2,Content为documentID\r123的消息,对该回复使用\r进行分割,即可获取相应的Command和Extra信息。

获取图片

获取到docID之后,即可向PS请求对应的图片,此处需要传递给PS几个参数:docID、wantPixmap、width、height。同样地,构造JavaScript

if (documents.length) {
var idNS = stringIDToTypeID('sendDocumentThumbnailToNetworkClient');
var desc1 = new ActionDescriptor();
desc1.putInteger(stringIDToTypeID('documentID'), docId);
desc1.putInteger(stringIDToTypeID('width'), width);
desc1.putInteger(stringIDToTypeID('height'), height);
desc1.putInteger(stringIDToTypeID('format'), wantPixmap ? "2" : "1");
executeAction(idNS, desc1, DialogModes.NO);
'Image Request Sent';
}

如果传入wantPixmap为true,PS会按照宽高返回位图,否则返回JPEG格式的图片。将请求加密后写入Socket,等待结果返回。

解析图片

protocol
上图为PS的图片报文格式,当ContentType字段为3是,我们认为其是一个图片报文。读取Content区域的第一个字节,如果为1,则按照JPEG的方式解析,否则按照位图解析。

JPEG

JPEG可以使用BitmapFactory直接解析

public static Bitmap createBitmapFromJPEG(byte[] inBytes, int inIndexer) {
Bitmap mBitmap = BitmapFactory.decodeByteArray(inBytes, inIndexer, inBytes.length - inIndexer);
return mBitmap;
}
Pixmap

Pixmap需要逐字节进行解析,在Pixmap中,前12字节分别为图片的宽度、高度和字节数信息,各占4字节;Pixmap中的颜色标准为RGBA8888。

    public static Bitmap createBitmapFromPixmap(byte[] inBytes, int inIndexer) {
int width =    (inBytes[inIndexer++] << 24) + ((inBytes[inIndexer++] & 0xFF) << 16) + ((inBytes[inIndexer++] & 0xFF) << 8) + (inBytes[inIndexer++] & 0xFF);
int height =   (inBytes[inIndexer++] << 24) + ((inBytes[inIndexer++] & 0xFF) << 16) + ((inBytes[inIndexer++] & 0xFF) << 8) + (inBytes[inIndexer++] & 0xFF);
int rowBytes = (inBytes[inIndexer++] << 24) + ((inBytes[inIndexer++] & 0xFF) << 16) + ((inBytes[inIndexer++] & 0xFF) << 8) + (inBytes[inIndexer++] & 0xFF);
byte colorMode = inBytes[inIndexer++];
Bitmap mBitmap;
if (1 != colorMode) { 
Log.e(tag, "Bad color mode");
}
byte channelCount = inBytes[inIndexer++];
if (1 != channelCount && 3 != channelCount) {
Log.e(tag, "Bad channel count");
}
byte bitsPerChannel = inBytes[inIndexer++];
if (8 != bitsPerChannel) {
Log.e(tag, "Bad bits per pixel");
}
int extra = rowBytes - width * channelCount;
mBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
for (int y = 0; y < height; y++, inIndexer += extra) {
for (int x = 0; x < width; x++) {
int color = 0;
// 1 or 3 for now
if (channelCount == 1) {
color = pack8888(inBytes[inIndexer], inBytes[inIndexer], inBytes[inIndexer], 255);
inIndexer++;
} else {
color = pack8888(unsignedByteToInt(inBytes[inIndexer + 2]), unsignedByteToInt(inBytes[inIndexer + 1]), unsignedByteToInt(inBytes[inIndexer]), 255);
inIndexer += 3;
}
mBitmap.setPixel(x, y, color);
}
}
return mBitmap;
}
private static int pack8888(int r, int g, int b, int a) {
return (r << 0) | ( g << 8) | (b << 16) | (a << 24);
}
private static int unsignedByteToInt(byte b) {
return (int) b & 0xFF;
}   

发送图片

获取一张JPEG格式的图片,转换成字节流后,构造如前文图片中所属的图片报文并进行加密,拿到Socket的输出流,写入非加密部分和加密部分即可发送图片,PS会新建一个文稿并显示发送的图片。

int messageLength = COMM_LENGTH + encryptedBytes.length;
int NO_COMM_ERROR = 0;
mOutputStream.writeInt(messageLength);
mOutputStream.writeInt(NO_COMM_ERROR);
mOutputStream.write(encryptedBytes, 0, encryptedBytes.length);

通过以上步骤,即可实现PS图片实时显示在手机屏幕和手机本地图片上传到PS的功能,PS还提供了丰富的JS接口来实现其他控制选项,详细内容可查看官方文档。