暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

ADB工具包ddmlib的剖析与修改

贝贝猫技术分享 2019-10-19
335


引言

因为最近开发的系统,需要从 Java 端控制 Android,所以使用到了ADB[1]的 Java 库 ddmlib,它的功能非常全,而且是 Google 官方维护的 ADB Java Lib。但是在实际使用的过程中,出现了并发使用时 ADB 掉线的情况,怀疑是通过 ADB 传输的数据带宽消耗过大导致的,所以对 ddmlib 进行了修改,使其可以设置每台手机的传输带宽限制。此外,为了远程调试线上系统的指定设备,我还在 ddmlib 加入了一个 ADB Proxy 的功能。

如何获取最新的官方 ddmlib 源码

在 Google官仓[2]中有很多个分支,其中很多已经很久没有维护了,我挨个看了一下,发现studio-master-dev[3]分支下的 ddmlib 是最新的,所以我就以该分支作为修改的起点,最终选用的 Git 节点是539b90ad[4]

ddmlib 的简单剖析

ddmlib 中包含两个主要功能: [1]Java 的 debug: 通过它可以连接到手机上的 JVM Debugger,我推测这一部分应该主要是 Android Studio 使用,因为我并没有用到这一部分,所以基本没有做改动,本文也不进行深入介绍。[2]传统 ADB 接口: 这些接口全部都是通过 Socket 连接 ADB Server,然后通过 ADB 协议进行通讯,协议的内容也不是很复杂,可以分为如下 3 大类:

  1. host:track-devices: 这种请求类似于执行adb devices
    , 它会实时返回设备的上下线情况。

  2. host:transport: 这种请求类似于执行adb -s <serialNumber> shell <command>
    , 是针对某一设备的 ADB 操作,在请求的描述中会包含序列号来指定想要操纵的设备,随后会传输要执行的指令等数据。

  3. host-serial: 这种请求类似于执行adb -s forward <local> <remote>
    ,是将手机上的 socket 映射到宿主机上。

