拖拽排序列表

拖拽排序是一个很常见的功能,在浏览器不支持原生拖拽功能的时代,人们只能使用鼠标的点击事件来模拟拖拽效果,手动控制拖拽元素的位置来进行移动操作。随着原生拖拽 API 的支持,在实现这个功能的时候就可以更简单一点了。

# 相关事件

首先,你要想拖拽某个元素的话必须设置属性 draggable="true" 才可以进行拖拽操作。然后进行拖拽操作主要有以下事件需要处理。

  1. onDragStart 当拖拽开始的时候触发
  2. onDragOver 当拖拽到某个元素内部的时候触发
  3. onDragEnter 当拖拽进入某个元素的时候触发
  4. onDragEnd 当鼠标松开,拖拽结束的时候触发
  5. onDrop 当拖拽到某个元素并松开鼠标的时候触发

由于我们使用拖拽进行排序,而不是拖拽元素放到别的元素里面,所以我们并不需要进行 drop 处理,当 dragOver 某个元素的时候也不需要额外的处理。所以这两个事件的代码最简单,我们只需要移除默认的事件响应。

const dragOverHandler = useCallback((event: React.DragEvent<HTMLElement>) => {
  event.preventDefault();
  event.dataTransfer.dropEffect = 'move';
  return false;
}, []);
const dropHandler = useCallback((event: React.DragEvent<HTMLElement>) => {
  event.stopPropagation();
  event.preventDefault();
  return false;
}, []);
1
2
3
4
5
6
7
8
9
10

dropEffect 有四种,默认的是 copy 我们需要改成 move 才符合需求。接下来再来看看其他三个事件:

const dragStartHandler = useCallback(
  (event: React.DragEvent<HTMLElement>, index: number) => {
    event.dataTransfer.effectAllowed = 'move';
    // set dataTransfer enable mobile drag
    event.dataTransfer.setData('text/plain', index.toString());
    dragItem.current = index;
    copyData.current = deepClone(sortedData);
    recordRect();
    onDragStart?.();
  },
  [sortedData, recordRect, onDragStart]
);
const dragEnterHandler = useCallback(
  (index: number) => {
    if (dragItem.current !== index && dragOverItem.current !== index) {
      dragOverItem.current = index;
      const newData = deepClone(copyData.current);
      const dragData = newData[dragItem.current];
      newData.splice(dragItem.current, 1);
      newData.splice(dragOverItem.current, 0, dragData);
      setSortedData(newData);
      onDragEnter?.();
    }
  },
  [onDragEnter]
);
const dragEndHandler = useCallback(() => {
  updateData?.(sortedData);
  onDragEnd?.();
}, [sortedData, updateData, onDragEnd]);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
  1. dragStart 的时候记录下当前的 index,并复制一份数组数据。
  2. dragEnter 的时候记录下拖拽进入的元素 index,然后重新排序数组会渲染一个新的列表
  3. dragEnd 的时候同步数据到上层,展示排序后的结果

完整的代码请看文章最后的链接。

# FLIP 动画

当进行拖拽排序的时候,我们想要加上元素移动的动画,可以更明显的感受到顺序的变化。 我们使用 FLIP 技术来完成这个效果。

  1. First: 记录当前元素的位置
  2. Last: 记录更新后元素的位置
  3. Invert: 计算元素在 X 方向和 Y 方向的偏移,然后使用 transform 进行移动
  4. Play: 播放 transform 动画
useEffect(() => {
  if (draggable && containerRef?.current) {
    Array.from(containerRef.current.querySelectorAll('[data-id]')).forEach(
      async node => {
        const dom = node as HTMLElement;
        const key = dom.dataset.id as string;
        const prevRect = prevRects.current[key];
        if (key) {
          const rect = dom.getBoundingClientRect();
          if (prevRect) {
            const dy = prevRect.y - rect.y;
            const dx = prevRect.x - rect.x;
            dom.style.pointerEvents = 'none';
            dom.animate(
              [
                {
                  transform: `translate(${dx}px, ${dy}px)`
                },
                { transform: 'translate(0, 0)' }
              ],
              {
                duration: TIMEOUT,
                easing: 'linear'
              }
            );
            await Promise.allSettled(
              node.getAnimations().map(animation => animation.finished)
            );
            dom.style.pointerEvents = '';
          }
          prevRects.current[key] = rect;
        }
      }
    );
  }
}, [draggable, sortedData, containerRef]);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

