QGC 二次开发实战:Android 单机离线授权怎么落地

QGroundControl 二次开发实战:Android 单机离线授权怎么落地

摘要

这篇文章记录一次 QGroundControl 二次开发中的实际需求落地:Android 遥控器上的 APK 可以安装,但拷贝到其它设备后不能继续正常使用。实现上没有走联网激活,而是采用 设备指纹 + 应用签名 + 私钥签发 license.dat + APK 内置公钥验签 的离线方案。整体仍然沿用 QGC 原有框架,把 Android 相关能力放在 QGCActivity.java,授权状态管理放在 C++ 工具层 LicenseManager,界面拦截放在 MainRootWindow.qml,同时补了一套便携式厂内发码工具,方便测试人员现场抓取设备信息并生成授权文件。本文重点记录这套方案在 QGC 二次开发中的代码落点、目录设计和实操流程。

一、需求背景

这次项目里的需求其实很典型:

  • App 运行在 Android 遥控器上
  • 同一个 APK 安装到其它设备后不能正常使用
  • 尽量不依赖联网
  • 厂家或测试人员要能现场快速完成授权测试

如果单看 Android 应用开发,很容易想到注册码、设备码这类做法;但项目本身是 QGroundControl(QGC) 二次开发,所以实现时更重要的是顺着 QGC 原有框架去接,而不是把逻辑堆在单一层里。

最后落地的方案是:

设备指纹 + 应用签名 + 私钥签发 license.dat + APK 内置公钥验签


二、整体架构

整个实现仍然沿用 QGC 二次开发常见的三层结构:

  • Android Java 层:采集设备指纹、应用签名、完成公钥验签
  • QGC C++ 工具层:统一处理授权文件加载、设备绑定和授权状态
  • QML 主界面层:负责授权页显示和主界面拦截

1. 整体架构图

Android 遥控器
usv-pilot App
QGCActivity.java

采集 Device Fingerprint

采集 App Signature
LicenseManager

读取 license.dat

绑定校验

验签
MainRootWindow.qml

授权页 / 主界面拦截
厂家发码工具

Capture-AndroidLicense.ps1
生成 license.dat
私钥

license_private_key.json
APK 内置公钥

QGCActivity.java
savePath/license.dat

导入源文件
savePath/License/license.dat

正式授权文件
授权成功

进入主界面
授权失败

显示授权页

2. 架构说明

这套方案里的角色划分比较明确:

(1)QGCActivity.java

位于:

text 复制代码
android/src/org/mavlink/qgroundcontrol/QGCActivity.java

这里负责:

  • 获取 Device Fingerprint
  • 获取 App Signature
  • 使用 APK 内置公钥做验签
  • 在 Debug 版本中打印设备指纹和签名日志
(2)LicenseManager

位于:

text 复制代码
src/LicenseManager.h
src/LicenseManager.cc

这是本次在 QGC 工具层新增的授权管理器,主要负责:

  • 查找 license.dat
  • 读取授权文件
  • 校验 deviceFingerprint
  • 校验 appSigningCertDigest
  • 调用 Java 层完成签名验证
  • 向 QML 暴露授权状态
(3)MainRootWindow.qml

位于:

text 复制代码
src/ui/MainRootWindow.qml

这里负责:

  • 未授权时拦截主界面
  • 显示授权页
  • 显示设备指纹、应用签名、错误信息
  • 提供导入授权文件和重新校验按钮

这里继续沿用项目自己的 X 控件体系,而不是回退到默认 QGC 控件,这样整体风格不会乱。


三、授权文件与目录设计

授权文件统一使用:

text 复制代码
license.dat

最终保存位置为:

text 复制代码
savePath/License/license.dat

其中 License/ 与这些目录同级:

  • WaterQualityData/
  • Logs/
  • Photo/
  • Video/
  • Telemetry/
  • Missions/

为了方便测试导入,先约定把源文件放在:

text 复制代码
savePath/license.dat

然后在 App 中点击导入按钮,由程序自动复制到:

text 复制代码
savePath/License/license.dat

这样目录结构清晰,也符合项目本身的保存路径体系。


四、授权校验流程

1. 授权校验流程图





启动 App
QGCActivity 获取

Device Fingerprint / App Signature
LicenseManager 查找授权文件
是否存在

savePath/License/license.dat ?
进入授权页
读取 license.dat
校验 productId
校验 deviceFingerprint
校验 appSigningCertDigest
使用 APK 内置公钥验签
是否全部通过?
授权成功

进入主界面
授权失败

显示错误原因

2. 流程说明

App 启动后,先由 Java 层拿到当前设备的 Device FingerprintApp Signature,再由 LicenseManager 去查找授权文件。如果 license.dat 不存在,直接进入授权页;如果存在,就读取文件内容,依次校验:

  • productId
  • deviceFingerprint
  • appSigningCertDigest
  • 签名是否由匹配的私钥生成

只有全部通过,才会解锁主界面。这样即使把同一个 APK 拷贝到其它遥控器,或者把同一份 license.dat 拿到另一台设备上,也无法继续正常使用。


五、QGC 二次开发中的关键代码落点

这部分是整套方案真正落在代码里的地方,也是后面维护时最常回看的部分。

1. Java 层:设备指纹、应用签名与验签

QGCActivity.java 中,一共做了三件事:

  • 采集设备指纹
  • 采集 APK 签名摘要
  • 使用内置公钥验签
关键代码片段:公钥验签
java 复制代码
private static PublicKey getLicensePublicKey() throws Exception
{
    byte[] modulusBytes = Base64.decode(LICENSE_PUBLIC_MODULUS_B64, Base64.DEFAULT);
    byte[] exponentBytes = Base64.decode(LICENSE_PUBLIC_EXPONENT_B64, Base64.DEFAULT);
    RSAPublicKeySpec keySpec = new RSAPublicKeySpec(new BigInteger(1, modulusBytes), new BigInteger(1, exponentBytes));
    return KeyFactory.getInstance("RSA").generatePublic(keySpec);
}

public static boolean verifyLicenseSignature(String canonicalPayload, String signatureBase64)
{
    try {
        java.security.Signature verifier = java.security.Signature.getInstance("SHA256withRSA");
        verifier.initVerify(getLicensePublicKey());
        verifier.update(canonicalPayload.getBytes(StandardCharsets.UTF_8));
        byte[] signatureBytes = Base64.decode(signatureBase64, Base64.DEFAULT);
        return verifier.verify(signatureBytes);
    } catch (Exception e) {
        Log.e(TAG, "verifyLicenseSignature failed", e);
        return false;
    }
}

这一段的作用很直接:

  • APK 内只保存公钥
  • license.dat 由厂家私钥签发
  • App 启动时用公钥校验这份授权文件是否可信
关键代码片段:生成设备指纹
java 复制代码
public static String getDeviceFingerprint()
{
    try {
        Context context = _instance != null ? _instance : m_context;
        if (context == null) {
            return "";
        }

        String androidId = "";
        try {
            androidId = Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID);
        } catch (Exception e) {
            Log.e(TAG, "ANDROID_ID lookup failed", e);
        }

        String rawFingerprint = "ANDROID_ID=" + normalizeLicenseField(androidId)
                + "|BRAND=" + normalizeLicenseField(safeBuildField(Build.BRAND))
                + "|MODEL=" + normalizeLicenseField(safeBuildField(Build.MODEL))
                + "|DEVICE=" + normalizeLicenseField(safeBuildField(Build.DEVICE))
                + "|SERIAL=" + normalizeLicenseField(getBestEffortSerial());

        String fingerprint = sha256Hex(rawFingerprint.getBytes(StandardCharsets.UTF_8));
        if (isDebugBuild()) {
            Log.d(TAG, "License device fingerprint: " + fingerprint);
        }
        return fingerprint;
    } catch (Exception e) {
        Log.e(TAG, "getDeviceFingerprint failed", e);
        return "";
    }
}

