Android DLNA投屏-基于CyberGarage开发投屏功能

在上一篇博客《Android DLNA投屏-基本原理》中,讲到了DLNA的一些基本原理。了解这些基本原理,对开发是很有帮助的。但仅仅依据原理去进行Android DLNA开发,是比较困难的。我们需要使用一些优秀的开源框架,这样能很大程度上提高开发效率,使得开发变得更简单。Android有如下几个用于DLNA开发的主流框架:

1. Cling. Cling是一个Java开源项目,开发者可直接编译源码生成jar包导入到Android项目中。目前Cling已停止维护,但这并不影响它的热度。
2. Platinum. Platinum是一个C库,它支持编译成多个平台的库,如Windows、Mac、IOS和Android等。但其编译流程相对来说比较复杂,Android使用Platinum开发需要用到jni。
3. CyberGarage. CyberGarage是一个Java Upnp开发包,开发者将其项目源码添加到Android工程当中,作为Android Library或者 Java Library直接使用。CyberGarage提供了jar包下载地址,但CyberGarage源码存在一些bug,需要对源码进行修改,因此不建议直接下载jar包。

由于原理相同,这些框架的使用方式都十分类似。本篇博客将介绍如何使用CyberGarage,进行Android DLNA投屏开发。使用Platinum和Cling的朋友,请参照github项目的文档指引进行开发。

1. 准备

由于Upnp是基于xml格式通信的,因此需要先下载xml解析包以获取xml解析支持, CyberGarage支持以下几种xml解析包:

  1. jaxp (java自带,不用下载)
  2. XmlPullParser (Android自带,不用下载)
  3. xerces2
  4. kxml2

选择其中一种解析包添加到项目中,CyberGarage会在解析xml时使用该解析包,上述解析包在CyberGarage中的使用优先级是从 4 到 1。
添加完xml解析包后,再将CyberGarage项目添加到Android工程中,就可以开始进行开发了。

2. ControlPoint

在上一篇博客《Android DLNA投屏-基本原理》中已提到,Android设备在投屏过程中主要扮演着控制点的角色。在CyberGarage项目中,与控制点相对应的类为ControlPoint类。只要创建并使用该类的实例,就能实现控制点的功能。

(1)初始化

实现初始化,只需要调用start方法即可,注意该方法要在子线程中调用:

ControlPoint controlPoint = new ControlPoint();
// 初始化
new Thread(new Runnable() {
    public void run() {
        controlPoint.start(); 
    }
}).start();

(2)搜索设备

搜索设备的方法为search方法,但与start方法一样,需要在子线程中调用:

new Thread(new Runnable() {
    public void run() {
        controlPoint.start(); 
        controlPoint.search();
    }
}).start();

(3)设备通知监听

添加设备通知监听,只需实例化一个NotifyListener并实现其deviceNotifyReceived方法,然后与ControlPoint实例绑定:

controlPoint.addNotifyListener(new NotifyListener() {
    @Override
    public void deviceNotifyReceived(SSDPPacket packet) {
        Log.i(TAG, "Got Notification from device, remoteAddress is" + packet.remoteAddress);
    }
})

(4)搜索结果监听

添加设备通知监听,则需要实例化一个SearchResponseListener并实现其deviceSearchResponseReceived方法,然后与ControlPoint实例绑定:

controlPoint.addSearchResponseListener(new SearchResponseListener() {
    @Override
    public void deviceSearchResponseReceived(SSDPPacket packet) {
        Log.i(TAG, "A new device was searched, remoteAddress is" + packet.remoteAddress);
    }
});

(5)设备变化监听

如果需要在设备被移除/添加的时候,做一些操作,则需要实例化一个DeviceChangeListener并实现其deviceRemoved和deviceAdded方法,然后与ControlPoint实例绑定:

controlPoint.addDeviceChangeListener(new DeviceChangeListener() {
    @Override
    public void deviceRemoved(Device device) {
        Log.i(TAG, "Device was removed, device name: " + device.friendlyName});
    }

   @Override
    public void deviceAdded(Device device) {
        Log.i(TAG, "Device was added, device name:" +  device.friendlyName);
       
    }
})

(6)发送动作请求

要向设备发送动作请求,以实现对设备的控制,首先得获取已添加的设备(Device类)的实例。而支持投屏播放的设备的设备类型主要为DMR,deviceType的值为urn:schemas-upnp-org:device:MediaRenderer:x。因此,添加设备前要做一个对设备类型的判断:

controlPoint.addDeviceChangeListener(new DeviceChangeListener() {
    @Override
    public void deviceRemoved(Device device) {
        if ("urn:schemas-upnp-org:device:MediaRenderer:1".equals(device.getDeviceType())) {
            deviceList.remove(device);
        }
    }

   @Override
    public void deviceAdded(Device device) {
        // 判断是否为DMR
        if ("urn:schemas-upnp-org:device:MediaRenderer:1".equals(device.getDeviceType())) {
            deviceList.add(device);
        }
    }
})