上面是示例代码,注意这里的 FLIP 动画加了一些特殊处理,我们使用 pointerEvents 禁用动画过程中的事件响应,因为动画过程中触发任何 drag 事件会导致 index 顺序被改动,然后无限进行排序操作。 这不是我们期望的结果,然后在动画结束的时候再取消这个 CSS 属性。

# 总结

使用原生的拖拽 API 来实现排序功能更简单和易用,但目前手机浏览器还不支持这个功能。排序中的过渡动画是一个难点,特别是动画过程中移除事件的响应。如果可以使用 JS 的方式来临时禁用所有事件的响应,应该可以处理的更优雅。

# 参考

HTML Drag and Drop API (opens new window)

simple-drag-drop-sort-list (opens new window)

FLIP Your Animations (opens new window)

Using the HTML5 Drag and Drop API (opens new window)

QR code 生成的相关参数

QR code 本身就是把文字生成一个二维码的过程,顶多指定一下纠错等级。但是有些硬件设备只支持特定参数的二维码,所以我就查了一下二维码的相关规范了解一下生成过程中需要哪些参数。

# version

二维码有一个 version 参数,范围是从 1 到 40。 每个版本都具备固有的码元结构(码元数),码元是指构成 QR 码的方形黑白点。“码元结构”是指二维码中的码元数。从版本 1(21 码元 ×21 码元)开始,在纵向和横向各自以 4 码元为单位递增,一直到版本 40(177 码元 ×177 码元)。规律是 4n +17 (n 代表版本号)。版本越大能容纳的信息就越多,一般是选择能容纳所需信息的最小版本即可。

# error correction level

QR 码具有纠错功能,保证二维码在丢失一定数据的情况下还是可以读取。纠错有四个级别分别是:

  1. L (Low) 纠错 7%
  2. M (Medium) 纠错 15%
  3. Q (Quartile) 纠错 25%
  4. H (High) 纠错 30%

纠错能力越高需要存储的信息就越多,对应需要的 version 就要越高。

# mask

mask 是为了防止二维码有大量空白或者填充,让扫描器尽可能容易的进行扫描。mask 方案目前有 8 种。

Mask Pattern

选择 mask 的方案有相关的标准可以自行查询,本质目标是让生成的图片尽量黑白相间,避免同一颜色的色块大量出现,方便扫描器读取数据。

# 总结

普通业务使用的二维码相关库应该只提供文本接口和纠错标准的设定,会根据文本长度和纠错标准来自动其他两个参数。但是针对特殊需求的情况下就需要把 version 和 mask 参数暴露出来。 同时因为手动指定 version 也会因为 version 版本太低导致无法生成合法的二维码的情况。使用的时候需要注意。我本身有修改一个版本可以传入底层参数,可以看一下参考链接。

# 参考

Information capacity and versions of QR Code (opens new window)

What is a QR code? (opens new window)

QR Code generator (opens new window)

Reed–Solomon codes for coders (opens new window)

qrcodegen (opens new window)

安卓逆向的经验记录

2023 年,博客也开通 10 年了。我都不敢看 10 年前自己写的东西。感觉全是黑历史,不过也记录了自己一点点的成长和心境的变化。今年也将继续写,记录自己技术上的各种折腾。

# 安卓逆向初体验

首先自己知道了一个安卓电视盒子可以看全球的电视频道,我就想着干嘛要买人家的硬件,随便找个安卓手机不也可以使用么?于是找到了电视盒子上的软件,发现人家是有校验的。不是自家硬件初始化都无法通过。 于是我就好奇这个硬件校验是怎么做的,带着这个好奇心,开始了安卓逆向的征程。

# Java 代码脱壳

现在的安卓软件都会使用梆梆加固之类的软件防止别人进行逆向操作,所以第一步就是处理这个。先看到 JAVA 代码才能深入研究相关逻辑。经过同学指点,使用了一个 BlackDex 的工具。可以直接在安卓手机上进行脱壳操作。使用这个工具可以拿到 dex 文件。然后使用 jadx 输出 JAVA 代码。

jadx -d dist_fold ./cookie_xxx.dex
1

# 安卓手机抓包