这里不是直接使用某一个原始设备号,而是把多个设备字段规范化后再做哈希,这样稳定性会更好一些。

关键代码片段:获取应用签名摘要
java 复制代码
public static String getAppSigningCertDigest()
{
    try {
        Context context = _instance != null ? _instance : m_context;
        if (context == null) {
            return "";
        }

        PackageManager packageManager = context.getPackageManager();
        PackageInfo packageInfo;

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
            packageInfo = packageManager.getPackageInfo(context.getPackageName(), PackageManager.GET_SIGNING_CERTIFICATES);
            SigningInfo signingInfo = packageInfo.signingInfo;
            if (signingInfo != null) {
                android.content.pm.Signature[] signatures = signingInfo.hasMultipleSigners()
                        ? signingInfo.getApkContentsSigners()
                        : signingInfo.getSigningCertificateHistory();
                if (signatures != null && signatures.length > 0) {
                    String digest = sha256Hex(signatures[0].toByteArray());
                    if (isDebugBuild()) {
                        Log.d(TAG, "License app signing digest: " + digest);
                    }
                    return digest;
                }
            }
        }
    } catch (Exception e) {
        Log.e(TAG, "getAppSigningCertDigest failed", e);
    }

    return "";
}

这里把当前 APK 的签名摘要也纳入授权校验范围,这样即使有人重新签名打包,也过不了授权验证。


2. C++ 层:LicenseManager 统一管理授权状态

在 QGC 二次开发里,如果想让授权状态对整个应用可见,最自然的做法就是接进 QGCToolbox。因此这里新增了 LicenseManager,并把它挂到了 QGC 全局工具链里。

关键代码片段:加载并校验授权文件
cpp 复制代码
void LicenseManager::reloadLicense()
{
    _refreshDeviceInfo();
    _licenseFilePath = _resolvePreferredLicensePath();

#if !defined(__android__)
    _setLicensed(true, QString());
    emit licenseStateChanged();
    return;
#endif

    QFile licenseFile(_licenseFilePath);
    if (!licenseFile.exists()) {
        _setLicensed(false, tr("License file not found at %1").arg(_licenseFilePath));
        return;
    }

    if (!licenseFile.open(QIODevice::ReadOnly | QIODevice::Text)) {
        _setLicensed(false, tr("Unable to open license file at %1").arg(_licenseFilePath));
        return;
    }

    const QByteArray licenseBytes = licenseFile.readAll();
    licenseFile.close();

    QJsonParseError parseError;
    const QJsonDocument licenseDocument = QJsonDocument::fromJson(licenseBytes, &parseError);
    if (parseError.error != QJsonParseError::NoError || !licenseDocument.isObject()) {
        _setLicensed(false, tr("Invalid license file format."));
        return;
    }

    QString validationError;
    if (!_validateLicenseObject(licenseDocument.object(), validationError)) {
        _setLicensed(false, validationError);
        return;
    }

    _setLicensed(true, QString());
}

这段代码的作用就是把整个授权流程收口到一个入口里,App 启动或用户点击 Recheck License 时都走这里。