这里用一个列表缓存已添加的设备,当要使用某个设备时,再从列表中获取对应实例。
获取设备实例后,需要从设备实例中根据serviceType获取Service类的实例,再从Service类实例中根据动作名获取Action类的实例,最后调用postControlAction方法发送请求。
DLNA投屏播放的服务的serviceType值为:urn:schemas-upnp-org:service:AVTransport:x;
实现播放需要发送两个动作请求:

  1. SetAVTransportURI。设置播放URI。需要转入两个参数: 1. InstanceID 实例ID, 2. CurrentURI 要设置的URI。
  2. Play。播放视频。需要传入一个参数: 1. InstanceID 实例ID.
    因此,整个投屏播放的动作请求代码如下:
// 实例ID
String instanceID = "0";
// 播放视频地址
String currentURI = "http://hc.yinyuetai.com/uploads/videos/common/026E01578953FD0EF0E47204247B5D13.flv?sc=2d17ae37a9186da6&br=780&vid=2693509&aid=623&area=US&vst=2";
Device device = deviceList.get(0);
// 获取服务
Service service = device.getService("urn:schemas-upnp-org:service:AVTransport:1");
// 获取动作
Action transportAction = service.getAction("SetAVTransportURI");
// 设置参数
transportAction.setArgumentValue("InstanceID", instanceID);
transportAction.setArgumentValue("CurrentURI", transportURI);
// SetAVTransportURI
if(transportAction.postControlAction()) {
    // 成功
    Action playAction = service.getAction("Play");
    playAction.setArgumentValue("InstanceID", instanceID);
    // Play
    if (!playAction.postControlAction()) {
        Log.e("upnpErr", playAction.getStatus().getDescription());
    } 
} else {
    // 失败
    Log.e("upnpErr", transportAction.getStatus().getDescription());
}

如果不清楚某个设备的服务动作,则可以查看其设备描述文档SDD,通过如下代码可以获取设备描述文档SDD的链接地址:

// 设备描述文档
String locationUrl = device.getLocation();
// 获取服务
Service service = device.getService("urn:schemas-upnp-org:service:AVTransport:1");
URL url = new URL(locationUrl);
// SDD
String sddUrl = locationUrl的ip地址和端口号 + service.getSCPDURL();

(7)事件订阅

如果设备在发生某些事件时,控制点需要跟着发生变化,如设备暂停播放,那么控制点的播放按钮理应变为暂停状态;则需要对设备进行事件订阅,订阅方法如下:

Device device = deviceList.get(0);
// 获取服务
Service service = device.getService("urn:schemas-upnp-org:service:AVTransport:1");
boolean ret = controlPoint.subscribe(service);
if (ret) {
    // 订阅成功
} else {
    // 订阅失败
}

要监听事件回调,则需要创建一个EventListener与ControlPoint实例绑定,当设备发生事件时,会执行EventListener中的eventNotifyReceived方法:

controlPoint.addEventListener(new EventListener() {
    @Override
    public void eventNotifyReceived(String uuid, long seq, String name, String value) {
        // 事件回调
        ...
    }
});

3. CyberGarage源码中的Bug

上文已提到,CyberGarage源码是存在Bug的,所以需要对源码进行一些修改,下面列出开发时遇到的一些 Bug:

(1)getAction方法返回一直为空

在获取到Service类实例后,发现调用Service类实例的getAction方法获取Action类实例时,返回的结果一直为空。

// 获取服务
Service service = device.getService("urn:schemas-upnp-org:service:AVTransport:1");
// 返回一直为null
Action action = service.getAction("SetAVTransportURI");

考虑到可能是设备服务中没有此动作,因此通过浏览器查看设备的sdd文档,发现文档中是有该SetAVTransportURI的动作描述,对此可以断定,设备是可以进行SetAVTransportURI的动作请求的。
为了找出问题,对getAction方法进行断点,并分析其源码执行情况,getAction的源码如下:

public Action getAction(String actionName)
{
    ActionList actionList = getActionList();
    int nActions = actionList.size();
    for (int n=0; n<nActions; n++) {
        Action action = actionList.getAction(n);
        String name = action.getName();
        if (name == null)
            continue;
        if (name.equals(actionName) == true)
                return action;
    }
    return null;
}

执行到getActionList方法时发现该方法直接返回一个空的列表。而根据文档描述,这里应该返回多个节点才对,因此我们看看getActionList这个方法的源码是否存在问题:

public ActionList getActionList()
{
    ActionList actionList = new ActionList();
    Node scdpNode = getSCPDNode();
    if (scdpNode == null)
        return actionList;
    ...
}

执行到getSCPNode这个方法时,该方法返回为空了,导致getActionList这个方法返回了一个空的列表。这是什么原因呢? 我们再继续看看getSCPNode方法的源码:

private Node getSCPDNode()
{
    ...
    try {
        URL scpdUrl = new URL(rootDev.getAbsoluteURL(scpdURLStr));
        System.out.println("SPCDURL: " + scpdURLStr);
        scpdNode = getSCPDNode(scpdUrl);
        if (scpdNode != null) {
            data.setSCPDNode(scpdNode);
            return scpdNode;
        }
    } catch (Exception e) {}
    ...
}

当执行到URL scpdUrl = new URL(rootDev.getAbsoluteURL(scpdURLStr))这句代码的时候,出现很奇怪的现象,在调试工具中查看rootDev.getAbsoluteURL(scpdURLStr)的返回时,发现它的值时这样的:

http://192.168.42.37:2869/upnphost/udhisapi.dll?content=uuid:79884bb3-3148-433f-b140-e790b6ec22ed/upnphost/udhisapi.dll?content=uuid:fe18f6aa-02fc-4e53-891c-48ef5d5b6957

终于找出原因了,这是因为rootDev.getAbsoluteURL(scpdURLStr)方法拼接SCDPURL出错了,导致无法获取并解析SDD文档中xml节点,从Android Profiler的记录中就可以看到SDD请求结果了:

Android Profiler记录.png

返回的内容为空,自然无法获取对应的动作。那个rootDev.getAbsoluteURL(scpdURLStr)这个方法究竟错在哪里呢?我们继续看源码:

public String getAbsoluteURL(String urlString) {
    String baseURLStr = null;
    String locationURLStr = null;

    Device rootDev = getRootDevice();
    if (rootDev != null) {
        baseURLStr = rootDev.getURLBase();
        locationURLStr = rootDev.getLocation();
    }

    return getAbsoluteURL(urlString, baseURLStr, locationURLStr);
}

这里依旧看不出什么问题,让我们看一下它的重载方法:

public String getAbsoluteURL(String urlString, String baseURLStr, String locationURLStr) {
    if ((urlString == null) || (urlString.length() <= 0)) return "";

    try {
        URL url = new URL(urlString); return url.toString();
    } catch (Exception e) {}

    if (baseURLStr == null || baseURLStr.length() <= 0) {
        if ((locationURLStr != null) && (0 < locationURLStr.length())) {
            if (!locationURLStr.endsWith("/") || !urlString.startsWith("/")) {
                String absUrl = locationURLStr + urlString;
                try {
                    URL url = new URL(absUrl);
                    return url.toString();
                } catch (Exception e) {}
            } 
        }
        ...
    }
    ...
}

程序执行到了if (!locationURLStr.endsWith("/") || !urlString.startsWith("/"))这个判断中,问题就出现在下面这句代码中:

String absUrl = locationURLStr + urlString;

这里直接拿locationURLStr和urlString拼接,这明显是不正确的,因为某些url可能在url后附带一些参数,如上例的locationURLStr是这样的:

http://192.168.42.37:2869/upnphost/udhisapi.dll?content=uuid:79884bb3-3148-433f-b140-e790b6ec22ed

于是跟urlString拼接起来就出问题了,要解决这个问题,便是通过URL类实例,获取字符串的协议、ip地址和端口号,再与urlString拼接,如下:

public String getAbsoluteURL(String urlString, String baseURLStr, String locationURLStr) {
    if ((urlString == null) || (urlString.length() <= 0)) return "";

    try {
        URL url = new URL(urlString); return url.toString();
    } catch (Exception e) {}

    if (baseURLStr == null || baseURLStr.length() <= 0) {
        if ((locationURLStr != null) && (0 < locationURLStr.length())) {
            if (!locationURLStr.endsWith("/") || !urlString.startsWith("/")) {
                try {
                    URL locationURL = new URL(locationURL);
                    // 重新拼接url
                    String absUrl = locationURL.getProtocol() + "://" + locationURL.getHost() + ":" + locationURL.getPort() + urlString;
                    URL url = new URL(absUrl);
                    return url.toString();
                } catch (Exception e) {}
            } 
        }
        ...
    }
    ...
}

修改以后,getAction方法就能正确获取对应的动作了。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,499评论 25 707
  • 用两张图告诉你,为什么你的 App 会卡顿? - Android - 掘金 Cover 有什么料? 从这篇文章中你...
    hw1212阅读 12,693评论 2 59
  • 前言 又来到了源码分析,说实话在写文章之前 我并没有很仔细的阅读过 Cling 的源码,所以说 我也只是个菜比。但...
    细卷子阅读 7,837评论 26 56
  • 导语 至于手机投屏的实现方法可谓五花八门,今天小袁就说下以开发人员的角度来说下当今手机的主流投屏方法。目前这种将终...
    快感的感知阅读 30,082评论 0 17
  • Spotify 是一个起源于瑞典的音乐流服务公司,也是目前全球最大的流音乐服务商。 当下个人首选且唯一在使用的流媒...
    小红帽的成长探索阅读 1,717评论 0 0