拿到 JAVA 代码全部都读一遍也不现实,基于前端的逆向经验。我想先抓包看看请求内容。抓包软件使用的是 Charles 一款跨平台的抓包软件,不过是商业软件需要付费。 通过抓包分析到,请求全部是基于 HTTP 的,所以不需要再向安卓手机上安装抓包用的 CA 证书确实省事了不少。但是请求的数据和返回都是加密的。基于请求的字段并结合 JAVA 代码分析出加密使用的是 aes-128-cbc 算法。这个算法需要 key 和 iv 两个参数,那这两个参数是基于什么信息来生成的呢?通过请求带的特殊的 HTTP headers 进行猜测,应该是 utctime mac model 这三个参数。 为什么这么分析呢,因为服务器端需要进行解密,那必须要把需要的参数都告诉服务端才能把密文解密回明文。

# 加密算法逻辑分析

来到这里,最重要的是需要知道如何生成 key 和 iv 参数,因为 aes 是对称加密,我们知道这两个参数就能把密文再解析回明文。那就带着这个疑问来去读 JAVA 代码。

public class EncryptRule {
    private static String getEncrypKey(String str) {
        String substring = MACUtils.getMac().substring(4, 10);
        return MD5Util.getStringMD5_32(substring + "k3k7a3mM" + str).substring(8, 24);
    }

    private static String getDecrypKey() {
        String substring = MACUtils.getMac().substring(4, 10);
        String str = Build.MODEL;
        return MD5Util.getStringMD5_32(substring + "k3k7a3mM" + str).substring(8, 24);
    }

    public static String getEncryptionParams(String str, String str2) {
        String encrypKey = getEncrypKey(str2);
        return new String(Base64.encodeBase64(AES.encrypt(str.getBytes(), encrypKey.getBytes(), encrypKey.getBytes())));
    }

    public static String getSoEncryptionParams(String str, String str2, String str3) {
        return android.util.Base64.encodeToString(MainActivity.getJniApi().encodeAES(str.getBytes(), str3.getBytes(), str2.getBytes()), 2);
    }

    public static String getSoDecrypt(String str, String str2) {
        try {
            return new String(MainActivity.getJniApi().decodeAES(str.getBytes(), android.util.Base64.decode(str2.getBytes(), 2)));
        } catch (Exception e) {
            e.printStackTrace();
            return "";
        }
    }

    public static String getDecrypt(String str) {
        return AES.decrypt(str, getDecrypKey(), getDecrypKey(), "utf8");
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

通过上面这段 JAVA 代码,我们知道加密的 key 是基于 MAC 地址信息进行生成的,传入的 str 参数其实是 utctime 。而解密 key 是基于 MAC 地址和 MODEL 信息,返回值的解密并没有用到 utctime 。返回信息的时候考虑到网络延迟 utctime 不是一个靠谱的参数。这里的 JAVA 逻辑是非核心请求的算法,我们想知道核心的播放列表信息的时候,发现它使用的是 getSoEncryptionParams 这个方法调用,而这个方法经过分析是调用 C++ 生成的 so 文件。逆向难度又上了一个台阶。顺便说下,经过 JAVA 代码分析 key 和 iv 其实是相同的参数。

# so 文件分析

C++ 编译成 so 文件本质就是一个二进制文件。要想分析这个二进制文件需要很高的汇编功底和 C 语言知识,因为有些工具可以把二进制反编译成可读的 C 语言。经过同学点拨,使用了 IDA ProGhidra 两个工具来对 so 文件进行逆向。so 文件的分析非常复杂,这里就只说结论了,so 里面分析出来的加解密算法和 JAVA 是一样的,但是中间拼接的字符串是不一样的。只要找到了核心的字符串就能完成请求的加解密。关于请求的解密,针对非英文字符,请使用 UTF-8 编码方式,由于我使用的工具默认采用 ASCII 编码,导致中文乱码,花了一段时间才找到原因。

# 总结

出于满足自己的好奇心,在各路同学的指点下,进行了安卓程序的逆向分析。非常有趣并且学到了很多知识,最后拿到了播放列表,但发现是基于一个私有的 tvbus 协议,没法移植到别的 APP 上使用,只能到此作罢。但是这次逆向分析的收获还是不少,在此记录一下。

# 参考

BlackDex (opens new window)

jadx (opens new window)

Charles (opens new window)

CyberChef (opens new window)

Ghidra (opens new window)

IDA Pro (opens new window)

EncryptRule (opens new window)