关键代码片段:从保存根目录导入授权文件
cpp 复制代码
bool LicenseManager::importLicenseFromSaveRoot()
{
#if !defined(__android__)
    _setLicensed(true, QString());
    return true;
#else
    QString saveRootPath;
    if (_appSettings) {
        saveRootPath = _appSettings->savePath()->rawValue().toString();
    }

    const QString sourcePath = QDir(saveRootPath).filePath(licenseFileName());
    const QString destinationPath = _resolvePrimaryLicensePath();

    if (saveRootPath.isEmpty() || !QFileInfo::exists(sourcePath)) {
        _setLicensed(false, tr("license.dat was not found in %1").arg(sourcePath));
        return false;
    }

    QDir destinationDir = QFileInfo(destinationPath).dir();
    if (!destinationDir.exists() && !destinationDir.mkpath(QStringLiteral("."))) {
        _setLicensed(false, tr("Unable to create license directory at %1").arg(destinationDir.absolutePath()));
        return false;
    }

    if (QFileInfo::exists(destinationPath) && !QFile::remove(destinationPath)) {
        _setLicensed(false, tr("Unable to replace the existing license file."));
        return false;
    }

    if (!QFile::copy(sourcePath, destinationPath)) {
        _setLicensed(false, tr("Failed to import license.dat from %1").arg(sourcePath));
        return false;
    }

    reloadLicense();
    return _isLicensed;
#endif
}

这段代码对应的就是授权页上的导入按钮。测试人员只要把 license.dat 先放到保存根目录,再点导入即可。


3. QML 层:在 MainRootWindow 中做授权拦截

界面如图所示:

QML 层的处理比较直接,就是在 MainRootWindow.qml 中增加一层授权页,用来拦截未授权设备。

关键代码片段:授权页显示条件
qml 复制代码
visible: XScreenTool.isAndroid
         && QGroundControl.licenseManager
         && QGroundControl.licenseManager.licenseEnforced
         && !QGroundControl.licenseManager.isLicensed

这段逻辑保证了:

  • 只在 Android 下拦截
  • 桌面版本不受影响
  • 只有未授权时才显示授权页
关键代码片段:导入与重检按钮
qml 复制代码
Buttonl {
    width: parent.width
    text: qsTr("Import license.dat from Save Root")
    onClicked: {
        if (QGroundControl.licenseManager) {
            QGroundControl.licenseManager.importLicenseFromSaveRoot()
        }
    }
}

Button {
    width: parent.width
    text: qsTr("Recheck License")
    onClicked: {
        if (QGroundControl.licenseManager) {
            QGroundControl.licenseManager.reloadLicense()
        }
    }
}

这部分在实际测试时非常关键,因为它直接决定了工厂导入流程是否顺手。


六、厂内实操流程

为了方便厂家和测试人员现场使用,这里又配套做了一套便携式脚本工具。

1. 工厂/测试流程图

测试人员连接遥控器
运行

Capture-AndroidLicense.bat
自动抓取

Device Fingerprint

App Signature
使用私钥生成

license.dat
推送到

savePath/license.dat
App 点击

Import license.dat from Save Root
导入到

savePath/License/license.dat
点击 Recheck License
授权成功

2. 便携工具包结构

text 复制代码
发码工具包/
  Capture-AndroidLicense.bat
  Capture-AndroidLicense.ps1
  license_private_key.json
  platform-tools/
    adb.exe
    AdbWinApi.dll
    AdbWinUsbApi.dll
    ...

3. 实操步骤

如果是一台全新的未授权 Android 遥控器,厂家或测试人员只需要按下面步骤操作:

  1. 安装最新 APK,首次启动后进入授权页
  2. 运行 Capture-AndroidLicense.bat
  3. 脚本自动抓取:
    • Device Fingerprint
    • App Signature
  4. 脚本自动生成 license.dat
  5. 脚本可选自动 push 到 savePath/license.dat
  6. 在 App 授权页点击:
    • Import license.dat from Save Root
    • Recheck License
  7. 授权成功后进入主界面
关键代码片段:抓取日志中的设备指纹与签名
powershell 复制代码
$logOutput = & $script:AdbExePath logcat "-d" "QGC_QGCActivity:D" "*:S" 2>&1 | Out-String
$fingerprintMatch = [regex]::Match($logOutput, "License device fingerprint:\s*([A-F0-9]+)")
$signingMatch = [regex]::Match($logOutput, "License app signing digest:\s*([A-F0-9]+)")

这段代码的作用很简单,就是直接从调试日志里抓出两个关键值,不需要手工去抄。