其中第二个是整个 ddmlib 中使用最多的,几乎所有的ADB[5]功能都是通过该方式实现。接下来我会以一个简单的adb shell ls
来介绍一下完整的 Socket 通讯。

    假设要操作的手机序列号为 ABCDEF
    Socket 连通后>>>
    ddmlib: host:transport:ABCDEF (实际发送的数据之前还包含4个16进制ASCII符号来描述发送数据的长度,因为我们要发送的数据长度为21,对应的16进制数为0015,所以最终的发送数据为'0015host:transport:ABCDEF')
    ADB Server: OKAY or FAIL (返回OKAY代表成功,如果返回FAIL代表失败,之后会跟4个字符的十六进制ASCII符号来表示错误信息的长度,拿到错误信息长度后,我们需要再读取相应长度的数据作为执行错误的原因)
    假设请求成功>>>
    ddmlib: shell:ls (这部分也需要描述消息长度的前缀,构造方式同上)
    ADB Server: ls的返回数据会全部通过socket传输

    ddmlib 的入口

    在使用 ddmlib 时需要先通过 AndroidDebugBridge#initIfNeeded 初始化 ADB。其中 clientSupport 参数可以控制 ddmlib 的工作模式,这个工作模式的区别主要是关于是否开启 Java Debug 功能。初始化完成后我们需要通过 AndroidDebugBridge#createBridge 来创建 AndroidDebugBridge 实例,在后续的使用中,我们需要通过该实例来获取设备的抽象,并通过它来操作手机。值得一提的是,ddmlib 默认是连接本机的 ADB Server,但是也可以通过 DdmPreferences 中的相关参数进行控制,使其连接到其他主机。我的 ADB proxy 功能就是基于这部分参数来使用的。

    ddmlib 的使用

    通常来说我们需要先注册一个 DeviceChangeListener,通过它可以监听到设备的上线,下线事件,然后初始化 AndroidDebugBridge。

          AndroidDebugBridge.addDeviceChangeListener(listener);
      AndroidDebugBridge.initIfNeeded(false); clientSupport参数设为false,关闭Java Debug的功能
      AndroidDebugBridge.createBridge();

      在 DeviceChangeListener 的回调中,我们也可以获取到 ddmlib 对设备的抽象 IDevice,里面包含了全部 ADB 操作。IDevice 的实现基本上都是和 ADB Server 建立 Socket 连接,然后通过协议来执行命令。

        public interface IDeviceChangeListener {
        **
        * Sent when the a device is connected to the {@link AndroidDebugBridge}.
        * <p>
        * This is sent from a non UI thread.
        * @param device the new device.
        */
        void deviceConnected(@NonNull IDevice device);


        **
        * Sent when the a device is connected to the {@link AndroidDebugBridge}.
        * <p>
        * This is sent from a non UI thread.
        * @param device the new device.
        */
        void deviceDisconnected(@NonNull IDevice device);


        **
        * Sent when a device data changed, or when clients are started/terminated on the device.
        * <p>
        * This is sent from a non UI thread.
        * @param device the device that was updated.
        * @param changeMask the mask describing what changed. It can contain any of the following
        * values: {@link IDevice#CHANGE_BUILD_INFO}, {@link IDevice#CHANGE_STATE},
        * {@link IDevice#CHANGE_CLIENT_LIST}
        */
        void deviceChanged(@NonNull IDevice device, int changeMask);
        }

        对 ddmlib 的修改

        1. 传输带宽限制: 基于 Netty 的 GlobalTrafficShapingHandler 实现,此外由于使用到了 Netty,所以处理大内存的数据(手机截图)时可以达到零拷贝的效果。

        2. ADB Proxy: 开放一个 Socket Server,并且解析了 ADB 请求的头部信息,然后通过请求头部的内容进行了一些设备拦截的操作,这样可以达到只放行指定设备 ADB Proxy 的效果。

        对初始化过程的修改

        为了让 Netty 的配置更加灵活,我给 AndroidDebugBridge.initIfNeeded 加了一个新参数 AdbNettyConfig,通过它可以修改 NettyClient 相关的参数。其中,TrafficHandlerGetter 就是用来获取每台手机的带宽限制的,此外还包括全部设备的整体带宽限制。

          public class AdbNettyConfig {
          private String eventExecutorGroupPrefix = "AdbBoss";
          private int eventExecutorGroupThreadSize = 1;
          private String eventLoopGroupWorkerPrefix = "AdbWorker";
          private String proxyEventLoopGroupWorkerPrefix = "AdbProxyWorker";
          private int eventLoopGroupWorkerThreadSize = NettyRuntime.availableProcessors();
          private int connectTimeoutMills = 10000;
          private TrafficHandlerGetter trafficHandlerGetter = new DefaultTrafficHandlerGetter();
          }
          public interface TrafficHandlerGetter {
          **
          * Get device traffic handler
          *
          * @param serialNumber device serial number
          * @return device traffic handler
          */
          @Nullable
          GlobalTrafficShapingHandler getDeviceTrafficHandler(String serialNumber);
          **
          * Get global traffic handler
          *
          * @return global traffic handler
          */
          @Nullable GlobalTrafficShapingHandler getGlobalTrafficHandler();
          }

          对 ADB 连接过程的修改

          有了上述参数后,我在初始化过程中构建了一个 AdbConnector,后续建立与 ADB Server 之间的连接时,都是通过该 AdbConnector 进行。在连接过程中会在 NettyPipeLine 中注入之前设置好的 GlobalTrafficShapingHandler。

            public class AdbConnector extends ChannelDuplexHandler {
            private final AdbNettyConfig config;
            private final Bootstrap bootstrap;


            public AdbConnector(AdbNettyConfig config) {
            this.config = config;
            bootstrap = new Bootstrap()
            .group(new NioEventLoopGroup(config.getEventLoopGroupWorkerThreadSize(),
            new NamedThreadFactory(config.getEventLoopGroupWorkerPrefix(),
            config.getEventLoopGroupWorkerThreadSize())))
            .channel(NioSocketChannel.class)
            .handler(this)
            .option(NioChannelOption.CONNECT_TIMEOUT_MILLIS, config.getConnectTimeoutMills())
            .option(NioChannelOption.TCP_NODELAY, Boolean.TRUE)
            .option(NioChannelOption.SO_KEEPALIVE, Boolean.TRUE);
            }


            public AdbConnection connect(InetSocketAddress adbSockAddr, String serialnumber) throws IOException {
            ChannelFuture f = this.bootstrap.connect(adbSockAddr);
            try {
            f.await(config.getConnectTimeoutMills(), TimeUnit.MILLISECONDS);
            if (f.isCancelled()) {
            throw new IOException("connect cancelled, can not connect to component.", f.cause());
            } else if (!f.isSuccess()) {
            throw new IOException("connect failed, can not connect to component.", f.cause());
            } else {
            injectTrafficHandler(f.channel(), serialnumber);
            f.channel().pipeline().remove(this);
            return new AdbConnection(f.channel());
            }
            } catch (Exception e) {
            throw new IOException("can not connect to component.", e);
            }
            }


            private void injectTrafficHandler(Channel channel, String serialNumber) {
            GlobalTrafficShapingHandler globalTrafficHandler =
            config.getTrafficHandlerGetter().getGlobalTrafficHandler();
            if (!Objects.isNull(globalTrafficHandler)) {
            channel.pipeline().addLast(globalTrafficHandler);
            }
            if (!Objects.isNull(serialNumber)) {
            GlobalTrafficShapingHandler deviceTrafficHandler =
            config.getTrafficHandlerGetter().getDeviceTrafficHandler(serialNumber);
            if (!Objects.isNull(deviceTrafficHandler)) {
            channel.pipeline().addLast(deviceTrafficHandler);
            }
            }
            }
            }

            完成上述内容之后,剩下的工作就基本都是重复的,就是替换现有的直接 Socket 实现,使用 AdbConnector 来创建基于 Netty 的 AdbConnection,在 AdbConnection 中,我封装了一些常用的数据发送接口。

              public class AdbConnection implements Closeable {


              private Channel channel;
              private boolean alreadyProxy = false;


              AdbConnection(Channel channel) {
              this.channel = channel;
              }


              public synchronized void sendAndWaitSuccess(String message, long timeout,
              TimeUnit timeUnit, AdbInputHandler... nextHandlers) throws TimeoutException, AdbCommandRejectedException {
              AdbRespondHandler adbRespondHandler = new AdbRespondHandler();
              channel.pipeline().addLast(adbRespondHandler);
              if (nextHandlers != null && nextHandlers.length > 0) {
              channel.pipeline().addLast(nextHandlers);
              }
              send(message, timeout, timeUnit);
              adbRespondHandler.waitRespond(timeout, timeUnit);
              if (!adbRespondHandler.getOkay()) {
              throw new AdbCommandRejectedException(adbRespondHandler.getMessage());
              }
              }


              public boolean isActive() {
              return channel != null && channel.isActive();
              }


              public void buildProxyConnectionIfNecessary(ChannelHandlerContext ctx, String serialNumber) {
              if (!alreadyProxy) {
              channel.pipeline().addLast(new ProxyInputHandler(ctx, serialNumber));
              alreadyProxy = true;
              }
              }


              public void writeAndFlush(ByteBuf buf) {
              channel.writeAndFlush(buf);
              }


              public synchronized void syncSendAndHandle(byte[] bytes, AdbInputHandler handler, long timeout, TimeUnit timeUnit) {
              channel.pipeline().addLast(handler);
              syncSend(bytes, 0, bytes.length, timeout, timeUnit);
              }


              public synchronized void syncSend(byte[] bytes, int offset, int length, long timeout, TimeUnit timeUnit) {
              try {
              if (timeout > 0) {
              channel.writeAndFlush(Unpooled.wrappedBuffer(bytes, offset, length)).await(timeout, timeUnit);
              } else {
              channel.writeAndFlush(Unpooled.wrappedBuffer(bytes, offset, length)).await();
              }
              } catch (InterruptedException e) {
              throw new RuntimeException("Interrupted", e);
              }
              }


              public synchronized void syncSend(byte[] bytes, long timeout, TimeUnit timeUnit) {
              syncSend(bytes, 0, bytes.length, timeout, timeUnit);
              }


              public synchronized void send(String message, long timeout, TimeUnit timeUnit) throws TimeoutException,
              AdbCommandRejectedException {
              doSend(message, timeout, timeUnit, new AdbStringOutputHandler());
              }


              public synchronized void send(InputStream inputStream) throws TimeoutException,
              AdbCommandRejectedException {
              doSend(inputStream, 0, null, new AdbStreamOutputHandler());
              }


              private <T> void doSend(T message, long timeout, TimeUnit timeUnit,
              AdbOutputHandler<T> adbStringOutputHandler) throws TimeoutException, AdbCommandRejectedException {
              channel.pipeline().addLast(adbStringOutputHandler);
              try {
              ChannelFuture channelFuture = channel.writeAndFlush(message);
              if (timeout > 0) {
              if (!channelFuture.await(timeout, timeUnit)) {
              channelFuture.cancel(true);
              throw new TimeoutException("Send request timeout.");
              }
              } else {
              channelFuture.await();
              }
              if (!channelFuture.isSuccess()) {
              throw new AdbCommandRejectedException("Send data failed, " +
              (Objects.isNull(channelFuture.cause()) ? "" : channelFuture.cause().getMessage()));
              }
              } catch (InterruptedException e) {
              throw new RuntimeException("Interrupted.", e);
              } finally {
              channel.pipeline().remove(adbStringOutputHandler);
              }
              }


              @Override
              public void close() {
              channel.close();
              }
              }

              对请求处理过程的修改

              在使用 AdbConnection 时,基本是按照如下模板进行的,值得注意的是,我们需要针对不同的 ADB 请求,实现不同的 ResponseHandler,就像如下例子中的 AdbStreamInputHandler,它需要在请求发送出去之前注入 Netty 的 PipeLine 中,这样才能保证返回的数据不会漏。

                try (AdbConnection adbConnection = adbConnector.connect(adbSockAddr, device.getSerialNumber())) {
                if the device is not -1, then we first tell adb we're looking to
                talk to a specific device
                setDevice(adbConnection, device);
                AdbStreamInputHandler customRespondHandler = new AdbStreamInputHandler(rcvr);
                adbConnection.sendAndWaitSuccess(
                adbService.name().toLowerCase() + ":" + command,
                DdmPreferences.getTimeOut(),
                TimeUnit.MILLISECONDS, customRespondHandler);
                / stream the input file if present.
                if (!Objects.isNull(is)) {
                adbConnection.send(is);
                }
                customRespondHandler.waitResponseBegin(maxTimeToOutputResponse, maxTimeUnits);
                customRespondHandler.waitFinish(maxTimeout, maxTimeUnits);
                }


                static void setDevice(AdbConnection adbConnection, IDevice device)
                throws TimeoutException, AdbCommandRejectedException {
                // if the device is not -1, then we first tell adb we're looking to talk
                // to a specific device
                if (device != null) {
                adbConnection.sendAndWaitSuccess(
                "host:transport:" + device.getSerialNumber(),
                DdmPreferences.getTimeOut(),
                TimeUnit.MILLISECONDS);
                }
                }

                我这里根据不同的 ADB 请求实现了如下 Handlers:

                实现 ADB Proxy Server

                参考 ddmlib 的初始化参数设置方法,我将 ADB Proxy 的设置也放进了 DdmPreferences,如果打开了该功能,我会开一个 Socket Server 来接受 ADB 命令。

                  public class AdbDeviceProxy extends ChannelInitializer {


                  private volatile static AdbDeviceProxy INSTANCE;
                  private final EventLoopGroup eventLoopGroupBoss;
                  private final EventLoopGroup eventLoopGroupWorker;
                  private final ServerBootstrap bootstrap = new ServerBootstrap();


                  private AdbDeviceProxy(AdbNettyConfig adbNettyConfig) {
                  try {
                  eventLoopGroupWorker = new NioEventLoopGroup(adbNettyConfig.getEventLoopGroupWorkerThreadSize(),
                  new NamedThreadFactory(adbNettyConfig.getProxyEventLoopGroupWorkerPrefix(),
                  adbNettyConfig.getEventLoopGroupWorkerThreadSize()));
                  eventLoopGroupBoss = new NioEventLoopGroup(adbNettyConfig.getEventExecutorGroupThreadSize(),
                  new NamedThreadFactory(adbNettyConfig.getEventExecutorGroupPrefix(),
                  adbNettyConfig.getEventExecutorGroupThreadSize()));
                  } catch (Exception e) {
                  throw new RuntimeException("Adb proxy event loop groups instantiation failed", e);
                  }
                  init();
                  }


                  @Override
                  protected void initChannel(Channel ch) {
                  ch.pipeline().addFirst(new ConnectionProxyHandler());
                  }


                  private void init() {
                  log.info("Adb-Proxy: Initializing...");
                  bootstrap
                  .group(eventLoopGroupBoss, eventLoopGroupWorker)
                  .channel(NioServerSocketChannel.class)
                  .childHandler(this);
                  try {
                  ChannelFuture bindFuture = bootstrap.bind(DdmPreferences.getAdbProxyPort()).sync();
                  if (!bindFuture.isSuccess()) {
                  throw new RuntimeException("Adb proxy port binding failed", bindFuture.cause());
                  } else {
                  log.info("Adb-Proxy: Server started at port: {}", DdmPreferences.getAdbProxyPort());
                  }
                  } catch (InterruptedException e) {
                  throw new RuntimeException("Adb proxy port binding interrupted", e);
                  }
                  }
                  }

                  在 ADB Proxy 中最重要的是如何识别每个 ADB 连接的请求内容,并针对不同请求作出不同的处理,在这里需要解析 ADB 请求的头部信息,并根据请求的内容来实现设备拦截的功能。需要注意的请求:

                  1. host:track-devices

                    • 介绍: 这种请求类似于执行adb devices
                      , 它会实时返回设备的上下线情况。

                    • 处理方式: 需要对原始 track-devices 请求的返回结果进行过滤,因为我们可能只放行了部分手机的 ADB Proxy 功能,这样只会传输指定设备的上线/下线信息。

                  2. host:transport

                    • 介绍: 这种请求类似于执行adb -s <serialNumber> shell <command>
                      , 是针对某一设备的 ADB 操作,在请求的描述中会包含序列号来指定想要操纵的设备。

                    • 处理方式: 从请求中解析出序列号,然后对这部分进行过滤,如果有非法的代理请求,就直接断开连接。

                  3. host-serial

                    • 介绍: 这种请求类似于执行adb -s forward <local> <remote>
                      ,是将手机上的 socket 映射到宿主机上。

                    • 处理方式: 从请求中解析出序列号,然后对这部分进行过滤,如果有非法的代理请求,就直接断开连接。

                  处理完这些需要过滤的内容后,最后我们会在 Netty 的 PipeLine 中加入一个 ProxyHandler,通过它来代理传输 ProxyConnection 和 OriginalAdbConnection 的数据,此外还要达到其中一个连接断开时,自动断开另一个连接的效果。

                    public class ConnectionProxyHandler extends ByteToMessageDecoder {


                    private static final int LENGTH_FIELD_SIZE = 4;
                    private static final String SPECIFIC_DEVICE_TRANSPORT_HEADER = "host:transport:";
                    private static final String FORWARD_HEADER = "host-serial:";
                    private Integer headerLength;
                    private String header;
                    private AdbConnection adbConnection;
                    private String serialNumber = "NULL";
                    private boolean originalConnectionHeaderSent = false;


                    @Override
                    public void channelInactive(ChannelHandlerContext ctx) {
                    if (adbConnection != null && adbConnection.isActive()) {
                    log.info("Adb-Proxy {}-{}: Closed, reason: proxy connection closed", ctx.channel().id(), serialNumber);
                    adbConnection.close();
                    }
                    }


                    @Override
                    protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
                    if (header == null) {
                    if (headerLength == null) {
                    if (in.readableBytes() >= LENGTH_FIELD_SIZE) {
                    headerLength = Integer.parseInt(in.readSlice(LENGTH_FIELD_SIZE).toString(AdbHelper.DEFAULT_CHARSET), 16);
                    }
                    } else if (in.readableBytes() >= headerLength) {
                    header = in.readSlice(headerLength).toString(AdbHelper.DEFAULT_CHARSET);
                    }
                    }
                    if (header != null) {
                    if (header.equals(ADB_TRACK_DEVICES_COMMAND)) {
                    handleTrackDevices(ctx, in);
                    } else if (header.startsWith(SPECIFIC_DEVICE_TRANSPORT_HEADER)) {
                    handleTransport(header.replace(SPECIFIC_DEVICE_TRANSPORT_HEADER, ""), ctx, in);
                    } else if (header.startsWith(FORWARD_HEADER)) {
                    handleTransport(header.split(":")[1], ctx, in);
                    } else {
                    log.error("Adb-Proxy {}-{}: Closed, reason: header type not supported yet, {}",
                    ctx.channel().id(), serialNumber, header);
                    ctx.close();
                    }
                    }
                    }


                    private void handleTransport(String serialNumber, ChannelHandlerContext ctx, ByteBuf in) {
                    this.serialNumber = serialNumber;
                    if (DdmPreferences.shouldOpenAdbProxy(serialNumber)) {
                    buildProxy(ctx, in);
                    } else {
                    log.info("Adb-Proxy {}-{}: Closed, reason: want to use limited device", ctx.channel().id(), serialNumber);
                    ctx.close();
                    }
                    }


                    private void handleTrackDevices(ChannelHandlerContext ctx, ByteBuf in) {
                    if (createProxyConnectionSuccess(ctx)) {
                    try {
                    if (!originalConnectionHeaderSent) {
                    // 先发OKAY,保证OKAY在device list之前发送,如果出错了直接断开就可以接受
                    ctx.writeAndFlush(Unpooled.wrappedBuffer(ID_OKAY));
                    adbConnection.sendAndWaitSuccess(
                    header,
                    DdmPreferences.getTimeOut(),
                    TimeUnit.MILLISECONDS,
                    new DeviceMonitorHandler(),
                    new TrackDevicesFilterHandler(ctx));
                    originalConnectionHeaderSent = true;
                    }
                    if (in.readableBytes() > 0) {
                    adbConnection.writeAndFlush(in.readBytes(in.readableBytes()));
                    }
                    } catch (Exception e) {
                    log.info("Adb-Proxy {}-{}: Closed, reason: {}", ctx.channel().id(), serialNumber, e.getMessage());
                    ctx.close();
                    }
                    }
                    }


                    private void buildProxy(ChannelHandlerContext ctx, ByteBuf in) {
                    if (createProxyConnectionSuccess(ctx)) {
                    adbConnection.buildProxyConnectionIfNecessary(ctx, serialNumber);
                    if (!originalConnectionHeaderSent) {
                    adbConnection.writeAndFlush(Unpooled.wrappedBuffer(String.format("%04X%s", headerLength, header)
                    .getBytes(AdbHelper.DEFAULT_CHARSET)));
                    originalConnectionHeaderSent = true;
                    }
                    if (in.readableBytes() > 0) {
                    adbConnection.writeAndFlush(in.readBytes(in.readableBytes()));
                    }
                    }
                    }


                    private boolean createProxyConnectionSuccess(ChannelHandlerContext ctx) {
                    if (adbConnection == null) {
                    try {
                    adbConnection = AdbHelper.connect(AndroidDebugBridge.getSocketAddress(), null);
                    log.info("Adb-Proxy {}-{}: Opened, command: {}", ctx.channel().id(),
                    serialNumber, header);
                    return true;
                    } catch (IOException e) {
                    log.info("Adb-Proxy {}-{}: Closed, reason: create original adb connection failed", ctx.channel().id(),
                    serialNumber);
                    ctx.close();
                    return false;
                    }
                    } else {
                    return true;
                    }
                    }
                    }

                    Github 仓库

                    https://github.com/BeiKeJieDeLiuLangMao/netty-ddmlib

                    注意事项

                    因为 adb forward 只会监听宿主机的 localhost,所以如果想要同时使用 ADB Proxy 和 adb forward,需要设置一下 iptables,完成从网络接口 ip 到 localhost 的映射。

                      # 这里以27081-27090为例
                      iptables -t nat -A PREROUTING -p tcp -m tcp --dport 27081:27090 -j DNAT --to-destination 127.0.0.1:27081-27090
                      service iptables save
                      service iptables restart
                      sysctl -w net.ipv4.conf.all.route_localnet=1

                      参考内容

                      [1]https://developer.android.com/studio/command-line/adb [2]https://android.googlesource.com/platform/tools/base/

                      参考资料

                      [1]

                      ADB: https://developer.android.com/studio/command-line/adb

                      [2]

                      官仓: https://android.googlesource.com/platform/tools/base/

                      [3]

                      studio-master-dev: https://android.googlesource.com/platform/tools/base/+/refs/heads/studio-master-dev

                      [4]

                      539b90ad: https://android.googlesource.com/platform/tools/base/+/539b90ad38808a0f3efec037c9a6ac3603ef0f9b

                      [5]

                      ADB: https://developer.android.com/studio/command-line/ad

                      文章转载自贝贝猫技术分享,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

                      评论