关键代码片段:便携模式优先使用本地 adb
powershell 复制代码
$localAdbPath = Resolve-PreferredPath -RelativeOrAbsolutePath "platform-tools\adb.exe" -BaseDirectories @($scriptDirectory, $workingDirectory)
if (Test-Path $localAdbPath) {
    $script:AdbExePath = $localAdbPath
} elseif ($adbCommand) {
    $script:AdbExePath = $adbCommand
} else {
    throw "adb.exe was not found. Place platform-tools next to this script, or install adb and add it to PATH."
}

这一步主要是为了让厂家测试人员拿到工具包后可以直接双击使用,不依赖系统环境变量。


七、license.dat 是怎么生成的

license.dat 里保存的是一组授权数据,例如:

  • productId
  • deviceFingerprint
  • appSigningCertDigest
  • fingerprintVersion
  • licenseVersion
  • issuedAt
  • features
  • signature

其中前面的字段构成授权内容,最后再由私钥生成 signature

所以从原理上说:

license.dat = 授权内容 + 私钥签名

对应关系如下:

  • 私钥:厂家保管,用于发码
  • 公钥:内置在 APK 中,用于验签
  • license.dat:保存在设备上,承载设备绑定授权信息

这样就算别人拿到了 APK,也只能看到公钥,无法反推出私钥;没有私钥,也就不能伪造合法授权文件。


八、方案优点与边界

1. 优点

这套方案的优点主要有:

  • 基于 QGC 原有框架扩展,代码落点清晰
  • 不依赖联网,适合工厂测试和现场交付
  • 同时绑定设备和 APK 签名
  • 授权页可直接显示关键调试信息
  • 配套便携发码工具,现场使用成本低

2. 边界

这套方案也有边界,需要提前说清楚:

  • 它不能阻止 APK 被安装
  • 只能保证"安装后未授权不可用"
  • 如果私钥泄露,别人也能自行发码

所以真正需要保护好的,是:

text 复制代码
license_private_key.json

这个文件不能随便外发。


九、总结

这次基于 QGroundControl 二次开发实现 Android 单机离线授权,最大的体会是:这类需求真正难的不是"验签"本身,而是如何把 Java、C++、QML 和厂内测试流程串成一条完整闭环。单独写一个注册码输入框并不难,难的是让它真正适合项目结构、适合交付、适合后面的人继续维护。

从结果上看,这套方案已经能满足当前项目的主要目标:未授权设备无法进入主界面,APK 拷贝到其它遥控器后不能继续正常使用,厂家或测试人员也可以通过便携工具快速完成现场发码和验证。对 QGC 二次开发项目来说,这种"尽量顺着原框架接"的实现方式,后期维护成本也会更低一些。

相关推荐
一笑的小酒馆1 分钟前
Androidiot扫地机器人设备详情页地图加载慢卡顿优化方案
android
Zhu7586 小时前
在k8s集群部署MySQL单实例,支持多个主流发行版
android·mysql·kubernetes
AFinalStone9 小时前
Android 7系统异常问题排查(四)Framework层(上)—System Server Watchdog
android·系统异常
郝学胜-神的一滴10 小时前
Qt 高级编程 040:按钮悬浮弹出滑块弹窗的完整攻略
开发语言·c++·qt·软件工程·用户界面
我是小灰灰吖10 小时前
Ubuntu 22.04 安装 SSH 服务与远程调试环境配置指南
qt·ubuntu·ssh
宝杰X711 小时前
Android Room3 多平台数据库
android·数据库
huibin14785236913 小时前
热缓解学习记录
android·学习
雨田言炎14 小时前
四、关于Qt项目需要知道的
开发语言·笔记·qt
小田的博客16 小时前
SAP MM 供应商银行主数据更新报错!message R1228!
android·java·服务器
必须得开心呀17 小时前
qt生成dump文件并定位异常
开发语言·qt