mirror of
https://gitea.winboll.cc/Studio/OriginMaster.git
synced 2026-02-05 08:41:35 +08:00
Compare commits
49 Commits
appbase-v1
...
appbase
| Author | SHA1 | Date | |
|---|---|---|---|
| 17d1c2f321 | |||
| 94ac2d9f9c | |||
| 447b7fa5a8 | |||
| 11d7846cd2 | |||
| dae39b43d6 | |||
| 530316b976 | |||
| 3f924b004c | |||
| 1db94b52e6 | |||
| 55c653af09 | |||
| 9d97d6ed94 | |||
| 21e464bf5e | |||
| 47f27b6bcb | |||
| 013ad7ff1b | |||
| ab6fd0c6b5 | |||
| e21bb9058d | |||
| ad6175f977 | |||
| 8b659f4b24 | |||
| 13b841f923 | |||
| e9ad701db4 | |||
| 0aaf71f285 | |||
| 3c1afc1c0b | |||
| 395920743b | |||
| 4ea2b5fad0 | |||
| 760fe4613f | |||
| a656dfcc62 | |||
| e9605fa991 | |||
| 8546b6c8ad | |||
| f5ddefa895 | |||
| 35527374da | |||
| 2751ce4a39 | |||
| 730022a9f0 | |||
| 25f4c79860 | |||
| 5440c1ad39 | |||
| 47dd4698b8 | |||
| a3bc90d9b8 | |||
| 861ccb5832 | |||
| 8b1f119e44 | |||
| c6495d1859 | |||
| 15197b123c | |||
| df2e91d390 | |||
| 61ca0d2672 | |||
| 8c18710e36 | |||
| 224bd243e2 | |||
| b30bdc6802 | |||
| 8f0973cb6c | |||
| b9fab2d737 | |||
| 156af54eaa | |||
| fb9dd93162 | |||
| 7e757a456a |
279
README.md
279
README.md
@@ -1,175 +1,104 @@
|
||||
## OriginMaster
|
||||
【OriginMaster】WinBoLL 源生态计划。正如话,我需要一个 Point, 去撬动成个地球。
|
||||
WinBoLL系列项目各个项目源码合并推送方向:
|
||||
WinBoLL=>APPBase=>OriginMaster
|
||||
WinBoLL=>AES=>OriginMaster
|
||||
WinBoLL=>PowerBell=>OriginMaster
|
||||
WinBoLL=>Positions=>OriginMaster
|
||||
仓库规划概述:
|
||||
☆ WinBoLL,APPBase,AES,PowerBell与Positions都是开发库。
|
||||
☆ OriginMaster 是分支汇总存档库。
|
||||
☆ OriginMaster 不适宜作为开发库克隆使用,不是应用开发基础库,而是汇总资料库。
|
||||
☆ WinBoLL 库可以作为应用开发的基础继承模板。
|
||||
|
||||
########
|
||||
## ☁ ☁ ☁ WinBoLL APP ☁ ☁ ☁ ☁ ☁ ☁ ☁ ☁ ☁ ☁ ☁ ☁ ☁ ☁ ☁ ☁ ☁ ☁ ☁
|
||||
# ☁ ☁ WinBoLL Studio Android 应用开源项目。☁ ☁ ☁ ☁ ☁ ☁ ☁ ☁ ☁ ☁ ☁ ☁ ☁
|
||||
# ☁ ☁ ☁ WinBoLL 网站地址 https://www.winboll.cc/ ☁ ☁ ☁ ☁ ☁ ☁ ☁ ☁
|
||||
# ☁ ☁ ☁ WinBoLL 源码地址 <https://gitea.winboll.cc/Studio/WinBoLL.git> ☁ ☁ ☁ ☁ ☁ ☁ ☁
|
||||
# ☁ ☁ ☁ GitHub 源码地址 <https://github.com/ZhanGSKen/WinBoLL.git> ☁ ☁ ☁ ☁ ☁ ☁ ☁ ☁
|
||||
# ☁ ☁ ☁ 码云 源码地址 <https://gitee.com/zhangsken/winboll.git> ☁ ☁ ☁ ☁ ☁ ☁ ☁ ☁
|
||||
# ☁ ☁ ☁ 在 jitpack.io 托管的 APPBase 类库源码<https://github.com/ZhanGSKen/APPBase.git> ☁ ☁ ☁ ☁
|
||||
# ☁ ☁ ☁ 在 jitpack.io 托管的 AES 类库源码<https://github.com/ZhanGSKen/AES.git> ☁ ☁ ☁ ☁
|
||||
## WinBoLL 提问
|
||||
同样是 /sdcard 目录,在开发 Android 应用时,
|
||||
能否实现手机编译与电脑编译的源码同步。
|
||||
☁因而 WinBoLL 项目组诞生了。
|
||||
|
||||
## WinBoLL 项目组研发计划
|
||||
致力于把 WinBoLL-APP 应用在手机端 Android 项目开发。
|
||||
也在探索 https://gitea.winboll.cc/<WinBoLL 项目组>/APP.git 应用于 WinBoLL-APP APK 分发。
|
||||
更想进阶 https://github.com/<WinBoLL 项目组>/APP.git 应用于 WinBoLL-APP Beta APK 分发。
|
||||
|
||||
## WinBoLL-APP 汗下...
|
||||
#### ☁应用何置如此呢。且观用户云云。
|
||||
|
||||
#### ☁ 正当下 ☁ ###
|
||||
#### ☁ 且容傻家叙说 ☁ WinBoLL-APP 应用场景
|
||||
### ☁ WinBoLL 设备资源概述
|
||||
#### ☁ 1. Raid Disk.
|
||||
概述:这是一个矩阵存储类设备。
|
||||
优点:该设备具有数据容错存储功能,
|
||||
数据存储具有特长持久性。
|
||||
缺点:设备使用能源消耗比较高,
|
||||
设备存取速度一般。
|
||||
|
||||
#### ☁ 2. Data Disk.
|
||||
概述:这是一个普通硬盘存储设备
|
||||
优点:该设备独立于操作系统,
|
||||
数据持久性一般,
|
||||
存取能源消耗小于 Raid Disk。
|
||||
缺点:数据存储速度一般,存储能源消耗一般。
|
||||
|
||||
#### ☁ 3. SSD Disk.
|
||||
概述:这是一个 SSD 硬盘存储设备。
|
||||
优点:存取速度快于 Data Disk 与 Raid Disk,
|
||||
存取能源消耗小于 Data Disk 与 Raid Disk。
|
||||
缺点:数据持久性一般,
|
||||
设备位于操作系统内部文件系统。
|
||||
数据持久性与操作系统挂钩。
|
||||
|
||||
#### ☁ 4. WinBoLL 用户资源概述。
|
||||
1> /home/<用户名> 位于 WinBoLL 操作系统目录下。
|
||||
2> /rdisk/<用户名> 挂载用户 Raid Disk.
|
||||
3> /data/<用户名> 挂载用户 Data Disk.
|
||||
4> /sdcard/<用户名> 挂载用户 SSD Disk.
|
||||
|
||||
#### ☁ 5. WinBoLL-APP 用户资源概述。
|
||||
1> /sdcard 挂载用户手机 SD 存储/storage/emulated/0
|
||||
|
||||
### ☁ 稍稍歇 ☁ ###
|
||||
### ☁ 急急停 ☁ WinBoLL 应用前置条件
|
||||
☁ WinBoLL 主机建立 1Panel MySQL 应用。
|
||||
☁ WinBoLL 主机建立 1Panel Gitea 应用。
|
||||
☁ WinBoLL 主机设置 WinBoLL 应用为非登录状态。
|
||||
☁ WinBoLL 主机建立 WinBoLL 账户与 WinBoLL 用户组。
|
||||
☁ WinBoLL 账户 User ID 为: J。
|
||||
☁ WinBoLL 用户组 Group ID 为: Studio。
|
||||
☁ WinBoLL 主机 WinBoLL 1Panel Gitea 建立 WinBoLL 工作组。
|
||||
☁ WinBoLL 主机 WinBoLL 1Panel Gitea 用户项目 APK 编译输出目录为 /sdcard/WinBoLLStudio/<用户名>/APKs/
|
||||
☁ WinBoLL 项目配置文件示例为 "<WinBoLL 项目根目录>/.winboll/winboll.properties-demo"(WinBoLL 项目已设置)
|
||||
☁ WinBoLL 项目配置文件为 "<WinBoLL 项目根目录>/.winboll/winboll.properties"
|
||||
☁ WinBoLL 项目配置文件设定为源码提交时忽略。(WinBoLL 项目已设置)
|
||||
☁ Gradle 项目配置文件示例为 "<WinBoLL 项目根目录>/.winboll/local.properties-demo"(WinBoLL 项目已设置)
|
||||
☁ Gradle 项目配置文件为 "<WinBoLL 项目根目录>/local.properties"(WinBoLL 项目已设置)
|
||||
☁ Gradle 项目配置文件设定为源码提交时忽略。(WinBoLL 项目已设置)
|
||||
|
||||
### ☁ 登高处 ☁ WinBoLL 应用需求规划
|
||||
☁ WinBoLL 主机建立 WinBoLL 客户端用户数据库为 MySQL winbollclient 数据库。
|
||||
☁ WinBoLL 主机设置 WinBoLL 客户端用户信息存储在 winbollclient 数据库中。
|
||||
☁ MySQL winbollclient 数据库中
|
||||
WinBoLL 客户端用户信息设定为:
|
||||
<用户名, 验证密码, 验证邮箱, 验证手机, 唯一存储令牌Token, 备用验证邮箱>。
|
||||
☁ WinBoLL 项目源码仓库托管在 WinBoLL 1Panel Gitea 目录 /opt/1panel/apps/gitea/gitea/data/git/repositories/studio/app.git中。
|
||||
☁ WinBoLL 主机提供 WinBoLL 1Panel Gitea 应用的 WinBoLL 项目源码仓库存取功能。(Gitea 应用已提供)
|
||||
☁ WinBoLL 主机提供 WinBoLL Gitea 项目仓库存档功能。(Gitea 应用已提供)
|
||||
☁ 提供 WinBoLL 客户端用户登录功能。(Gitea 应用已提供)
|
||||
|
||||
### ☁ 看远方 ☁ ###
|
||||
### ☁ 心忧虑 ☁ WinBoLL-APP 应用前置需求
|
||||
☁ WinBoLL-APP WinBoLL 项目根目录设定为手机的 /sdcard/WinBoLLStudio/Sources 目录。(需要用户手动建立文件夹)
|
||||
☁ WinBoLL-APP 具有手机 /sdcard/WinBoLL 目录的存储权限。(需要手机操作系统授权)
|
||||
☁ WinBoLL-APP WinBoLL 项目仓库源码存储路径为 /sdcard/WinBoLLStudio/Sources/APP.git(需要用户手动建立文件夹)
|
||||
☁ WinBoLL-APP 项目 APK 编译输出目录为 /sdcard/WinBoLLStudio/APKs/
|
||||
☁ WinBoLL-APP 应用签名验证可定制化。(WinBoLL 项目已提供)
|
||||
☁ WinBoLL-APP 与系列衍生 APP 应用共享 cc.winboll.studio 命名空间资源。(WinBoLL 项目已提供)
|
||||
☁ WinBoLL-APP 用户客户端信息存储在命名空间为 WinBoLL APP MySQLLite 应用的 winbollappclient 数据库中。
|
||||
☁ WinBoLL-APP MySQLLite 应用的 winbollappclient 数据库中,
|
||||
WinBoLL 用户客户端信息设定为:
|
||||
<用户名, 唯一存储令牌Token>。
|
||||
|
||||
### ☁ 云游四方 ☁ ###
|
||||
### ☁ 呔! ☁ WinBoLL-APP 应用需求规划
|
||||
☁ 如要使用 WinBoLL Android 项目的 Gradle 编译功能,则需要设置以下两个文件夹。
|
||||
☁ 1. 则需要建立数据存储目录 /sdcard/WinBoLLStudio/APKs。
|
||||
WinBoLL 项目源码编译出来的安装包会拷贝一份到 /sdcard/WinBoLLStudio/APKs 目录下。
|
||||
☁ 2. 则需要建立数据存储目录 /sdcard/AppProjects。
|
||||
WinBoLL 项目源码编译出来的安装包会拷贝一份并命名 "app.apk" 的安装文件为到 /sdcard/AppProjects 目录下。
|
||||
|
||||
|
||||
### ☁ 吁! ☁ WinBoLL-APP 共享计划前景
|
||||
☁ WinBoLL-APP 将会实现 https://winboll.cc/api 访问功能。
|
||||
☁ WinBoLL-APP 将会实现手机端 Android 应用的开发与管理功能。
|
||||
|
||||
## ☁ WinBoLL ☁ WinBoLL 主机忧虑
|
||||
☁ WinBoLL 将会提供 gitea.winboll.cc 域名用户注册登录功能。
|
||||
☁ WinBoLL 将会提供 WinBoLL-APP 及其衍生应用的 Gitea 仓库管理服务。
|
||||
☁ WinBoLL 将会提供 winboll.cc 域名 WinBoLL 项目组注册登录功能。
|
||||
|
||||
# 本项目要实际运用需要注意以下几个步骤:
|
||||
# 在项目根目录下:
|
||||
## ★. 项目模块编译环境设置(必须),settings.gradle-demo 要复制为 settings.gradle,并取消相应项目模块的注释。
|
||||
## ★. 项目模块编译环境设置(必须) 在根目录拷贝 gradle.properties-androidx-demo 或者 gradle.properties-android-demo 文件为 gradle.properties。
|
||||
## ★. 项目 Android SDK 编译环境设置(可选),local.properties-demo 要复制为 local.properties,并按需要设置 Android SDK 目录。
|
||||
## ★. 应用签名密钥 keystore 设置问题。一般调试编译只需用【Termux】cd 进 GenKeyStore 目录执行 $ bash gen_debug_keystore.sh 命令即可完成设置。
|
||||
## ☆. 应用 WiBoLL 签名密钥配置问题<非必须考虑>。设置时需要 clone 【keystore】模块源码并拷贝模块目录的 appkey.jks 与 appkey.keystore 到项目根目录即可。
|
||||
## ☆. 类库型模块编译环境设置(可选),winboll.properties-demo 要复制为 winboll.properties,并按需要设置 WinBoLL Maven 库登录用户信息, 和 APK 文件额外输出路径。
|
||||
|
||||
|
||||
# ☆类库型项目编译方法
|
||||
## 先编译类库对应的模块测试项目
|
||||
### 修改模块测试项目的 build.properties 文件
|
||||
设置属性 libraryProject=<类库项目模块文件夹名称>
|
||||
### 再编译测试项目
|
||||
$ bash .winboll/bashPublishAPKAddTag.sh <应用项目模块文件夹名称>
|
||||
#### 测试项目编译后,编译器会复制一份 APK 到 路径:"/sdcard/WinBoLLStudio/APKs/<项目根目录名称>/tag/" 文件夹。
|
||||
#### 若是 winboll.properties 文件的 [ExtraAPKOutputPath] 属性设置了路径。编译器也会复制一份 APK 到这个路径。
|
||||
### 最后编译类库项目
|
||||
$ bash .winboll/bashPublishLIBAddTag.sh <类库项目模块文件夹名称>
|
||||
#### 类库模块编译命令执行后,编译器会发布到 WinBoLL Nexus Maven 库:Maven 库地址可以参阅根项目目录配置 build.gradle 文件。
|
||||
|
||||
# ☆应用型项目编译方法
|
||||
## 直接调用以下命令编译应用型项目
|
||||
$ bash .winboll/bashPublishAPKAddTag.sh <应用项目模块文件夹名称>
|
||||
#### 应用模块编译命令执行后,编译器会复制一份 APK 到
|
||||
#### 测试项目编译后,编译器会复制一份 APK 到 路径:"/sdcard/WinBoLLStudio/APKs/<项目根目录名称>/tag/" 文件夹。
|
||||
#### 若是 winboll.properties 文件的 [ExtraAPKOutputPath] 属性设置了路径。编译器也会复制一份 APK 到这个路径。
|
||||
|
||||
## ☆应用调试编译方法
|
||||
使用以下命令编译调试:
|
||||
|
||||
### Beta调试使用:
|
||||
$ bash gradlew assembleBetaDebug
|
||||
|
||||
### Stage调试使用:
|
||||
$ bash gradlew assembleStageDebug
|
||||
|
||||
### 若是 winboll.properties 文件的 [ExtraAPKOutputPath] 属性设置了路径。编译器也会复制一份 APK 到这个路径。
|
||||
|
||||
# 应用版本号命名方式
|
||||
## statge 渠道
|
||||
V<应用开发环境编号><应用功能变更号><应用调试阶段号>
|
||||
如:APPBase_15.7.0
|
||||
## beta 渠道
|
||||
V<应用开发环境编号><应用功能变更号><应用调试阶段号>-beta<调试编译计数>_<调试编译时间(分钟+秒钟)>
|
||||
如:APPBase_15.9.6-beta8_5413
|
||||
WinBoLL 源生态计划项目说明书
|
||||
|
||||
一、项目概述
|
||||
|
||||
1. 核心定位
|
||||
|
||||
【OriginMaster】WinBoLL 源生态计划,旨在通过核心项目 WinBoLL 联动系列开发库,构建手机端 Android 项目开发与多端编译同步的完整生态,实现手机与电脑的源码同步开发。
|
||||
|
||||
2. 仓库架构
|
||||
|
||||
仓库类型 包含仓库 功能说明
|
||||
开发库 WinBoLL、APPBase、AES、PowerBell、Positions 核心开发依赖库,其中 WinBoLL 可作为应用开发的基础继承模板
|
||||
分支汇总存档库 OriginMaster 仅用于汇总各开发库分支,不适宜作为开发库克隆使用,非应用开发基础库
|
||||
|
||||
3. 源码推送路径
|
||||
|
||||
- WinBoLL → APPBase → OriginMaster
|
||||
- WinBoLL → AES → OriginMaster
|
||||
- WinBoLL → PowerBell → OriginMaster
|
||||
- WinBoLL → Positions → OriginMaster
|
||||
|
||||
二、WinBoLL APP 核心信息
|
||||
|
||||
1. 项目简介
|
||||
|
||||
WinBoLL Studio Android 应用开源项目,专注于手机端 Android 开发与多端编译同步。
|
||||
|
||||
2. 官方资源
|
||||
|
||||
- 官方网站:https://www.winboll.cc/
|
||||
- 源码地址:
|
||||
- Gitea:https://gitea.winboll.cc/Studio/WinBoLL.git
|
||||
- GitHub:https://github.com/ZhanGSKen/WinBoLL.git
|
||||
- 码云:https://gitee.com/zhangsken/winboll.git
|
||||
- 托管类库源码:
|
||||
- APPBase(jitpack.io):https://github.com/ZhanGSKen/APPBase.git
|
||||
- AES(jitpack.io):https://github.com/ZhanGSKen/AES.git
|
||||
|
||||
三、通用特征文件夹前置(/sdcard)
|
||||
|
||||
- Linux 系统文件夹直接使用 /sdcard 。
|
||||
- 手机 SD 卡存储( /storage/emulated/0 )挂载的别名也可为 /sdcard 。
|
||||
|
||||
四、前置条件
|
||||
|
||||
1. WinBoLL-APP 配置
|
||||
|
||||
- APK 编译输出目录: /sdcard/WinBoLLStudio/APKs/ ,以及 /sdcard/AppProjects/ (命名为 app.apk )
|
||||
- 签名与命名空间:支持应用签名验证定制化,与衍生 APP 共享 cc.winboll.studio 命名空间
|
||||
|
||||
五、核心需求规划
|
||||
|
||||
1. 主机端需求
|
||||
|
||||
- 支持 winboll.cc 域名的用户注册登录服务
|
||||
- 支持 https://console.winboll.cc/api 访问
|
||||
|
||||
2. APP 端需求
|
||||
|
||||
- 实现手机端 Android 应用开发与管理功能
|
||||
|
||||
六、编译与使用指南
|
||||
|
||||
1. 项目初始化(必须)
|
||||
|
||||
1. 复制 settings.gradle-demo 为 settings.gradle ,取消对应项目模块注释
|
||||
2. 复制 gradle.properties-androidx-demo 或 gradle.properties-android-demo 为 gradle.properties
|
||||
3. (可选)复制 local.properties-demo 为 local.properties ,配置 Android SDK 目录
|
||||
4. 签名设置:
|
||||
- 调试编译:进入 GenKeyStore 目录执行 bash gen_debug_keystore.sh
|
||||
- 非必须:clone keystore 模块,拷贝 appkey.jks 与 appkey.keystore 到项目根目录
|
||||
|
||||
2. 编译命令
|
||||
|
||||
(1)类库型项目
|
||||
|
||||
1. 修改测试项目 build.properties ,设置 libraryProject=<类库项目模块名>
|
||||
2. 编译测试项目: bash .winboll/bashPublishAPKAddTag.sh <应用项目模块名>
|
||||
3. 编译类库项目: bash .winboll/bashPublishLIBAddTag.sh <类库项目模块名> (发布至 WinBoLL Nexus Maven 库)
|
||||
|
||||
(2)应用型项目
|
||||
|
||||
- 编译命令: bash .winboll/bashPublishAPKAddTag.sh <应用项目模块名>
|
||||
|
||||
(3)调试编译
|
||||
|
||||
- Beta 调试: bash gradlew assembleBetaDebug
|
||||
- Stage 调试: bash gradlew assembleStageDebug
|
||||
|
||||
(4)发布编译
|
||||
|
||||
- Stage 发布:bash .winboll/bashPublishAPKAddTag.sh <应用项目模块名>
|
||||
或者执行 bash gradlew assembleStageRelease
|
||||
|
||||
3. 编译输出路径
|
||||
|
||||
- 默认路径(assembleBetaDebug任务): /sdcard/WinBoLLStudio/APKs/<项目根目录名称>/debug/
|
||||
- 默认路径(assembleStageRelease任务): /sdcard/WinBoLLStudio/APKs/<项目根目录名称>/tag/
|
||||
- 额外路径:若 winboll.properties 配置 ExtraAPKOutputPath ,APK 同步拷贝至该ExtraAPKOutputPath路径
|
||||
|
||||
4. 版本号命名规则
|
||||
|
||||
- Stage 渠道: V<应用开发环境编号><应用功能变更号><应用调试阶段号> (示例: APPBase_15.7.0 )
|
||||
- Beta 渠道: V<应用开发环境编号><应用功能变更号><应用调试阶段号>-beta<调试编译计数>_<调试编译时间(分钟+秒钟)> (示例: APPBase_15.9.6-beta8_5413 )
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#Created by .winboll/winboll_app_build.gradle
|
||||
#Fri Jan 23 03:11:07 HKT 2026
|
||||
stageCount=8
|
||||
#Sat Jan 31 13:02:06 GMT 2026
|
||||
stageCount=12
|
||||
libraryProject=libappbase
|
||||
baseVersion=15.15
|
||||
publishVersion=15.15.7
|
||||
buildCount=0
|
||||
baseBetaVersion=15.15.8
|
||||
publishVersion=15.15.11
|
||||
buildCount=29
|
||||
baseBetaVersion=15.15.12
|
||||
|
||||
@@ -13,6 +13,13 @@ import cc.winboll.studio.appbase.R;
|
||||
import cc.winboll.studio.libappbase.LogActivity;
|
||||
import cc.winboll.studio.libappbase.LogUtils;
|
||||
import cc.winboll.studio.libappbase.ToastUtils;
|
||||
import cc.winboll.studio.libappbase.activities.FTPBackupsActivity;
|
||||
import cc.winboll.studio.libappbase.dialogs.SFTPBackupsSettingsDialog;
|
||||
import cc.winboll.studio.libappbase.models.SFTPAuthModel;
|
||||
import java.util.HashMap;
|
||||
import android.os.Environment;
|
||||
import java.io.File;
|
||||
import cc.winboll.studio.appbase.model.TestBean;
|
||||
|
||||
/**
|
||||
* @Author ZhanGSKen<zhangsken@qq.com>
|
||||
@@ -36,13 +43,28 @@ public class MainActivity extends Activity {
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
ToastUtils.show("onCreate"); // 显示 Activity 创建提示(调试用)
|
||||
//ToastUtils.show("onCreate"); // 显示 Activity 创建提示(调试用)
|
||||
setContentView(R.layout.activity_main); // 加载主界面布局
|
||||
|
||||
// 初始化 Toolbar 并设置为 ActionBar
|
||||
mToolbar = findViewById(R.id.toolbar);
|
||||
setActionBar(mToolbar); // 将 Toolbar 替代系统默认 ActionBar
|
||||
|
||||
initTestData();
|
||||
}
|
||||
|
||||
void initTestData() {
|
||||
TestBean bean1 = new TestBean();
|
||||
bean1.setTestNum1(456);
|
||||
TestBean.saveBeanToFile(getFilesDir().getAbsolutePath() + getTestBeanRelativePath(), bean1);
|
||||
TestBean bean2 = new TestBean();
|
||||
bean2.setTestNum1(789);
|
||||
TestBean.saveBeanToFile(getExternalFilesDir(null).getAbsolutePath() + getTestBeanRelativePath(), bean2);
|
||||
}
|
||||
|
||||
String getTestBeanRelativePath() {
|
||||
return "/BaseBaen/"+TestBean.class.getName()+".json";
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建菜单时回调(加载工具栏菜单)
|
||||
@@ -136,12 +158,42 @@ public class MainActivity extends Activity {
|
||||
// 启动意图(唤起浏览器)
|
||||
context.startActivity(intent);
|
||||
}
|
||||
|
||||
|
||||
public void onAboutActivity(View view) {
|
||||
LogUtils.d(TAG, "startAboutActivity() 调用");
|
||||
LogUtils.d(TAG, "onAboutActivity() 调用");
|
||||
Intent aboutIntent = new Intent(getApplicationContext(), AboutActivity.class);
|
||||
startActivity(aboutIntent);
|
||||
LogUtils.d(TAG, "startAboutActivity: 关于页面已启动");
|
||||
}
|
||||
|
||||
public void onFTPBackupsActivity(View view) {
|
||||
LogUtils.d(TAG, "onFTPBackupsActivity() 调用");
|
||||
SFTPBackupsSettingsDialog dlg = new SFTPBackupsSettingsDialog(this);
|
||||
SFTPAuthModel authModel = SFTPBackupsSettingsDialog.getSFTPAuthModelFromSP(this);
|
||||
if (authModel == null) {
|
||||
dlg.show();
|
||||
} else {
|
||||
// 1. 构建应用Data目录待备份文件Map
|
||||
HashMap<String, String> dataFileMap = new HashMap<>();
|
||||
// 存入文件:key=唯一标识,value=应用Data目录下的相对路径
|
||||
dataFileMap.put(TestBean.class.getName() + ".json",
|
||||
getTestBeanRelativePath());
|
||||
// 构建SDCard目录待备份文件Map(与BackupUtils的SdcardMap泛型一致:String-String)
|
||||
HashMap<String, String> sdcardFileMap = new HashMap<>();
|
||||
// 存入文件:key=唯一标识,value=应用专属外部文件目录下的相对路径
|
||||
sdcardFileMap.put(TestBean.class.getName() + ".json",
|
||||
getTestBeanRelativePath());
|
||||
|
||||
// 2. 构建Intent,指定跳转到FTPBackupsActivity
|
||||
Intent ftpBackupsIntent = new Intent(getApplicationContext(), FTPBackupsActivity.class);
|
||||
// 3. 序列化传递Map参数(使用FTPBackupsActivity中定义的常量,避免硬编码)
|
||||
ftpBackupsIntent.putExtra(FTPBackupsActivity.EXTRA_DATA_DIR_FILE_MAP, dataFileMap);
|
||||
ftpBackupsIntent.putExtra(FTPBackupsActivity.EXTRA_SDCARD_DIR_FILE_MAP, sdcardFileMap);
|
||||
// 传递FTP上传目标目录参数,路径为/WinBoLLStudio/APPBackups/WinBoLL
|
||||
ftpBackupsIntent.putExtra(FTPBackupsActivity.EXTRA_FTP_TARGET_DIR, "/WinBoLLStudio/APPBackups/WinBoLL");
|
||||
|
||||
// 4. 启动Activity,参数自动透传
|
||||
startActivity(ftpBackupsIntent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package cc.winboll.studio.appbase.model;
|
||||
|
||||
import android.util.JsonReader;
|
||||
import android.util.JsonWriter;
|
||||
import cc.winboll.studio.libappbase.BaseBean;
|
||||
import cc.winboll.studio.libappbase.LogUtils;
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* 测试实体类
|
||||
* 继承BaseBean实现JSON序列化/反序列化能力,提供基础int类型属性的封装与数据持久化支持
|
||||
* 适配Java7语法,遵循BaseBean统一的反射识别、JSON读写规范
|
||||
* @Author 豆包&ZhanGSKen<zhangsken@qq.com>
|
||||
* @Date 2026/01/31 19:16:00
|
||||
* @LastEditTime 2026/02/01 10:46:00
|
||||
*/
|
||||
public class TestBean extends BaseBean {
|
||||
|
||||
// ====================================== 常量定义 ======================================
|
||||
/** 当前类的日志 TAG(用于调试输出) */
|
||||
public static final String TAG = "TestBean";
|
||||
|
||||
// ====================================== 成员属性 ======================================
|
||||
/**
|
||||
* 测试数字属性(默认值:123)
|
||||
* 基础int类型属性,用于测试BaseBean的JSON序列化/反序列化能力
|
||||
*/
|
||||
private int testNum1;
|
||||
|
||||
// ====================================== 构造方法 ======================================
|
||||
/**
|
||||
* 无参构造器(默认初始化)
|
||||
* 给testNum1赋值默认值123,满足反射实例化、JSON解析的无参构造要求
|
||||
*/
|
||||
public TestBean() {
|
||||
this.testNum1 = 123;
|
||||
LogUtils.d(TAG, "TestBean无参构造器调用,testNum1默认初始化值:" + this.testNum1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 有参构造器(自定义初始化)
|
||||
* @param testNum1 测试数字初始值
|
||||
*/
|
||||
public TestBean(int testNum1) {
|
||||
this.testNum1 = testNum1;
|
||||
LogUtils.d(TAG, "TestBean有参构造器调用,传入testNum1:" + testNum1);
|
||||
}
|
||||
|
||||
// ====================================== Get/Set 方法 ======================================
|
||||
/**
|
||||
* 设置测试数字属性值
|
||||
* @param testNum1 待设置的int类型值
|
||||
*/
|
||||
public void setTestNum1(int testNum1) {
|
||||
LogUtils.d(TAG, "setTestNum1调用,传入参数:" + testNum1);
|
||||
this.testNum1 = testNum1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取测试数字属性值
|
||||
* @return 当前testNum1的int类型值
|
||||
*/
|
||||
public int getTestNum1() {
|
||||
LogUtils.d(TAG, "getTestNum1调用,返回值:" + this.testNum1);
|
||||
return testNum1;
|
||||
}
|
||||
|
||||
// ====================================== 重写父类BaseBean方法 ======================================
|
||||
/**
|
||||
* 重写父类方法:获取当前类的全限定名
|
||||
* 用于BaseBean反射识别、类名匹配等统一逻辑
|
||||
* @return 类全限定名(cc.winboll.studio.appbase.model.TestBean)
|
||||
*/
|
||||
@Override
|
||||
public String getName() {
|
||||
LogUtils.d(TAG, "getName方法调用,返回类全限定名:" + TestBean.class.getName());
|
||||
return TestBean.class.getName();
|
||||
}
|
||||
|
||||
/**
|
||||
* 重写父类方法:将当前对象序列化为JSON(持久化存储专用)
|
||||
* 遵循BaseBean规范,先执行父类序列化逻辑,再处理子类专属字段
|
||||
* @param jsonWriter JSON写入器(外部传入的JSON流操作实例)
|
||||
* @throws IOException JSON写入异常(流关闭、格式错误等)
|
||||
*/
|
||||
@Override
|
||||
public void writeThisToJsonWriter(JsonWriter jsonWriter) throws IOException {
|
||||
LogUtils.d(TAG, "writeThisToJsonWriter调用,传入参数JsonWriter:" + jsonWriter);
|
||||
// 执行父类公共字段的序列化逻辑
|
||||
super.writeThisToJsonWriter(jsonWriter);
|
||||
// 序列化子类专属字段testNum1
|
||||
jsonWriter.name("testNum1").value(this.getTestNum1());
|
||||
LogUtils.d(TAG, "writeThisToJsonWriter执行完成,已序列化testNum1:" + this.getTestNum1());
|
||||
}
|
||||
|
||||
/**
|
||||
* 重写父类方法:从JSON字段初始化当前对象属性(解析JSON专用)
|
||||
* 先让父类处理公共字段,再匹配子类专属字段,不匹配则返回false跳过
|
||||
* @param jsonReader JSON读取器(外部传入的JSON流操作实例)
|
||||
* @param name 当前解析的JSON字段名
|
||||
* @return true-字段解析成功;false-字段不匹配,需跳过/父类处理
|
||||
* @throws IOException JSON读取异常(字段类型不匹配、流中断等)
|
||||
*/
|
||||
@Override
|
||||
public boolean initObjectsFromJsonReader(JsonReader jsonReader, String name) throws IOException {
|
||||
LogUtils.d(TAG, "initObjectsFromJsonReader调用,传入参数:name=" + name + ",JsonReader=" + jsonReader);
|
||||
// 父类优先处理公共字段,处理成功则直接返回
|
||||
if (super.initObjectsFromJsonReader(jsonReader, name)) {
|
||||
LogUtils.d(TAG, "initObjectsFromJsonReader:字段" + name + "由父类BaseBean处理成功");
|
||||
return true;
|
||||
}
|
||||
// 解析子类专属字段
|
||||
if ("testNum1".equals(name)) {
|
||||
this.setTestNum1(jsonReader.nextInt());
|
||||
LogUtils.d(TAG, "initObjectsFromJsonReader:解析testNum1成功,值为:" + this.getTestNum1());
|
||||
} else {
|
||||
LogUtils.w(TAG, "initObjectsFromJsonReader:字段" + name + "不匹配,返回false跳过解析");
|
||||
// 字段不匹配,返回false表示跳过
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重写父类方法:从JSON读取器完整解析并初始化当前对象(JSON解析入口)
|
||||
* 负责JSON对象的开始/结束标识处理,遍历所有字段并调用字段解析方法
|
||||
* 严格遵循writeThisToJsonWriter的序列化结构,保证解析一致性
|
||||
* @param jsonReader JSON读取器(外部传入的JSON流操作实例)
|
||||
* @return 解析后的当前TestBean实例(支持链式调用)
|
||||
* @throws IOException JSON解析异常(格式错误、字段缺失、流异常等)
|
||||
*/
|
||||
@Override
|
||||
public BaseBean readBeanFromJsonReader(JsonReader jsonReader) throws IOException {
|
||||
LogUtils.d(TAG, "readBeanFromJsonReader调用,传入参数JsonReader:" + jsonReader);
|
||||
// 开始解析JSON对象,与序列化结构保持一致
|
||||
jsonReader.beginObject();
|
||||
// 遍历所有JSON字段
|
||||
while (jsonReader.hasNext()) {
|
||||
String fieldName = jsonReader.nextName();
|
||||
LogUtils.d(TAG, "readBeanFromJsonReader:开始解析字段,fieldName=" + fieldName);
|
||||
// 解析字段,不匹配则跳过该值
|
||||
if (!this.initObjectsFromJsonReader(jsonReader, fieldName)) {
|
||||
jsonReader.skipValue();
|
||||
LogUtils.w(TAG, "readBeanFromJsonReader:字段" + fieldName + "解析失败,已跳过该值");
|
||||
}
|
||||
}
|
||||
// 结束JSON对象解析,必须调用避免流异常
|
||||
jsonReader.endObject();
|
||||
LogUtils.d(TAG, "readBeanFromJsonReader执行完成,JSON解析结束,当前TestBean实例testNum1:" + this.getTestNum1());
|
||||
// 返回当前实例,支持链式调用
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,18 @@
|
||||
android:onClick="onToastUtilsTest"
|
||||
android:layout_margin="10dp"/>
|
||||
|
||||
<Button
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="FTP应用备份"
|
||||
android:textSize="16sp"
|
||||
android:textColor="@android:color/white"
|
||||
android:background="#81C7F5"
|
||||
android:paddingVertical="12dp"
|
||||
android:layout_marginHorizontal="24dp"
|
||||
android:onClick="onFTPBackupsActivity"
|
||||
android:layout_margin="10dp"/>
|
||||
|
||||
<Button
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
@@ -70,7 +82,7 @@
|
||||
android:layout_marginHorizontal="24dp"
|
||||
android:onClick="onAboutActivity"
|
||||
android:layout_margin="10dp"/>
|
||||
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</ScrollView>
|
||||
|
||||
@@ -27,5 +27,8 @@ dependencies {
|
||||
api 'com.google.code.gson:gson:2.8.9'
|
||||
// Html 解析
|
||||
api 'org.jsoup:jsoup:1.13.1'
|
||||
// 添加JSch依赖(SFTP核心,com.jcraft:jsch:0.1.54)
|
||||
api 'com.jcraft:jsch:0.1.54'
|
||||
|
||||
api fileTree(dir: 'libs', include: ['*.jar'])
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#Created by .winboll/winboll_app_build.gradle
|
||||
#Fri Jan 23 03:11:07 HKT 2026
|
||||
stageCount=8
|
||||
#Sat Jan 31 13:02:06 GMT 2026
|
||||
stageCount=12
|
||||
libraryProject=libappbase
|
||||
baseVersion=15.15
|
||||
publishVersion=15.15.7
|
||||
buildCount=0
|
||||
baseBetaVersion=15.15.8
|
||||
publishVersion=15.15.11
|
||||
buildCount=29
|
||||
baseBetaVersion=15.15.12
|
||||
|
||||
@@ -6,8 +6,18 @@
|
||||
<!-- 拥有完全的网络访问权限 -->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
|
||||
<!-- 读取您共享存储空间中的内容 -->
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
|
||||
<!-- 修改或删除您共享存储空间中的内容 -->
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||
|
||||
<!-- MANAGE_EXTERNAL_STORAGE -->
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
|
||||
|
||||
<application
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
|
||||
<activity
|
||||
android:name=".CrashHandler$CrashActivity"
|
||||
@@ -34,6 +44,8 @@
|
||||
|
||||
<activity android:name="cc.winboll.studio.libappbase.activities.NfcRsaLoginActivity"/>
|
||||
|
||||
<activity android:name="cc.winboll.studio.libappbase.activities.FTPBackupsActivity"/>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
</manifest>
|
||||
@@ -0,0 +1,262 @@
|
||||
package cc.winboll.studio.libappbase.activities;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.view.View;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import cc.winboll.studio.libappbase.LogUtils;
|
||||
import cc.winboll.studio.libappbase.R;
|
||||
import cc.winboll.studio.libappbase.ToastUtils;
|
||||
import cc.winboll.studio.libappbase.models.SFTPAuthModel;
|
||||
import cc.winboll.studio.libappbase.utils.BackupUtils;
|
||||
import cc.winboll.studio.libappbase.utils.FTPUtils;
|
||||
import cc.winboll.studio.libappbase.dialogs.SFTPBackupsSettingsDialog;
|
||||
|
||||
/**
|
||||
* BackupUtils 调用实例
|
||||
* 支持Intent传入双Map参数+FTP上传目标目录,初始化BackupUtils的待备份文件列表和上传路径
|
||||
* 强制校验FTP上传目录参数,未传参时直接提示不执行备份
|
||||
* @Author 豆包&ZhanGSKen<zhangsken@qq.com>
|
||||
* @Date 2026/01/30 20:55
|
||||
* @LastEditTime 2026/02/01 11:00
|
||||
*/
|
||||
public class FTPBackupsActivity extends Activity {
|
||||
|
||||
public static final String TAG = "FTPBackupsActivity";
|
||||
// 主线程Handler:子线程更新UI专用
|
||||
private Handler mMainHandler;
|
||||
// FTP服务器上传目标目录-默认值(移除,改为强制传参,无默认值)
|
||||
private static final String DEFAULT_FTP_TARGET_DIR = null;
|
||||
|
||||
// ==================== Intent传参常量(规范外部调用)====================
|
||||
/**
|
||||
* Intent传入参数-Data目录待备份文件Map
|
||||
* 类型:HashMap<String,String> 实现Serializable接口
|
||||
*/
|
||||
public static final String EXTRA_DATA_DIR_FILE_MAP = "extra_data_dir_file_map";
|
||||
/**
|
||||
* Intent传入参数-SDCard目录待备份文件Map
|
||||
* 类型:HashMap<String,String> 实现Serializable接口
|
||||
*/
|
||||
public static final String EXTRA_SDCARD_DIR_FILE_MAP = "extra_sdcard_dir_file_map";
|
||||
/**
|
||||
* Intent传入参数-FTP/SFTP服务器上传目标目录【强制传参】
|
||||
* 类型:String 示例:/backup/app/ ,必须传参否则不执行备份
|
||||
*/
|
||||
public static final String EXTRA_FTP_TARGET_DIR = "extra_ftp_target_dir";
|
||||
|
||||
// 解析后的参数(用于初始化BackupUtils)
|
||||
private Map<String, String> mDataDirFileMap;
|
||||
private Map<String, String> mSdcardDirFileMap;
|
||||
private String mFtpTargetDir; // 解析后的FTP上传目标目录(强制非空)
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_ftp_backups);
|
||||
// 初始化主线程Handler,避免子线程更新UI崩溃
|
||||
mMainHandler = new Handler(Looper.getMainLooper());
|
||||
// 解析Intent中的所有参数(双Map+FTP上传目录)
|
||||
parseIntentParams();
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析Intent传入的所有序列化参数
|
||||
* 兜底处理:双Map解析失败初始化空Map,FTP目录**未传/空则直接赋值null,不兜底默认值**
|
||||
*/
|
||||
private void parseIntentParams() {
|
||||
Intent intent = getIntent();
|
||||
if (intent == null) {
|
||||
LogUtils.w(TAG, "parseIntentParams:Intent为null,参数解析失败");
|
||||
initEmptyParams();
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. 解析Data目录Map:强转为HashMap,失败则初始化空Map
|
||||
Serializable dataMapSer = intent.getSerializableExtra(EXTRA_DATA_DIR_FILE_MAP);
|
||||
if (dataMapSer instanceof HashMap) {
|
||||
mDataDirFileMap = (HashMap<String, String>) dataMapSer;
|
||||
LogUtils.d(TAG, "Intent解析Data目录Map成功,共" + mDataDirFileMap.size() + "个文件");
|
||||
} else {
|
||||
mDataDirFileMap = new HashMap<>();
|
||||
LogUtils.d(TAG, "Intent未解析到有效Data目录Map,初始化空Map");
|
||||
}
|
||||
|
||||
// 2. 解析SDCard目录Map:强转为HashMap,失败则初始化空Map
|
||||
Serializable sdcardMapSer = intent.getSerializableExtra(EXTRA_SDCARD_DIR_FILE_MAP);
|
||||
if (sdcardMapSer instanceof HashMap) {
|
||||
mSdcardDirFileMap = (HashMap<String, String>) sdcardMapSer;
|
||||
LogUtils.d(TAG, "Intent解析SDCard目录Map成功,共" + mSdcardDirFileMap.size() + "个文件");
|
||||
} else {
|
||||
mSdcardDirFileMap = new HashMap<>();
|
||||
LogUtils.d(TAG, "Intent未解析到有效SDCard目录Map,初始化空Map");
|
||||
}
|
||||
|
||||
// 3. 解析FTP上传目标目录:**强制校验,未传/空/空格则直接赋值null,不兜底**
|
||||
String inputFtpDir = intent.getStringExtra(EXTRA_FTP_TARGET_DIR);
|
||||
if (inputFtpDir != null && !inputFtpDir.trim().isEmpty()) {
|
||||
mFtpTargetDir = inputFtpDir.trim().endsWith("/") ? inputFtpDir.trim() : inputFtpDir.trim() + "/";
|
||||
LogUtils.d(TAG, "Intent解析FTP上传目录成功:" + mFtpTargetDir);
|
||||
} else {
|
||||
mFtpTargetDir = null;
|
||||
LogUtils.w(TAG, "Intent未传递/传递空的FTP上传目录参数,禁止执行备份");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化空参数:Intent为null时使用,FTP目录直接赋值null
|
||||
*/
|
||||
private void initEmptyParams() {
|
||||
mDataDirFileMap = new HashMap<>();
|
||||
mSdcardDirFileMap = new HashMap<>();
|
||||
mFtpTargetDir = null;
|
||||
}
|
||||
|
||||
public void onSFTPSettings(View view) {
|
||||
SFTPBackupsSettingsDialog dlg = new SFTPBackupsSettingsDialog(this);
|
||||
dlg.show();
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击事件:执行FTP备份(核心调用方法)
|
||||
* 【强制校验】未传递FTP_TARGET_DIR参数时,仅吐司提示,不执行任何操作
|
||||
* 每次点击都重新解析Intent参数,保证参数最新
|
||||
*/
|
||||
public void onBackups(View view) {
|
||||
// 1. 重新解析参数,保证最新
|
||||
parseIntentParams();
|
||||
// 2. 强制核心校验:FTP上传目录未传参,直接吐司提示,不执行后续操作
|
||||
if (mFtpTargetDir == null) {
|
||||
ToastUtils.show("备份失败:未设置FTP上传目标目录参数");
|
||||
LogUtils.e(TAG, "触发备份操作失败:Intent未传递EXTRA_FTP_TARGET_DIR参数");
|
||||
return;
|
||||
}
|
||||
// 3. 校验SFTP配置,未配置则弹框
|
||||
SFTPAuthModel authModel = SFTPBackupsSettingsDialog.getSFTPAuthModelFromSP(this);
|
||||
if (authModel == null) {
|
||||
ToastUtils.show("备份失败:请先配置SFTP服务器信息");
|
||||
new SFTPBackupsSettingsDialog(this).show();
|
||||
LogUtils.w(TAG, "触发备份操作失败:未配置SFTP服务器信息");
|
||||
return;
|
||||
}
|
||||
// 4. 所有校验通过,提示并执行备份
|
||||
ToastUtils.show("开始执行FTP备份,请勿退出页面...");
|
||||
LogUtils.d(TAG, "所有校验通过,开启子线程执行备份核心逻辑");
|
||||
doBackups(authModel, mDataDirFileMap, mSdcardDirFileMap, mFtpTargetDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* 核心备份逻辑:子线程执行(仅在校验通过后调用)
|
||||
* @param authModel SFTP认证模型
|
||||
* @param dataDirFileMap Intent解析的Data目录文件Map
|
||||
* @param sdcardDirFileMap Intent解析的SDCard目录文件Map
|
||||
* @param ftpTargetDir Intent解析的FTP上传目标目录(已标准化、非空)
|
||||
*/
|
||||
void doBackups(final SFTPAuthModel authModel, final Map<String, String> dataDirFileMap,
|
||||
final Map<String, String> sdcardDirFileMap, final String ftpTargetDir) {
|
||||
// 所有BackupUtils操作放入子线程,规避网络/IO主线程异常
|
||||
new Thread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
try {
|
||||
// 初始化BackupUtils单例,透传动态解析的FTP上传目录
|
||||
BackupUtils backupUtils = BackupUtils.getInstance(
|
||||
getApplicationContext(),
|
||||
authModel,
|
||||
ftpTargetDir
|
||||
);
|
||||
|
||||
// 向BackupUtils添加解析后的待备份文件(双Map)
|
||||
addBackupFilesToUtils(backupUtils, dataDirFileMap, sdcardDirFileMap);
|
||||
|
||||
LogUtils.d(TAG, "待备份文件初始化完成 → Data目录:" + backupUtils.getAllDataDirFiles().size() + "个 | SDCard目录:" + backupUtils.getAllSdcardFiles().size() + "个 | 上传目录:" + ftpTargetDir);
|
||||
|
||||
// 核心执行:打包为ZIP + SFTP上传(内部已实现登录→打包→上传→登出)
|
||||
boolean isSuccess = backupUtils.packAndUploadByFtp();
|
||||
|
||||
// 主线程反馈执行结果
|
||||
if (isSuccess) {
|
||||
updateUi("FTP备份成功!文件已打包为ZIP上传至服务器:" + ftpTargetDir);
|
||||
LogUtils.i(TAG, "FTP备份全流程执行成功,上传目录:" + ftpTargetDir);
|
||||
} else {
|
||||
updateUi("FTP备份失败!请检查服务器配置或文件路径");
|
||||
LogUtils.e(TAG, "FTP备份全流程执行失败,上传目录:" + ftpTargetDir);
|
||||
}
|
||||
|
||||
} catch (IllegalArgumentException e) {
|
||||
// 捕获初始化参数异常
|
||||
LogUtils.e(TAG, "BackupUtils初始化失败:" + e.getMessage(), e);
|
||||
updateUi("备份初始化失败:" + e.getMessage());
|
||||
} catch (Exception e) {
|
||||
// 捕获所有执行异常
|
||||
LogUtils.e(TAG, "FTP备份执行异常:" + e.getMessage(), e);
|
||||
updateUi("备份执行失败:" + e.getMessage());
|
||||
} finally {
|
||||
// 仅保留FTP断连,BackupUtils内部已兜底登出/断连,此处做双重保障
|
||||
FTPUtils.getInstance().disconnect();
|
||||
// 关键日志:明确仅删除临时ZIP包,不修改/删除源文件
|
||||
LogUtils.d(TAG, "备份流程结束:仅删除本地临时ZIP打包文件,待备份源文件未做任何修改/删除");
|
||||
}
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
/**
|
||||
* 向BackupUtils添加待备份文件:遍历双Map,调用add方法逐个添加
|
||||
* 先清空原有文件,避免Activity复用导致文件重复
|
||||
* @param backupUtils BackupUtils实例
|
||||
* @param dataMap Data目录待备份文件Map
|
||||
* @param sdcardMap SDCard目录待备份文件Map
|
||||
*/
|
||||
private void addBackupFilesToUtils(BackupUtils backupUtils, Map<String, String> dataMap, Map<String, String> sdcardMap) {
|
||||
backupUtils.clearDataDirFiles();
|
||||
backupUtils.clearSdcardFiles();
|
||||
// 添加Data目录文件
|
||||
for (Map.Entry<String, String> entry : dataMap.entrySet()) {
|
||||
backupUtils.addDataDirFile(entry.getKey(), entry.getValue());
|
||||
}
|
||||
// 添加SDCard目录文件
|
||||
for (Map.Entry<String, String> entry : sdcardMap.entrySet()) {
|
||||
backupUtils.addSdcardFile(entry.getKey(), entry.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 子线程更新UI工具方法(Toast提示)
|
||||
* @param msg 提示信息
|
||||
*/
|
||||
private void updateUi(final String msg) {
|
||||
mMainHandler.post(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
ToastUtils.show(msg);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面销毁:释放资源,避免内存泄漏
|
||||
* 仅释放本地资源,不清空Map/重复断连FTP
|
||||
*/
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
super.onDestroy();
|
||||
// 移除Handler未执行的任务
|
||||
if (mMainHandler != null) {
|
||||
mMainHandler.removeCallbacksAndMessages(null);
|
||||
}
|
||||
// 清空本地引用,协助GC
|
||||
mDataDirFileMap = null;
|
||||
mSdcardDirFileMap = null;
|
||||
mFtpTargetDir = null;
|
||||
LogUtils.d(TAG, "FTPBackupsActivity销毁:已释放本地资源,不影响二次备份初始化");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package cc.winboll.studio.libappbase.dialogs;
|
||||
|
||||
import android.app.Dialog;
|
||||
import android.content.Context;
|
||||
import android.graphics.Color;
|
||||
import android.os.Bundle;
|
||||
import android.widget.EditText;
|
||||
import android.widget.TextView;
|
||||
|
||||
import cc.winboll.studio.libappbase.LogUtils;
|
||||
import cc.winboll.studio.libappbase.R;
|
||||
import cc.winboll.studio.libappbase.ToastUtils;
|
||||
import cc.winboll.studio.libappbase.utils.APPUtils;
|
||||
import cc.winboll.studio.libappbase.utils.ApkSignUtils;
|
||||
|
||||
/**
|
||||
* @Author 豆包&ZhanGSKen<zhangsken@qq.com>
|
||||
* @CreateTime 2026-01-20 21:20:00
|
||||
* @LastEditTime 2026-01-24 18:45:00
|
||||
* @Describe 签名显示+正版校验对话框:展示应用签名字节位信息,调用网络接口完成正版合法性校验,实时返回校验结果
|
||||
*/
|
||||
public class AppValidationDialog extends Dialog {
|
||||
// ===================================== 全局常量 =====================================
|
||||
public static final String TAG = "AppValidationDialog";
|
||||
// 签名字节位分组大小
|
||||
private static final int BIT_GROUP_SIZE = 16;
|
||||
|
||||
// ===================================== 控件与上下文属性 =====================================
|
||||
private Context mContext;
|
||||
private EditText etSignFingerprint;
|
||||
private TextView tvAuthResult;
|
||||
|
||||
// ===================================== 业务入参属性 =====================================
|
||||
private String projectName;
|
||||
private String versionName;
|
||||
private String clientSign;
|
||||
private String clientHash;
|
||||
|
||||
// ===================================== 构造方法 =====================================
|
||||
public AppValidationDialog(Context context, String projectName, String versionName) {
|
||||
super(context, R.style.DialogStyle);
|
||||
this.mContext = context;
|
||||
this.projectName = projectName;
|
||||
this.versionName = versionName;
|
||||
LogUtils.d(TAG, "AppValidationDialog: 构造方法初始化,入参-> projectName=" + projectName + ", versionName=" + versionName);
|
||||
}
|
||||
|
||||
// ===================================== 生命周期方法 =====================================
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
LogUtils.d(TAG, "onCreate: 对话框创建,开始初始化布局与业务逻辑");
|
||||
setContentView(R.layout.dialog_sign_get);
|
||||
setCancelable(true);
|
||||
// 初始化应用签名与哈希
|
||||
initSignAndHash();
|
||||
// 初始化页面控件
|
||||
initView();
|
||||
// 执行签名展示与正版校验
|
||||
doSignShowAndAuthCheck();
|
||||
LogUtils.d(TAG, "onCreate: 对话框初始化流程执行完成");
|
||||
}
|
||||
|
||||
// ===================================== 页面与数据初始化方法 =====================================
|
||||
/**
|
||||
* 初始化页面控件,绑定视图并设置基础属性
|
||||
*/
|
||||
private void initView() {
|
||||
LogUtils.d(TAG, "initView: 开始初始化页面控件");
|
||||
etSignFingerprint = findViewById(R.id.et_sign_fingerprint);
|
||||
tvAuthResult = findViewById(R.id.tv_auth_result);
|
||||
// 签名显示框设为只读,方便用户复制
|
||||
etSignFingerprint.setEnabled(false);
|
||||
// 填充签名字节位信息
|
||||
etSignFingerprint.setText(convertSignToBitArrayWithWrap(clientSign));
|
||||
LogUtils.d(TAG, "initView: 控件初始化完成,已填充签名字节位信息");
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化应用签名与SHA256哈希,调用工具类获取与服务端对齐的参数
|
||||
*/
|
||||
private void initSignAndHash() {
|
||||
LogUtils.d(TAG, "initSignAndHash: 开始获取应用签名与SHA256哈希");
|
||||
this.clientSign = ApkSignUtils.getApkSignAlignedWithServer(mContext);
|
||||
this.clientHash = ApkSignUtils.getApkSHA256Hash(mContext);
|
||||
LogUtils.d(TAG, "initSignAndHash: 签名与哈希获取完成-> clientSign=" + clientSign + ", clientHash=" + clientHash);
|
||||
}
|
||||
|
||||
// ===================================== 核心业务方法 =====================================
|
||||
/**
|
||||
* 核心业务:展示签名字节位信息,发起网络正版校验请求
|
||||
*/
|
||||
private void doSignShowAndAuthCheck() {
|
||||
LogUtils.d(TAG, "doSignShowAndAuthCheck: 开始执行应用正版合法性校验");
|
||||
// 校验签名与哈希非空,避免空参请求
|
||||
if (clientSign == null || clientHash == null) {
|
||||
String errorMsg = "应用签名或哈希获取失败,无法执行正版校验";
|
||||
LogUtils.e(TAG, "doSignShowAndAuthCheck: " + errorMsg);
|
||||
tvAuthResult.setTextColor(Color.RED);
|
||||
tvAuthResult.setText(errorMsg);
|
||||
ToastUtils.show(errorMsg);
|
||||
return;
|
||||
}
|
||||
// 调用网络校验接口
|
||||
new APPUtils().checkAPKValidation(
|
||||
mContext,
|
||||
projectName,
|
||||
versionName,
|
||||
clientSign,
|
||||
clientHash,
|
||||
new APPUtils.CheckResultCallback() {
|
||||
@Override
|
||||
public void onResult(boolean isValid, String message) {
|
||||
LogUtils.d(TAG, "checkAPKValidation: 校验结果返回-> isValid=" + isValid + ", message=" + message);
|
||||
handleAuthResult(isValid, message);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理正版校验结果,更新UI并提示用户
|
||||
* @param isValid 校验是否通过
|
||||
* @param message 服务端返回提示信息
|
||||
*/
|
||||
private void handleAuthResult(boolean isValid, String message) {
|
||||
String showMessage;
|
||||
if (isValid) {
|
||||
showMessage = "< 这是正版的 WinBoLL 应用,请放心使用。 >";
|
||||
tvAuthResult.setTextColor(Color.BLUE);
|
||||
LogUtils.d(TAG, "handleAuthResult: 正版校验通过," + showMessage + ",服务端信息:" + message);
|
||||
} else {
|
||||
showMessage = "< 您使用的可能不是正版的 WinBoLL 应用。 >";
|
||||
tvAuthResult.setTextColor(Color.RED);
|
||||
LogUtils.e(TAG, "handleAuthResult: 正版校验失败," + showMessage + ",失败原因:" + message);
|
||||
}
|
||||
// 更新UI并弹提示
|
||||
tvAuthResult.setText(showMessage);
|
||||
ToastUtils.show(showMessage);
|
||||
}
|
||||
|
||||
// ===================================== 工具方法 =====================================
|
||||
/**
|
||||
* 签名字符串转0/1比特数组格式:每2个bit加空格,每16位换行,提升可读性
|
||||
* @param signStr 原始签名字符串
|
||||
* @return 格式化后的比特数字符串,签名字符为空返回空串
|
||||
*/
|
||||
private String convertSignToBitArrayWithWrap(String signStr) {
|
||||
LogUtils.d(TAG, "convertSignToBitArrayWithWrap: 开始格式化签名字符串为比特数组");
|
||||
if (signStr == null || signStr.isEmpty()) {
|
||||
LogUtils.w(TAG, "convertSignToBitArrayWithWrap: 原始签名字符串为空,返回空串");
|
||||
return "";
|
||||
}
|
||||
// 字符转8位补零的二进制字符串
|
||||
StringBuilder bitBuilder = new StringBuilder();
|
||||
for (char c : signStr.toCharArray()) {
|
||||
String bit8 = String.format("%8s", Integer.toBinaryString(c)).replace(' ', '0');
|
||||
bitBuilder.append(bit8);
|
||||
}
|
||||
String fullBitStr = bitBuilder.toString();
|
||||
LogUtils.d(TAG, "convertSignToBitArrayWithWrap: 签名转二进制完成,总长度=" + fullBitStr.length() + "bit");
|
||||
|
||||
// 按16位分组,组内每2bit加空格,分组后换行
|
||||
StringBuilder finalBuilder = new StringBuilder();
|
||||
for (int i = 0; i < fullBitStr.length(); i += BIT_GROUP_SIZE) {
|
||||
int end = Math.min(i + BIT_GROUP_SIZE, fullBitStr.length());
|
||||
String group = fullBitStr.substring(i, end);
|
||||
// 组内加空格
|
||||
StringBuilder groupWithSpace = new StringBuilder();
|
||||
for (int j = 0; j < group.length(); j++) {
|
||||
groupWithSpace.append(group.charAt(j));
|
||||
if ((j + 1) % 2 == 0 && j != group.length() - 1) {
|
||||
groupWithSpace.append(" ");
|
||||
}
|
||||
}
|
||||
finalBuilder.append(groupWithSpace);
|
||||
// 最后一组不换行
|
||||
if (end < fullBitStr.length()) {
|
||||
finalBuilder.append("\n");
|
||||
}
|
||||
}
|
||||
LogUtils.d(TAG, "convertSignToBitArrayWithWrap: 签名比特数组格式化完成");
|
||||
return finalBuilder.toString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
package cc.winboll.studio.libappbase.dialogs;
|
||||
|
||||
import android.app.AlertDialog;
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.text.TextUtils;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.widget.Button;
|
||||
import android.widget.CheckBox;
|
||||
import android.widget.EditText;
|
||||
import android.widget.Toast;
|
||||
|
||||
import cc.winboll.studio.libappbase.LogUtils;
|
||||
import cc.winboll.studio.libappbase.R;
|
||||
import cc.winboll.studio.libappbase.models.SFTPAuthModel;
|
||||
|
||||
/**
|
||||
* SFTP备份设置对话框
|
||||
* 功能:1.布局式UI展示SFTP配置项 2.SP持久化存储SFTPAuthModel模型数据 3.提供公开静态方法供外部读取/清空SP配置
|
||||
* 存储模式:SharedPreferences私有模式,仅本应用可访问
|
||||
* UI实现:通过XML布局文件自定义控件,支持账号密码/秘钥双登录方式配置
|
||||
* @Author 豆包&ZhanGSKen<zhangsken@qq.com>
|
||||
* @Date 2026/01/31 12:36:00
|
||||
* @LastEditTime 2026/01/31 21:58:00
|
||||
*/
|
||||
public class SFTPBackupsSettingsDialog {
|
||||
// ************************ 常量属性 ************************
|
||||
public static final String TAG = "SFTPBackupsSettingsDialog";
|
||||
// SP存储核心常量
|
||||
private static final String SP_NAME = "sftp_backup_settings_sp";
|
||||
private static final String SP_KEY_PREFIX = "sftp_backup_";
|
||||
// SP各字段存储Key
|
||||
private static final String KEY_SERVER = SP_KEY_PREFIX + "server";
|
||||
private static final String KEY_PORT = SP_KEY_PREFIX + "port";
|
||||
private static final String KEY_USERNAME = SP_KEY_PREFIX + "username";
|
||||
private static final String KEY_PASSWORD = SP_KEY_PREFIX + "password";
|
||||
private static final String KEY_KEY_PATH = SP_KEY_PREFIX + "key_path";
|
||||
private static final String KEY_KEY_PWD = SP_KEY_PREFIX + "key_pwd";
|
||||
private static final String KEY_ACTIVE_MODE = SP_KEY_PREFIX + "active_mode";
|
||||
private static final String KEY_CHARSET = SP_KEY_PREFIX + "charset";
|
||||
// SFTP默认配置常量(抽离便于维护)
|
||||
private static final int SFTP_DEFAULT_PORT = 22;
|
||||
private static final String SFTP_DEFAULT_CHARSET = "UTF-8";
|
||||
|
||||
// ************************ 成员属性 ************************
|
||||
// 上下文与对话框核心实例
|
||||
private final Context mContext;
|
||||
private AlertDialog mDialog;
|
||||
// 布局控件实例(按配置模块排序,便于查找)
|
||||
private EditText etServer;
|
||||
private EditText etPort;
|
||||
private EditText etUsername;
|
||||
private EditText etPwd;
|
||||
private EditText etKeyPath;
|
||||
private EditText etKeyPwd;
|
||||
private EditText etCharset;
|
||||
private CheckBox cbActiveMode;
|
||||
private Button btnSave;
|
||||
private Button btnCancel;
|
||||
private Button btnClear;
|
||||
|
||||
// ************************ 构造方法 ************************
|
||||
/**
|
||||
* 构造方法:初始化对话框核心依赖与流程
|
||||
* @param context 上下文(推荐Application/Activity,避免内存泄漏)
|
||||
*/
|
||||
public SFTPBackupsSettingsDialog(Context context) {
|
||||
LogUtils.d(TAG, "SFTPBackupsSettingsDialog构造方法调用,传入上下文:" + context.getClass().getSimpleName());
|
||||
this.mContext = context;
|
||||
initDialog();
|
||||
initView();
|
||||
initData();
|
||||
initListener();
|
||||
LogUtils.d(TAG, "SFTPBackupsSettingsDialog初始化全流程执行完成");
|
||||
}
|
||||
|
||||
// ************************ 公共方法 ************************
|
||||
/**
|
||||
* 显示SFTP配置对话框(做非空/非显示校验,避免重复显示)
|
||||
*/
|
||||
public void show() {
|
||||
LogUtils.d(TAG, "show()方法调用,对话框当前状态:" + (mDialog == null ? "未初始化" : mDialog.isShowing() ? "已显示" : "未显示"));
|
||||
if (mDialog != null && !mDialog.isShowing()) {
|
||||
mDialog.show();
|
||||
LogUtils.i(TAG, "SFTP配置对话框显示成功");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 【公开静态方法】从SP读取SFTPAuthModel配置模型
|
||||
* 外部可直接调用,无有效配置(服务器地址空)返回null
|
||||
* @param context 上下文
|
||||
* @return 已存储的配置模型/无有效配置返回null
|
||||
*/
|
||||
public static SFTPAuthModel getSFTPAuthModelFromSP(Context context) {
|
||||
LogUtils.d(TAG, "getSFTPAuthModelFromSP()静态方法调用,传入上下文:" + (context == null ? "null" : context.getClass().getSimpleName()));
|
||||
try {
|
||||
// 前置非空校验,避免空指针
|
||||
if (context == null) {
|
||||
LogUtils.e(TAG, "getSFTPAuthModelFromSP()执行失败:传入上下文为null");
|
||||
return null;
|
||||
}
|
||||
SharedPreferences sp = context.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE);
|
||||
SFTPAuthModel model = new SFTPAuthModel();
|
||||
// 读取基础配置
|
||||
model.setFtpServer(sp.getString(KEY_SERVER, null));
|
||||
model.setFtpPort(sp.getInt(KEY_PORT, SFTP_DEFAULT_PORT));
|
||||
model.setFtpUsername(sp.getString(KEY_USERNAME, null));
|
||||
model.setFtpPassword(sp.getString(KEY_PASSWORD, null));
|
||||
// 读取秘钥配置
|
||||
model.setFtpKeyPath(sp.getString(KEY_KEY_PATH, null));
|
||||
model.setFtpKeyPwd(sp.getString(KEY_KEY_PWD, null));
|
||||
// 读取高级配置
|
||||
model.setFtpCharset(sp.getString(KEY_CHARSET, SFTP_DEFAULT_CHARSET));
|
||||
|
||||
// 核心有效校验:服务器地址为空则判定为无有效配置
|
||||
if (TextUtils.isEmpty(model.getFtpServer())) {
|
||||
LogUtils.w(TAG, "getSFTPAuthModelFromSP()执行完成:SP中无有效SFTP配置(服务器地址为空)");
|
||||
return null;
|
||||
}
|
||||
LogUtils.i(TAG, "getSFTPAuthModelFromSP()执行成功,读取到SFTP配置:" + model.getFtpServer() + ":" + model.getFtpPort());
|
||||
return model;
|
||||
} catch (Exception e) {
|
||||
LogUtils.e(TAG, "getSFTPAuthModelFromSP()执行异常:" + e.getMessage(), e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 【公开静态方法】清空SP中所有SFTP配置数据
|
||||
* 外部可直接调用,做前置非空校验避免崩溃
|
||||
* @param context 上下文
|
||||
*/
|
||||
public static void clearSFTPAuthModelFromSP(Context context) {
|
||||
LogUtils.d(TAG, "clearSFTPAuthModelFromSP()静态方法调用,传入上下文:" + (context == null ? "null" : context.getClass().getSimpleName()));
|
||||
try {
|
||||
if (context == null) {
|
||||
LogUtils.e(TAG, "clearSFTPAuthModelFromSP()执行失败:传入上下文为null");
|
||||
return;
|
||||
}
|
||||
SharedPreferences sp = context.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE);
|
||||
SharedPreferences.Editor editor = sp.edit();
|
||||
editor.clear();
|
||||
editor.commit();
|
||||
LogUtils.i(TAG, "clearSFTPAuthModelFromSP()执行成功:SP中SFTP配置已全部清空");
|
||||
} catch (Exception e) {
|
||||
LogUtils.e(TAG, "clearSFTPAuthModelFromSP()执行异常:" + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
// ************************ 私有初始化方法 ************************
|
||||
/**
|
||||
* 初始化对话框基础样式与布局绑定
|
||||
*/
|
||||
private void initDialog() {
|
||||
LogUtils.d(TAG, "initDialog()方法调用,开始初始化对话框基础样式");
|
||||
View dialogView = LayoutInflater.from(mContext).inflate(R.layout.dialog_sftp_backup_settings, null);
|
||||
AlertDialog.Builder builder = new AlertDialog.Builder(mContext);
|
||||
builder.setView(dialogView);
|
||||
builder.setCancelable(false); // 禁止外部点击/返回键关闭
|
||||
mDialog = builder.create();
|
||||
// 对话框样式适配,解决全屏/背景异常问题
|
||||
if (mDialog.getWindow() != null) {
|
||||
mDialog.getWindow().setBackgroundDrawableResource(android.R.color.white);
|
||||
}
|
||||
LogUtils.d(TAG, "initDialog()执行完成,对话框基础实例创建成功");
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化布局控件:绑定XML控件ID与Java实例(一一对应)
|
||||
*/
|
||||
private void initView() {
|
||||
LogUtils.d(TAG, "initView()方法调用,开始绑定布局控件实例");
|
||||
View rootView = mDialog.getLayoutInflater().inflate(R.layout.dialog_sftp_backup_settings, null);
|
||||
// 基础配置控件
|
||||
etServer = rootView.findViewById(R.id.et_sftp_server);
|
||||
etPort = rootView.findViewById(R.id.et_sftp_port);
|
||||
etUsername = rootView.findViewById(R.id.et_sftp_username);
|
||||
etPwd = rootView.findViewById(R.id.et_sftp_pwd);
|
||||
// 秘钥配置控件
|
||||
etKeyPath = rootView.findViewById(R.id.et_sftp_key_path);
|
||||
etKeyPwd = rootView.findViewById(R.id.et_sftp_key_pwd);
|
||||
// 高级配置控件
|
||||
etCharset = rootView.findViewById(R.id.et_sftp_charset);
|
||||
// 操作按钮控件
|
||||
btnSave = rootView.findViewById(R.id.btn_sftp_save);
|
||||
btnCancel = rootView.findViewById(R.id.btn_sftp_cancel);
|
||||
btnClear = rootView.findViewById(R.id.btn_sftp_clear);
|
||||
// 重新绑定对话框View,确保控件生效
|
||||
mDialog.setView(rootView);
|
||||
LogUtils.d(TAG, "initView()执行完成,所有布局控件绑定成功");
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化控件数据:从SP读取配置并回显到控件,无配置则设默认值
|
||||
*/
|
||||
private void initData() {
|
||||
LogUtils.d(TAG, "initData()方法调用,开始回显SP配置到控件");
|
||||
SFTPAuthModel model = getSFTPAuthModelFromSP(mContext);
|
||||
if (model == null) {
|
||||
LogUtils.w(TAG, "initData()执行提示:SP中无有效SFTP配置,控件设置默认值");
|
||||
// 无配置时设置默认值,提升用户体验
|
||||
etPort.setText(String.valueOf(SFTP_DEFAULT_PORT));
|
||||
etCharset.setText(SFTP_DEFAULT_CHARSET);
|
||||
return;
|
||||
}
|
||||
// 回显基础配置(做非空校验,避免空指针)
|
||||
etServer.setText(TextUtils.isEmpty(model.getFtpServer()) ? "" : model.getFtpServer());
|
||||
etPort.setText(model.getFtpPort() <= 0 ? String.valueOf(SFTP_DEFAULT_PORT) : String.valueOf(model.getFtpPort()));
|
||||
etUsername.setText(TextUtils.isEmpty(model.getFtpUsername()) ? "" : model.getFtpUsername());
|
||||
etPwd.setText(TextUtils.isEmpty(model.getFtpPassword()) ? "" : model.getFtpPassword());
|
||||
// 回显秘钥配置
|
||||
etKeyPath.setText(TextUtils.isEmpty(model.getFtpKeyPath()) ? "" : model.getFtpKeyPath());
|
||||
etKeyPwd.setText(TextUtils.isEmpty(model.getFtpKeyPwd()) ? "" : model.getFtpKeyPwd());
|
||||
// 回显高级配置
|
||||
etCharset.setText(TextUtils.isEmpty(model.getFtpCharset()) ? SFTP_DEFAULT_CHARSET : model.getFtpCharset());
|
||||
LogUtils.d(TAG, "initData()执行完成,SP配置已成功回显到所有控件");
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化控件监听:为所有按钮设置Java7原生点击事件(匿名内部类)
|
||||
*/
|
||||
private void initListener() {
|
||||
LogUtils.d(TAG, "initListener()方法调用,开始设置控件点击事件");
|
||||
// 保存按钮点击事件
|
||||
btnSave.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
onSaveClick();
|
||||
}
|
||||
});
|
||||
// 取消按钮点击事件
|
||||
btnCancel.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
onCancelClick();
|
||||
}
|
||||
});
|
||||
// 清空按钮点击事件
|
||||
btnClear.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
onClearClick();
|
||||
}
|
||||
});
|
||||
LogUtils.d(TAG, "initListener()执行完成,所有按钮点击事件设置成功");
|
||||
}
|
||||
|
||||
// ************************ 私有事件处理方法 ************************
|
||||
/**
|
||||
* 保存按钮点击事件处理:控件数据→模型封装→有效性校验→SP持久化
|
||||
*/
|
||||
private void onSaveClick() {
|
||||
LogUtils.d(TAG, "onSaveClick()方法调用,开始处理配置保存逻辑");
|
||||
SFTPAuthModel model = getModelFromView();
|
||||
if (checkModelValid(model)) {
|
||||
saveSFTPAuthModelToSP(mContext, model);
|
||||
Toast.makeText(mContext, "SFTP配置保存成功", Toast.LENGTH_SHORT).show();
|
||||
mDialog.dismiss();
|
||||
LogUtils.i(TAG, "SFTP配置保存成功,服务器配置:" + model.getFtpServer() + ":" + model.getFtpPort());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消按钮点击事件处理:直接关闭对话框,不做任何数据修改
|
||||
*/
|
||||
private void onCancelClick() {
|
||||
LogUtils.d(TAG, "onCancelClick()方法调用,开始处理取消配置逻辑");
|
||||
mDialog.dismiss();
|
||||
LogUtils.i(TAG, "SFTP配置对话框已关闭(用户主动取消)");
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空按钮点击事件处理:清空所有控件输入数据,恢复默认值(不修改SP)
|
||||
*/
|
||||
private void onClearClick() {
|
||||
LogUtils.d(TAG, "onClearClick()方法调用,开始处理控件数据清空逻辑");
|
||||
// 清空基础配置控件
|
||||
etServer.setText("");
|
||||
etPort.setText(String.valueOf(SFTP_DEFAULT_PORT));
|
||||
etUsername.setText("");
|
||||
etPwd.setText("");
|
||||
// 清空秘钥配置控件
|
||||
etKeyPath.setText("");
|
||||
etKeyPwd.setText("");
|
||||
// 清空高级配置控件
|
||||
cbActiveMode.setChecked(false);
|
||||
etCharset.setText(SFTP_DEFAULT_CHARSET);
|
||||
// 提示用户
|
||||
Toast.makeText(mContext, "控件数据已清空", Toast.LENGTH_SHORT).show();
|
||||
LogUtils.d(TAG, "onClearClick()执行完成,所有控件数据已恢复默认值");
|
||||
}
|
||||
|
||||
// ************************ 私有业务方法 ************************
|
||||
/**
|
||||
* 从控件读取输入数据,封装为SFTPAuthModel模型
|
||||
* 做基础非空处理,避免空字符串存入SP
|
||||
* @return 封装后的SFTP配置模型
|
||||
*/
|
||||
private SFTPAuthModel getModelFromView() {
|
||||
LogUtils.d(TAG, "getModelFromView()方法调用,开始从控件读取数据封装模型");
|
||||
SFTPAuthModel model = new SFTPAuthModel();
|
||||
// 读取基础配置(trim去空格,避免无效空格)
|
||||
model.setFtpServer(etServer.getText().toString().trim());
|
||||
String portStr = etPort.getText().toString().trim();
|
||||
model.setFtpPort(TextUtils.isEmpty(portStr) ? SFTP_DEFAULT_PORT : Integer.parseInt(portStr));
|
||||
model.setFtpUsername(etUsername.getText().toString().trim());
|
||||
model.setFtpPassword(etPwd.getText().toString().trim());
|
||||
// 读取秘钥配置
|
||||
model.setFtpKeyPath(etKeyPath.getText().toString().trim());
|
||||
model.setFtpKeyPwd(etKeyPwd.getText().toString().trim());
|
||||
// 读取高级配置
|
||||
model.setFtpCharset(TextUtils.isEmpty(etCharset.getText().toString().trim()) ? SFTP_DEFAULT_CHARSET : etCharset.getText().toString().trim());
|
||||
LogUtils.d(TAG, "getModelFromView()执行完成,控件数据已成功封装为SFTPAuthModel");
|
||||
return model;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验SFTP配置模型的有效性(核心必选参数校验)
|
||||
* 仅校验服务器地址和端口号,其他参数为可选
|
||||
* @param model 待校验的SFTP配置模型
|
||||
* @return true=配置有效 false=配置无效
|
||||
*/
|
||||
private boolean checkModelValid(SFTPAuthModel model) {
|
||||
LogUtils.d(TAG, "checkModelValid()方法调用,开始校验配置模型有效性");
|
||||
// 校验服务器地址(必选)
|
||||
if (TextUtils.isEmpty(model.getFtpServer())) {
|
||||
Toast.makeText(mContext, "请输入SFTP服务器地址", Toast.LENGTH_SHORT).show();
|
||||
LogUtils.w(TAG, "配置校验失败:SFTP服务器地址为空");
|
||||
return false;
|
||||
}
|
||||
// 校验端口号(必选,1-65535合法范围)
|
||||
if (model.getFtpPort() <= 0 || model.getFtpPort() > 65535) {
|
||||
Toast.makeText(mContext, "请输入有效的端口号(1-65535)", Toast.LENGTH_SHORT).show();
|
||||
LogUtils.w(TAG, "配置校验失败:SFTP端口号无效,当前值:" + model.getFtpPort());
|
||||
return false;
|
||||
}
|
||||
LogUtils.d(TAG, "checkModelValid()执行完成,SFTP配置模型核心参数校验通过");
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将SFTPAuthModel模型数据存入SP(私有方法,仅内部调用)
|
||||
* 使用commit同步提交,保证配置即时生效
|
||||
* @param context 上下文
|
||||
* @param model 待存储的SFTP配置模型
|
||||
*/
|
||||
private static void saveSFTPAuthModelToSP(Context context, SFTPAuthModel model) {
|
||||
LogUtils.d(TAG, "saveSFTPAuthModelToSP()方法调用,准备存储SFTP配置:" + model.getFtpServer() + ":" + model.getFtpPort());
|
||||
try {
|
||||
SharedPreferences sp = context.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE);
|
||||
SharedPreferences.Editor editor = sp.edit();
|
||||
// 存储基础配置
|
||||
editor.putString(KEY_SERVER, model.getFtpServer());
|
||||
editor.putInt(KEY_PORT, model.getFtpPort());
|
||||
editor.putString(KEY_USERNAME, model.getFtpUsername());
|
||||
editor.putString(KEY_PASSWORD, model.getFtpPassword());
|
||||
// 存储秘钥配置
|
||||
editor.putString(KEY_KEY_PATH, model.getFtpKeyPath());
|
||||
editor.putString(KEY_KEY_PWD, model.getFtpKeyPwd());
|
||||
// 存储高级配置
|
||||
editor.putString(KEY_CHARSET, model.getFtpCharset());
|
||||
// 同步提交,保证配置即时生效(区别于apply异步)
|
||||
editor.commit();
|
||||
LogUtils.d(TAG, "saveSFTPAuthModelToSP()执行完成,SFTP配置已成功存入SP");
|
||||
} catch (Exception e) {
|
||||
LogUtils.e(TAG, "saveSFTPAuthModelToSP()执行异常:" + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
package cc.winboll.studio.libappbase.dialogs;
|
||||
|
||||
import android.app.Dialog;
|
||||
import android.content.Context;
|
||||
import android.graphics.Color;
|
||||
import android.os.Bundle;
|
||||
import android.widget.EditText;
|
||||
import android.widget.TextView;
|
||||
import cc.winboll.studio.libappbase.LogUtils;
|
||||
import cc.winboll.studio.libappbase.R;
|
||||
import cc.winboll.studio.libappbase.ToastUtils;
|
||||
import cc.winboll.studio.libappbase.utils.APPUtils;
|
||||
import cc.winboll.studio.libappbase.utils.SignGetUtils;
|
||||
|
||||
/**
|
||||
* @Describe 签名显示+正版校验对话框
|
||||
* @Author 豆包&ZhanGSKen<zhangsken@qq.com>
|
||||
* @Date 2026/01/20 21:20:00
|
||||
* @LastEditTime 2026/01/21 11:00:00
|
||||
*/
|
||||
public class SignGetDialog extends Dialog {
|
||||
public static final String TAG = "SignGetDialog";
|
||||
private EditText etSignFingerprint;
|
||||
private TextView tvAuthResult;
|
||||
private Context mContext;
|
||||
|
||||
public SignGetDialog(Context context) {
|
||||
super(context, R.style.DialogStyle); // 适配默认对话框样式
|
||||
this.mContext = context;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.dialog_sign_get); // 绑定xml布局
|
||||
setCancelable(true); // 点击外部可关闭
|
||||
initView();
|
||||
initSignAndCheck(); // 获取签名+正版校验
|
||||
}
|
||||
|
||||
private void initView() {
|
||||
etSignFingerprint = findViewById(R.id.et_sign_fingerprint);
|
||||
tvAuthResult = findViewById(R.id.tv_auth_result);
|
||||
// 输入框只读,方便复制
|
||||
etSignFingerprint.setEnabled(false);
|
||||
}
|
||||
|
||||
// 核心:获取签名+调用APPUtils校验
|
||||
private void initSignAndCheck() {
|
||||
// 1. 获取当前应用签名
|
||||
String sign = getCurrentSign();
|
||||
if (sign == null) {
|
||||
etSignFingerprint.setText("签名获取失败");
|
||||
} else {
|
||||
// 签名字符串转0/1 bit数组(每2个bit加空格,每16位换行,下一行无前置空格)
|
||||
String bitArrayStr = convertSignToBitArrayWithWrap(sign);
|
||||
etSignFingerprint.setText(bitArrayStr);
|
||||
}
|
||||
LogUtils.d(TAG, "当前应用签名:" + sign);
|
||||
|
||||
// 2. 正版校验+显示结果
|
||||
APPUtils.checkAppValid(mContext, new APPUtils.CheckResultCallback() {
|
||||
@Override
|
||||
public void onResult(boolean isValid, String message) {
|
||||
String szOfficialMessage;
|
||||
// if (isValid) {
|
||||
// // 校验通过,执行正常逻辑
|
||||
// } else {
|
||||
// // 校验失败,提示用户
|
||||
// ToastUtils.show(message);
|
||||
// }
|
||||
if (isValid) {
|
||||
LogUtils.d(TAG, "校验通过:" + message);
|
||||
szOfficialMessage = "< 这是正版的 WinBoLL 应用,请放心使用。 >";
|
||||
tvAuthResult.setTextColor(Color.BLUE);
|
||||
} else {
|
||||
LogUtils.e(TAG, "校验失败:" + message);
|
||||
szOfficialMessage = "< 您使用的可能不是正版的 WinBoLL 应用。 >";
|
||||
tvAuthResult.setTextColor(Color.RED);
|
||||
}
|
||||
ToastUtils.show(szOfficialMessage);
|
||||
tvAuthResult.setText(szOfficialMessage);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
|
||||
// 核心修改:签名字符串转0/1 bit数组(每2个bit加空格,每16位换行,下一行无前置空格)
|
||||
private String convertSignToBitArrayWithWrap(String signStr) {
|
||||
StringBuilder bitBuilder = new StringBuilder();
|
||||
// 1. 字符转8位bit
|
||||
for (char c : signStr.toCharArray()) {
|
||||
String bit8 = String.format("%8s", Integer.toBinaryString(c)).replace(' ', '0');
|
||||
bitBuilder.append(bit8);
|
||||
}
|
||||
String fullBitStr = bitBuilder.toString();
|
||||
|
||||
// 2. 按16位分组,组内每2个bit加空格(避免换行后带空格)
|
||||
StringBuilder finalBuilder = new StringBuilder();
|
||||
int groupSize = 16; // 每组16个bit
|
||||
for (int i = 0; i < fullBitStr.length(); i += groupSize) {
|
||||
// 截取16位bit为一组
|
||||
int end = Math.min(i + groupSize, fullBitStr.length());
|
||||
String group = fullBitStr.substring(i, end);
|
||||
|
||||
// 组内每2个bit加空格
|
||||
StringBuilder groupWithSpace = new StringBuilder();
|
||||
for (int j = 0; j < group.length(); j++) {
|
||||
groupWithSpace.append(group.charAt(j));
|
||||
if ((j + 1) % 2 == 0 && j != group.length() - 1) {
|
||||
groupWithSpace.append(" ");
|
||||
}
|
||||
}
|
||||
|
||||
// 添加组到最终结果,每组后换行(最后一组不换行)
|
||||
finalBuilder.append(groupWithSpace);
|
||||
if (end < fullBitStr.length()) {
|
||||
finalBuilder.append("\n");
|
||||
}
|
||||
}
|
||||
return finalBuilder.toString();
|
||||
}
|
||||
|
||||
// 获取签名(复用SignGetUtils逻辑,避免重复代码)
|
||||
private String getCurrentSign() {
|
||||
try {
|
||||
return SignGetUtils.getSignStr(mContext); // 复用工具类逻辑
|
||||
} catch (Exception e) {
|
||||
LogUtils.e(TAG, "获取签名失败", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 校验签名是否合法(匹配APPUtils目标签名)
|
||||
// private boolean isSignValid() {
|
||||
// String currentSign = getCurrentSign();
|
||||
// String targetSign = APPUtils.TARGET_SIGN_FINGERPRINT; // 取APPUtils目标签名
|
||||
// return currentSign != null && targetSign != null && currentSign.equals(targetSign);
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package cc.winboll.studio.libappbase.models;
|
||||
|
||||
/**
|
||||
* SFTP登录验证信息实体类
|
||||
* 封装SFTP登录所需的所有配置信息:服务端地址、端口、账号密码、秘钥信息、编码
|
||||
* @Author 豆包&ZhanGSKen<zhangsken@qq.com>
|
||||
* @Date 2026/01/30 19:08:00
|
||||
* @LastEditTime 2026/01/31 22:45:00
|
||||
*/
|
||||
public class SFTPAuthModel {
|
||||
public static final String TAG = "SFTPAuthModel";
|
||||
|
||||
// SFTP服务器地址(必填,如192.168.1.100、sftp.xxx.com)
|
||||
private String ftpServer;
|
||||
// SFTP服务器端口(必填,默认22)
|
||||
private int ftpPort = 22;
|
||||
// SFTP登录用户名(匿名登录传null/空)
|
||||
private String ftpUsername;
|
||||
// SFTP登录密码(匿名登录传null/空)
|
||||
private String ftpPassword;
|
||||
// SFTP登录秘钥路径(秘钥登录时使用,本地绝对路径,如/sdcard/sftp/key.pem,账号密码登录传null/空)
|
||||
private String ftpKeyPath;
|
||||
// SFTP登录秘钥密码(秘钥有密码时填写,无密码传null/空)
|
||||
private String ftpKeyPwd;
|
||||
// SFTP编码(默认UTF-8,解决中文文件名乱码)
|
||||
private String ftpCharset = "UTF-8";
|
||||
|
||||
// 空参构造(JavaBean规范)
|
||||
public SFTPAuthModel() {
|
||||
}
|
||||
|
||||
// 全参构造(快速初始化)
|
||||
public SFTPAuthModel(String ftpServer, int ftpPort, String ftpUsername, String ftpPassword,
|
||||
String ftpKeyPath, String ftpKeyPwd, String ftpCharset) {
|
||||
this.ftpServer = ftpServer;
|
||||
this.ftpPort = ftpPort;
|
||||
this.ftpUsername = ftpUsername;
|
||||
this.ftpPassword = ftpPassword;
|
||||
this.ftpKeyPath = ftpKeyPath;
|
||||
this.ftpKeyPwd = ftpKeyPwd;
|
||||
this.ftpCharset = ftpCharset;
|
||||
}
|
||||
|
||||
// ==================== Get/Set 方法 ====================
|
||||
public String getFtpServer() {
|
||||
return ftpServer;
|
||||
}
|
||||
|
||||
public void setFtpServer(String ftpServer) {
|
||||
this.ftpServer = ftpServer;
|
||||
}
|
||||
|
||||
public int getFtpPort() {
|
||||
return ftpPort;
|
||||
}
|
||||
|
||||
public void setFtpPort(int ftpPort) {
|
||||
this.ftpPort = ftpPort;
|
||||
}
|
||||
|
||||
public String getFtpUsername() {
|
||||
return ftpUsername;
|
||||
}
|
||||
|
||||
public void setFtpUsername(String ftpUsername) {
|
||||
this.ftpUsername = ftpUsername;
|
||||
}
|
||||
|
||||
public String getFtpPassword() {
|
||||
return ftpPassword;
|
||||
}
|
||||
|
||||
public void setFtpPassword(String ftpPassword) {
|
||||
this.ftpPassword = ftpPassword;
|
||||
}
|
||||
|
||||
public String getFtpKeyPath() {
|
||||
return ftpKeyPath;
|
||||
}
|
||||
|
||||
public void setFtpKeyPath(String ftpKeyPath) {
|
||||
this.ftpKeyPath = ftpKeyPath;
|
||||
}
|
||||
|
||||
public String getFtpKeyPwd() {
|
||||
return ftpKeyPwd;
|
||||
}
|
||||
|
||||
public void setFtpKeyPwd(String ftpKeyPwd) {
|
||||
this.ftpKeyPwd = ftpKeyPwd;
|
||||
}
|
||||
|
||||
public String getFtpCharset() {
|
||||
return ftpCharset;
|
||||
}
|
||||
|
||||
public void setFtpCharset(String ftpCharset) {
|
||||
this.ftpCharset = ftpCharset;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
package cc.winboll.studio.libappbase.utils;
|
||||
|
||||
import cc.winboll.studio.libappbase.LogUtils;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Base64;
|
||||
import java.util.jar.JarEntry;
|
||||
import java.util.jar.JarFile;
|
||||
|
||||
/**
|
||||
* APK文件工具类(单例)- 生产级签名+哈希双校验版(修复Too short异常)
|
||||
* 1. 稳定解析CERT.RSA原始字节,与客户端Signature.toByteArray()1:1对齐,解决X509解析异常
|
||||
* 2. 支持SHA256文件哈希字节级唯一校验,签名+哈希双重验证
|
||||
* 3. 入参包含:项目名/版本名/APK名/客户端签名/客户端哈希,适配生产级版本管理
|
||||
* 4. APK路径规范:apks_root/项目名/debug/tag/APK文件(支持调试/正式环境)
|
||||
* @Author ZhanGSKen<zhangsken@qq.com>
|
||||
*/
|
||||
public class APKFileUtils {
|
||||
// 单例实例
|
||||
private static volatile APKFileUtils sInstance;
|
||||
// 配置项
|
||||
private static final String CONFIG_SECTION = "APP";
|
||||
private static final String KEY_APKS_FOLDER = "apks_folder_path";
|
||||
// 算法常量(与客户端严格对齐)
|
||||
private static final String SIGN_ALGORITHM = "SHA1"; // 签名摘要算法
|
||||
private static final String HASH_ALGORITHM = "SHA-256"; // 文件哈希算法
|
||||
// 签名文件(兼容大小写,适配所有打包工具)
|
||||
private static final String CERT_RSA_UPPER = "META-INF/CERT.RSA";
|
||||
private static final String CERT_RSA_LOWER = "META-INF/cert.rsa";
|
||||
// APK根目录
|
||||
private String apksRootPath;
|
||||
|
||||
private APKFileUtils() {}
|
||||
|
||||
/**
|
||||
* 初始化工具类(需在应用启动时调用)
|
||||
*/
|
||||
public static void init() {
|
||||
if (sInstance == null) {
|
||||
synchronized (APKFileUtils.class) {
|
||||
if (sInstance == null) {
|
||||
sInstance = new APKFileUtils();
|
||||
//sInstance.loadConfig();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单例实例
|
||||
*/
|
||||
public static APKFileUtils getInstance() {
|
||||
if (sInstance == null) {
|
||||
LogUtils.e("APKFileUtils", "请先调用init()初始化工具类");
|
||||
throw new IllegalStateException("APKFileUtils未初始化,请先调用init()");
|
||||
}
|
||||
return sInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载配置文件中的APK根目录
|
||||
*/
|
||||
// private void loadConfig() {
|
||||
// try {
|
||||
// apksRootPath = IniConfigUtils.getConfigValue(CONFIG_SECTION, KEY_APKS_FOLDER, "").trim();
|
||||
// if (apksRootPath.isEmpty()) {
|
||||
// LogUtils.e("APKFileUtils", "配置项apks_folder_path为空,初始化失败");
|
||||
// return;
|
||||
// }
|
||||
// File rootDir = new File(apksRootPath);
|
||||
// if (!rootDir.exists() && !rootDir.mkdirs()) {
|
||||
// LogUtils.e("APKFileUtils", "APK根目录创建失败:" + apksRootPath);
|
||||
// apksRootPath = "";
|
||||
// return;
|
||||
// }
|
||||
// LogUtils.i("APKFileUtils", "APK根目录加载成功:" + apksRootPath);
|
||||
// } catch (Exception e) {
|
||||
// LogUtils.e("APKFileUtils", "加载APK根目录配置失败", e);
|
||||
// apksRootPath = "";
|
||||
// }
|
||||
// }
|
||||
|
||||
/**
|
||||
* 对外暴露核心校验方法:签名 + SHA256文件哈希 双校验
|
||||
* 入参包含:项目名/版本名/APK文件名/客户端签名Base64/客户端文件哈希
|
||||
* APK路径规范:apksRootPath/项目名/版本名/APK文件
|
||||
* @param projectName 项目名(非空)
|
||||
* @param versionName 版本名(非空,如15.11.11)
|
||||
* @param apkFileName APK文件名(非空,需以.apk结尾)
|
||||
* @param clientSignBase64 客户端传入的签名Base64(非空)
|
||||
* @param clientFileHash 客户端传入的APK文件SHA256哈希(小写/大写均可,非空)
|
||||
* @return 校验通过返回true,否则false
|
||||
*/
|
||||
public static boolean checkAPK(String projectName, String versionName, String apkFileName,
|
||||
String clientSignBase64, String clientFileHash) {
|
||||
return getInstance().doCheckAPK(projectName, versionName, apkFileName, clientSignBase64, clientFileHash);
|
||||
}
|
||||
|
||||
/**
|
||||
* 核心校验实现:严格按「哈希先验,签名后验」顺序,哈希不匹配直接返回
|
||||
*/
|
||||
private boolean doCheckAPK(String projectName, String versionName, String apkFileName,
|
||||
String clientSignBase64, String clientFileHash) {
|
||||
// 1. 基础入参非空校验
|
||||
if (isParamEmpty(projectName) || isParamEmpty(versionName) || isParamEmpty(apkFileName)
|
||||
|| isParamEmpty(clientSignBase64) || isParamEmpty(clientFileHash)) {
|
||||
LogUtils.w("APKFileUtils", "基础参数不能为空:projectName/versionName/apkFileName/clientSignBase64/clientFileHash");
|
||||
return false;
|
||||
}
|
||||
// 2. APK文件名格式校验
|
||||
if (!apkFileName.endsWith(".apk")) {
|
||||
LogUtils.w("APKFileUtils", "APK文件名格式错误,需以.apk结尾:" + apkFileName);
|
||||
return false;
|
||||
}
|
||||
// 3. APK根目录校验
|
||||
if (isParamEmpty(apksRootPath)) {
|
||||
LogUtils.w("APKFileUtils", "APK根目录未配置,无法进行校验");
|
||||
return false;
|
||||
}
|
||||
// 4. 拼接标准APK路径:根目录/项目名/debug/项目名_版本名.apk(调试环境,可切换tag)
|
||||
String apkFullPath = String.format("%s/%s/debug/%s_%s.apk",
|
||||
apksRootPath,
|
||||
projectName,
|
||||
projectName,
|
||||
versionName);
|
||||
//正式环境路径(注释保留,切换时解开即可)
|
||||
// String apkFullPath = String.format("%s/%s/tag/%s_%s.apk",
|
||||
// apksRootPath,
|
||||
// projectName,
|
||||
// projectName,
|
||||
// versionName);
|
||||
LogUtils.d("APKFileUtils", String.format("apkFullPath : %s", apkFullPath));
|
||||
File apkFile = new File(apkFullPath);
|
||||
// 5. APK文件存在性校验
|
||||
if (!apkFile.exists() || !apkFile.isFile()) {
|
||||
LogUtils.w("APKFileUtils", "APK文件不存在或非文件类型:" + apkFullPath);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// ===== 第一步:SHA256文件哈希校验(字节级唯一,优先级最高)=====
|
||||
String serverFileHash = getAPKFileHash(apkFile);
|
||||
if (isParamEmpty(serverFileHash)) {
|
||||
LogUtils.w("APKFileUtils", "解析服务端APK文件哈希失败:" + apkFileName);
|
||||
return false;
|
||||
}
|
||||
boolean isHashMatch = serverFileHash.equalsIgnoreCase(clientFileHash.trim());
|
||||
LogUtils.d("APKFileUtils", "【哈希对比】服务端SHA256:" + serverFileHash);
|
||||
LogUtils.d("APKFileUtils", "【哈希对比】客户端SHA256:" + clientFileHash.trim());
|
||||
if (!isHashMatch) {
|
||||
LogUtils.i("APKFileUtils", "【哈希对比结果】❌ 不匹配(字节级文件不一致)");
|
||||
return false;
|
||||
}
|
||||
LogUtils.i("APKFileUtils", "【哈希对比结果】✅ 匹配(字节级文件完全一致)");
|
||||
|
||||
// ===== 第二步:签名校验(直接读取CERT.RSA原始字节,与客户端严格对齐)=====
|
||||
String serverSignBase64 = getAPKSign(apkFile);
|
||||
if (isParamEmpty(serverSignBase64)) {
|
||||
LogUtils.w("APKFileUtils", "解析服务端APK签名失败:" + apkFileName);
|
||||
return false;
|
||||
}
|
||||
boolean isSignMatch = serverSignBase64.equals(clientSignBase64.trim());
|
||||
LogUtils.d("APKFileUtils", "【签名对比】服务端Base64:" + serverSignBase64);
|
||||
LogUtils.d("APKFileUtils", "【签名对比】客户端Base64:" + clientSignBase64.trim());
|
||||
if (!isSignMatch) {
|
||||
LogUtils.i("APKFileUtils", "【签名对比结果】❌ 不匹配(签名不一致)");
|
||||
return false;
|
||||
}
|
||||
LogUtils.i("APKFileUtils", "【签名对比结果】✅ 匹配(签名完全一致)");
|
||||
|
||||
// 所有校验通过
|
||||
LogUtils.i("APKFileUtils", "APK双校验全部通过:项目名=" + projectName + ",版本名=" + versionName + ",文件名=" + apkFileName);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
LogUtils.e("APKFileUtils", "APK双校验异常", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 稳定解析APK签名:直接读取CERT.RSA原始字节,SHA1+Base64(与客户端1:1对齐)
|
||||
* 解决X509证书解析的Too short异常,兼容所有APK(普通/加固/自定义打包)
|
||||
* @param apkFile APK文件
|
||||
* @return 签名Base64字符串,失败返回null
|
||||
*/
|
||||
private String getAPKSign(File apkFile) {
|
||||
JarFile jarFile = null;
|
||||
InputStream certIs = null;
|
||||
try {
|
||||
jarFile = new JarFile(apkFile);
|
||||
// 先找大写CERT.RSA,找不到再找小写,兼容所有打包工具
|
||||
JarEntry certEntry = jarFile.getJarEntry(CERT_RSA_UPPER);
|
||||
if (certEntry == null) {
|
||||
certEntry = jarFile.getJarEntry(CERT_RSA_LOWER);
|
||||
if (certEntry == null) {
|
||||
LogUtils.w("APKFileUtils", "APK中未找到签名文件:META-INF/CERT.RSA/cert.rsa");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// 核心:直接读取CERT.RSA的原始字节流(不做证书解析,适配PKCS7签名块)
|
||||
certIs = jarFile.getInputStream(certEntry);
|
||||
byte[] sigRawBytes = readStreamToBytes(certIs);
|
||||
if (sigRawBytes == null || sigRawBytes.length == 0) {
|
||||
LogUtils.w("APKFileUtils", "读取CERT.RSA原始字节为空");
|
||||
return null;
|
||||
}
|
||||
// 与客户端完全一致的处理流程:SHA1摘要 → Base64编码(去换行)
|
||||
MessageDigest md = MessageDigest.getInstance(SIGN_ALGORITHM);
|
||||
byte[] signDigest = md.digest(sigRawBytes);
|
||||
String signBase64 = Base64.getEncoder().encodeToString(signDigest)
|
||||
.replaceAll("\\r", "").replaceAll("\\n", "");
|
||||
|
||||
LogUtils.d("APKFileUtils", "APK签名解析成功(Base64):" + signBase64);
|
||||
return signBase64;
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
LogUtils.e("APKFileUtils", "解析签名失败:" + SIGN_ALGORITHM + "算法不存在", e);
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
LogUtils.e("APKFileUtils", "解析APK签名异常", e);
|
||||
return null;
|
||||
} finally {
|
||||
// 强制关闭流资源,避免内存泄漏
|
||||
try {
|
||||
if (certIs != null) certIs.close();
|
||||
if (jarFile != null) jarFile.close();
|
||||
} catch (IOException e) {
|
||||
LogUtils.e("APKFileUtils", "关闭签名文件流失败", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析APK文件的SHA256哈希(字节级唯一,任何字节修改都会改变)
|
||||
* @param apkFile APK文件
|
||||
* @return 小写64位SHA256哈希字符串,失败返回null
|
||||
*/
|
||||
private String getAPKFileHash(File apkFile) {
|
||||
FileInputStream fis = null;
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance(HASH_ALGORITHM);
|
||||
fis = new FileInputStream(apkFile);
|
||||
byte[] buffer = new byte[8192]; // 8K缓冲区,提升大APK读取效率
|
||||
int len;
|
||||
while ((len = fis.read(buffer)) != -1) {
|
||||
md.update(buffer, 0, len);
|
||||
}
|
||||
// 哈希字节转小写16进制字符串(64位,官方标准格式)
|
||||
byte[] hashBytes = md.digest();
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (byte b : hashBytes) {
|
||||
sb.append(String.format("%02x", b));
|
||||
}
|
||||
String fileHash = sb.toString();
|
||||
LogUtils.d("APKFileUtils", "APK文件SHA256哈希解析成功:" + fileHash);
|
||||
return fileHash;
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
LogUtils.e("APKFileUtils", "获取文件哈希失败:" + HASH_ALGORITHM + "算法不存在", e);
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
LogUtils.e("APKFileUtils", "解析APK文件哈希异常", e);
|
||||
return null;
|
||||
} finally {
|
||||
if (fis != null) {
|
||||
try {
|
||||
fis.close();
|
||||
} catch (IOException e) {
|
||||
LogUtils.e("APKFileUtils", "关闭APK文件流失败", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 流转字节数组工具方法:稳定读取任意输入流,无截断/空指针问题
|
||||
*/
|
||||
private byte[] readStreamToBytes(InputStream is) throws IOException {
|
||||
if (is == null) {
|
||||
LogUtils.w("APKFileUtils", "readStreamToBytes: 输入流为null");
|
||||
return new byte[0];
|
||||
}
|
||||
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||||
byte[] buffer = new byte[4096];
|
||||
int len;
|
||||
while ((len = is.read(buffer)) != -1) {
|
||||
bos.write(buffer, 0, len);
|
||||
}
|
||||
byte[] result = bos.toByteArray();
|
||||
// 按顺序关闭流
|
||||
is.close();
|
||||
bos.close();
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具方法:判断参数是否为空(null/空字符串/全空格)
|
||||
*/
|
||||
private boolean isParamEmpty(String param) {
|
||||
return param == null || param.trim().isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
package cc.winboll.studio.libappbase.utils;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.pm.PackageInfo;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.content.pm.Signature;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Base64;
|
||||
|
||||
import cc.winboll.studio.libappbase.GlobalApplication;
|
||||
import cc.winboll.studio.libappbase.LogUtils;
|
||||
import cc.winboll.studio.libappbase.models.SignCheckResponse;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URLEncoder;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Date;
|
||||
|
||||
import okhttp3.Call;
|
||||
import okhttp3.Callback;
|
||||
import okhttp3.OkHttpClient;
|
||||
@@ -21,171 +24,174 @@ import okhttp3.Response;
|
||||
|
||||
/**
|
||||
* @Author 豆包&ZhanGSKen<zhangsken@qq.com>
|
||||
* @Date 2026/01/20 19:17
|
||||
* @Describe APPUtils 应用包名、签名校验工具类(OKHTTP网络校验版)
|
||||
* @CreateTime 2026-01-20 19:17:00
|
||||
* @LastEditTime 2026-01-24 17:58:00
|
||||
* @Describe APPUtils 应用合法性校验工具类(OKHTTP网络校验版,兼容Java7)
|
||||
* 对外传入签名/哈希值,拼接调试标识后发起网络校验,主线程返回校验结果
|
||||
*/
|
||||
public class APPUtils {
|
||||
// ===================================== 全局常量/单例属性 =====================================
|
||||
public static final String TAG = "APPUtils";
|
||||
// 网络校验接口地址
|
||||
// 网络校验接口基础地址
|
||||
private static final String CHECK_API_URI = "api/app-signatures-check";
|
||||
// OKHTTP客户端(单例复用)
|
||||
private static OkHttpClient sOkHttpClient = new OkHttpClient();
|
||||
// Gson解析实例
|
||||
private static Gson sGson = new Gson();
|
||||
// OKHTTP客户端单例(复用连接,避免资源浪费)
|
||||
private static final OkHttpClient sOkHttpClient = new OkHttpClient();
|
||||
// Gson解析单例(全局复用,提高解析效率)
|
||||
private static final Gson sGson = new Gson();
|
||||
|
||||
// ===================================== 对外核心校验方法 =====================================
|
||||
/**
|
||||
* 检查应用合法性(包名校验+OKHTTP网络校验签名)
|
||||
* @param context 上下文
|
||||
* @param callback 校验结果回调(主线程回调)
|
||||
* 检查应用合法性(外部传入签名+哈希,拼接调试标识发起网络校验)
|
||||
* @param context 上下文,用于主线程回调
|
||||
* @param projectName 项目名称(服务端区分项目标识)
|
||||
* @param versionName 应用版本名(服务端版本校验)
|
||||
* @param clientSign 外部计算的应用签名字符串(Base64)
|
||||
* @param clientHash 外部计算的APK SHA256哈希字符串(小写16进制)
|
||||
* @param callback 校验结果回调(主线程调用,返回是否合法+提示信息)
|
||||
*/
|
||||
public static void checkAppValid(Context context, final CheckResultCallback callback) {
|
||||
public void checkAPKValidation(Context context, String projectName, String versionName,
|
||||
String clientSign, String clientHash, final CheckResultCallback callback) {
|
||||
// 方法调用+全量入参调试日志
|
||||
LogUtils.d(TAG, "checkAPKValidation: 方法调用,入参-> projectName=" + projectName
|
||||
+ ", versionName=" + versionName + ", clientSign=" + clientSign + ", clientHash=" + clientHash);
|
||||
|
||||
// 1. 核心入参空值校验(快速失败)
|
||||
if (context == null) {
|
||||
LogUtils.w(TAG, "checkAppValid: context为空,跳过校验");
|
||||
if (callback != null) callback.onResult(false, "context为空");
|
||||
LogUtils.w(TAG, "checkAPKValidation: 入参context为空,直接返回校验失败");
|
||||
callCallbackOnMainThread(callback, false, "上下文对象不能为空");
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 获取当前应用签名(SHA1+Base64)和证书生效时间
|
||||
String currentSign = getAppSignFingerprint(context);
|
||||
long certValidTime = getCertValidTime(context); // 证书生效时间(毫秒时间戳)
|
||||
if (currentSign == null) {
|
||||
String errorMsg = "获取应用签名失败";
|
||||
LogUtils.e(TAG, "checkAppValid: " + errorMsg);
|
||||
if (callback != null) callback.onResult(false, errorMsg);
|
||||
if (isStringEmpty(projectName)) {
|
||||
LogUtils.w(TAG, "checkAPKValidation: 入参projectName为空/空白,直接返回校验失败");
|
||||
callCallbackOnMainThread(callback, false, "项目名称不能为空");
|
||||
return;
|
||||
}
|
||||
if (isStringEmpty(versionName)) {
|
||||
LogUtils.w(TAG, "checkAPKValidation: 入参versionName为空/空白,直接返回校验失败");
|
||||
callCallbackOnMainThread(callback, false, "应用版本名不能为空");
|
||||
return;
|
||||
}
|
||||
if (isStringEmpty(clientSign)) {
|
||||
LogUtils.w(TAG, "checkAPKValidation: 入参clientSign为空/空白,直接返回校验失败");
|
||||
callCallbackOnMainThread(callback, false, "应用签名字符串不能为空");
|
||||
return;
|
||||
}
|
||||
if (isStringEmpty(clientHash)) {
|
||||
LogUtils.w(TAG, "checkAPKValidation: 入参clientHash为空/空白,直接返回校验失败");
|
||||
callCallbackOnMainThread(callback, false, "APK SHA256哈希字符串不能为空");
|
||||
return;
|
||||
}
|
||||
LogUtils.d(TAG, "checkAPKValidation: 入参校验通过,开始处理网络请求");
|
||||
|
||||
// 新增:对currentSign进行Base64二次加密(URL安全编码,避免特殊字符)
|
||||
String encryptedSign = base64Encode(currentSign);
|
||||
LogUtils.d(TAG, "checkAppValid: 原始签名=" + currentSign + ",Base64二次加密后=" + encryptedSign);
|
||||
// 2. 动态参数URL编码(避免特殊字符导致请求解析异常)
|
||||
LogUtils.d(TAG, "checkAPKValidation: 开始对动态参数进行UTF-8 URL编码");
|
||||
String encodeProjectName = urlEncode(projectName);
|
||||
String encodeVersionName = urlEncode(versionName);
|
||||
String encodeClientSign = urlEncode(clientSign);
|
||||
String encodeClientHash = urlEncode(clientHash);
|
||||
String isDebug = String.valueOf(GlobalApplication.isDebugging());
|
||||
LogUtils.d(TAG, "checkAPKValidation: 参数编码完成,debug标识=" + isDebug);
|
||||
|
||||
// 3. 构建请求URL(拼接加密后的签名参数)
|
||||
String requestUrl = String.format("%s?signature=%s&validTime=%d",
|
||||
GlobalApplication.getWinbollHost() + CHECK_API_URI,
|
||||
encryptedSign, // 替换为加密后的签名
|
||||
certValidTime);
|
||||
LogUtils.d(TAG, "checkAppValid: 发起网络校验请求,URL=" + requestUrl);
|
||||
// 3. 构建完整网络校验请求URL
|
||||
String requestUrl = String.format("%s?isDebug=%s&projectName=%s&versionName=%s&clientSign=%s&clientHash=%s",
|
||||
GlobalApplication.getWinbollHost() + CHECK_API_URI,
|
||||
isDebug,
|
||||
encodeProjectName,
|
||||
encodeVersionName,
|
||||
encodeClientSign,
|
||||
encodeClientHash);
|
||||
LogUtils.d(TAG, "checkAPKValidation: 构建网络校验请求URL=" + requestUrl);
|
||||
|
||||
// 4. OKHTTP发起异步GET请求
|
||||
// 4. 发起OKHTTP异步GET请求(避免阻塞主线程)
|
||||
LogUtils.d(TAG, "checkAPKValidation: 发起异步网络校验请求");
|
||||
Request request = new Request.Builder().url(requestUrl).build();
|
||||
sOkHttpClient.newCall(request).enqueue(new Callback() {
|
||||
@Override
|
||||
public void onFailure(Call call, IOException e) {
|
||||
final String errorMsg = "网络校验请求失败:" + e.getMessage();
|
||||
LogUtils.e(TAG, "checkAppValid: " + errorMsg, e);
|
||||
if (callback != null) {
|
||||
// 切换到主线程回调
|
||||
new android.os.Handler(android.os.Looper.getMainLooper()).post(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
callback.onResult(false, errorMsg);
|
||||
}
|
||||
});
|
||||
}
|
||||
String errorMsg = "网络校验请求失败:" + e.getMessage();
|
||||
LogUtils.e(TAG, "checkAPKValidation: " + errorMsg, e);
|
||||
callCallbackOnMainThread(callback, false, errorMsg);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResponse(Call call, Response response) throws IOException {
|
||||
if (response.isSuccessful() && response.body() != null) {
|
||||
// 响应成功,解析返回JSON
|
||||
String responseJson = response.body().string();
|
||||
LogUtils.d(TAG, "checkAppValid: 网络校验响应JSON=" + responseJson);
|
||||
// 解析JSON响应
|
||||
LogUtils.d(TAG, "checkAPKValidation: 网络校验响应成功,JSON=" + responseJson);
|
||||
SignCheckResponse checkResponse = sGson.fromJson(responseJson, SignCheckResponse.class);
|
||||
final boolean isValid = checkResponse != null && checkResponse.isValid();
|
||||
final String msg = checkResponse != null ? checkResponse.getMessage() : "响应解析失败";
|
||||
if (callback != null) {
|
||||
new android.os.Handler(android.os.Looper.getMainLooper()).post(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
callback.onResult(isValid, msg);
|
||||
}
|
||||
});
|
||||
}
|
||||
boolean isValid = checkResponse != null && checkResponse.isValid();
|
||||
String msg = checkResponse != null ? checkResponse.getMessage() : "服务端响应解析失败";
|
||||
LogUtils.d(TAG, "checkAPKValidation: 校验结果解析完成,isValid=" + isValid + ", 提示信息=" + msg);
|
||||
callCallbackOnMainThread(callback, isValid, msg);
|
||||
} else {
|
||||
final String errorMsg = "网络校验响应失败,code=" + response.code();
|
||||
LogUtils.e(TAG, "checkAppValid: " + errorMsg);
|
||||
if (callback != null) {
|
||||
new android.os.Handler(android.os.Looper.getMainLooper()).post(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
callback.onResult(false, errorMsg);
|
||||
}
|
||||
});
|
||||
}
|
||||
// 响应失败,返回状态码信息
|
||||
String errorMsg = "网络校验响应失败,服务端状态码=" + response.code();
|
||||
LogUtils.e(TAG, "checkAPKValidation: " + errorMsg);
|
||||
callCallbackOnMainThread(callback, false, errorMsg);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ===================================== 内部工具方法 =====================================
|
||||
/**
|
||||
* 新增:Base64加密工具(URL安全编码,避免特殊字符影响URL拼接)
|
||||
* @param content 待加密内容
|
||||
* @return 加密后的Base64字符串
|
||||
* 字符串空值/空白校验工具
|
||||
* @param str 待校验字符串
|
||||
* @return true=空/空白,false=非空
|
||||
*/
|
||||
private static String base64Encode(String content) {
|
||||
private boolean isStringEmpty(String str) {
|
||||
return str == null || str.trim().isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* URL编码工具(Java7适配,UTF-8编码,处理特殊字符)
|
||||
* @param content 待编码内容
|
||||
* @return 编码后的字符串,编码失败返回原内容
|
||||
*/
|
||||
private String urlEncode(String content) {
|
||||
try {
|
||||
// 使用URL安全的Base64编码(替换+为-,/为_,去除=)
|
||||
byte[] contentBytes = content.getBytes("UTF-8");
|
||||
return Base64.encodeToString(contentBytes, Base64.URL_SAFE | Base64.NO_PADDING | Base64.NO_WRAP);
|
||||
return URLEncoder.encode(content, "UTF-8");
|
||||
} catch (Exception e) {
|
||||
LogUtils.e(TAG, "base64Encode: 加密失败", e);
|
||||
return content; // 加密失败则返回原始内容,避免请求异常
|
||||
LogUtils.e(TAG, "urlEncode: 字符串编码失败,content=" + content, e);
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前应用签名SHA1指纹(BASE64编码)
|
||||
* 主线程执行回调(统一处理,避免外部线程切换)
|
||||
* @param callback 回调接口
|
||||
* @param isValid 是否合法
|
||||
* @param message 提示信息
|
||||
*/
|
||||
private static String getAppSignFingerprint(Context context) {
|
||||
try {
|
||||
PackageManager pm = context.getPackageManager();
|
||||
PackageInfo pkgInfo = pm.getPackageInfo(context.getPackageName(), PackageManager.GET_SIGNATURES);
|
||||
Signature[] signatures = pkgInfo.signatures;
|
||||
if (signatures == null || signatures.length == 0) {
|
||||
LogUtils.w(TAG, "getAppSignFingerprint: 未获取到应用签名");
|
||||
return null;
|
||||
}
|
||||
MessageDigest md = MessageDigest.getInstance("SHA1");
|
||||
md.update(signatures[0].toByteArray());
|
||||
return Base64.encodeToString(md.digest(), Base64.NO_WRAP);
|
||||
} catch (PackageManager.NameNotFoundException | NoSuchAlgorithmException e) {
|
||||
LogUtils.e(TAG, "getAppSignFingerprint: 获取签名异常", e);
|
||||
} catch (Exception e) {
|
||||
LogUtils.e(TAG, "getAppSignFingerprint: 未知异常", e);
|
||||
private void callCallbackOnMainThread(final CheckResultCallback callback,
|
||||
final boolean isValid, final String message) {
|
||||
if (callback == null) {
|
||||
LogUtils.w(TAG, "callCallbackOnMainThread: 回调接口为null,无需执行");
|
||||
return;
|
||||
}
|
||||
// 已在主线程直接执行,否则切换主线程
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
callback.onResult(isValid, message);
|
||||
} else {
|
||||
new Handler(Looper.getMainLooper()).post(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
callback.onResult(isValid, message);
|
||||
}
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ===================================== 校验结果回调接口 =====================================
|
||||
/**
|
||||
* 获取应用证书生效时间(毫秒时间戳)
|
||||
* 应用合法性校验结果回调接口(主线程调用)
|
||||
*/
|
||||
private static long getCertValidTime(Context context) {
|
||||
try {
|
||||
PackageManager pm = context.getPackageManager();
|
||||
PackageInfo pkgInfo = pm.getPackageInfo(context.getPackageName(), PackageManager.GET_SIGNATURES);
|
||||
Signature[] signatures = pkgInfo.signatures;
|
||||
if (signatures == null || signatures.length == 0) {
|
||||
LogUtils.w(TAG, "getCertValidTime: 未获取到应用签名");
|
||||
return new Date().getTime(); // 默认当前时间
|
||||
}
|
||||
// 解析签名证书,获取生效时间(简化实现,实际需解析X.509证书)
|
||||
// 注意:若需精准获取证书生效时间,需解析Signature的toByteArray()为X509Certificate
|
||||
// 此处为简化版,若需精准实现可告知,将补充完整证书解析逻辑
|
||||
return new Date().getTime();
|
||||
} catch (PackageManager.NameNotFoundException e) {
|
||||
LogUtils.e(TAG, "getCertValidTime: 获取包信息异常", e);
|
||||
} catch (Exception e) {
|
||||
LogUtils.e(TAG, "getCertValidTime: 未知异常", e);
|
||||
}
|
||||
return new Date().getTime();
|
||||
}
|
||||
|
||||
// ==================== 校验结果回调接口 ====================
|
||||
public interface CheckResultCallback {
|
||||
/**
|
||||
* 校验结果回调(主线程调用)
|
||||
* @param isValid 是否合法
|
||||
* @param message 校验信息
|
||||
* 校验结果回调方法
|
||||
* @param isValid 是否合法(true=校验通过,false=校验失败)
|
||||
* @param message 校验提示信息(失败时返回错误原因,成功时返回服务端提示)
|
||||
*/
|
||||
void onResult(boolean isValid, String message);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
package cc.winboll.studio.libappbase.utils;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.pm.ApplicationInfo;
|
||||
import android.content.pm.PackageInfo;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.content.pm.Signature;
|
||||
import android.util.Base64;
|
||||
|
||||
import cc.winboll.studio.libappbase.LogUtils;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Enumeration;
|
||||
import java.util.jar.JarEntry;
|
||||
import java.util.jar.JarFile;
|
||||
|
||||
/**
|
||||
* @Author 豆包&ZhanGSKen<zhangsken@qq.com>
|
||||
* @CreateTime 2026-01-24 10:00:00
|
||||
* @LastEditTime 2026-01-24 22:00:00
|
||||
* @Describe 客户端签名工具类:与服务端APKFileUtils签名/哈希校验逻辑严格对齐,纯Java7实现;兼容MT重签名(遍历META-INF所有RSA文件),增加PackageManager兜底方案
|
||||
*/
|
||||
public class ApkSignUtils {
|
||||
// ===================================== 全局常量定义 =====================================
|
||||
private static final String TAG = "ApkSignUtils";
|
||||
// 加密算法常量
|
||||
private static final String ALGORITHM_SHA1 = "SHA1";
|
||||
private static final String ALGORITHM_SHA256 = "SHA-256";
|
||||
// 缓冲区大小常量(按业务场景区分)
|
||||
private static final int BUFFER_4K = 4096;
|
||||
private static final int BUFFER_8K = 8192;
|
||||
// 签名文件目录与后缀
|
||||
private static final String META_INF_DIR = "META-INF/";
|
||||
private static final String RSA_SUFFIX_UPPER = ".RSA";
|
||||
private static final String RSA_SUFFIX_LOWER = ".rsa";
|
||||
|
||||
// ===================================== 对外核心方法 =====================================
|
||||
/**
|
||||
* 获取与服务端对齐的签名Base64串(兼容MT重签名)
|
||||
* 优先逻辑:遍历APK内META-INF所有.RSA文件 → 读取第一个有效文件原始字节 → SHA1摘要 → Base64.NO_WRAP
|
||||
* 兜底逻辑:PackageManager获取系统解析的签名 → SHA1摘要 → Base64.NO_WRAP
|
||||
* @param context 上下文,用于获取当前应用APK路径/包信息
|
||||
* @return 签名Base64字符串,任意步骤失败返回null
|
||||
*/
|
||||
public static String getApkSignAlignedWithServer(Context context) {
|
||||
LogUtils.d(TAG, "getApkSignAlignedWithServer: 方法调用,开始执行服务端对齐签名计算(兼容MT重签名)");
|
||||
// 入参空值快速校验
|
||||
if (context == null) {
|
||||
LogUtils.w(TAG, "getApkSignAlignedWithServer: 入参context为null,直接返回null");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 方案1:优先读取APK内META-INF目录下所有RSA文件(兼容MT重签名任意命名)
|
||||
String signBase64 = getSignFromApkRsaFile(context);
|
||||
if (signBase64 != null) {
|
||||
LogUtils.d(TAG, "getApkSignAlignedWithServer: 方案1成功(APK内读取RSA文件),返回签名Base64");
|
||||
return signBase64;
|
||||
}
|
||||
|
||||
// 方案2:兜底 - PackageManager获取系统解析的应用签名(避免APK文件读取失败)
|
||||
signBase64 = getSignFromPackageManager(context);
|
||||
if (signBase64 != null) {
|
||||
LogUtils.d(TAG, "getApkSignAlignedWithServer: 方案2成功(PackageManager兜底),返回签名Base64");
|
||||
return signBase64;
|
||||
}
|
||||
|
||||
// 所有方案失败
|
||||
LogUtils.e(TAG, "getApkSignAlignedWithServer: 所有签名获取方案均失败");
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前运行APK的SHA256哈希值(兼容重签名APK)
|
||||
* 逻辑:读取APK完整文件字节流 → SHA256摘要 → 转小写64位16进制字符串,服务端同款校验逻辑
|
||||
* @param context 上下文,用于获取当前应用APK的真实安装路径
|
||||
* @return SHA256小写16进制字符串,任意步骤失败返回null
|
||||
*/
|
||||
public static String getApkSHA256Hash(Context context) {
|
||||
LogUtils.d(TAG, "getApkSHA256Hash: 方法调用,开始执行APK文件SHA256哈希计算");
|
||||
// 入参空值快速校验
|
||||
if (context == null) {
|
||||
LogUtils.w(TAG, "getApkSHA256Hash: 入参context为null,直接返回null");
|
||||
return null;
|
||||
}
|
||||
|
||||
JarFile jarFile = null;
|
||||
FileInputStream fis = null;
|
||||
try {
|
||||
// 1. 获取当前应用APK真实路径
|
||||
ApplicationInfo appInfo = context.getApplicationContext().getApplicationInfo();
|
||||
String apkPath = appInfo.sourceDir;
|
||||
LogUtils.d(TAG, "getApkSHA256Hash: 成功获取APK路径,path=" + apkPath);
|
||||
if (apkPath == null || apkPath.trim().isEmpty()) {
|
||||
LogUtils.e(TAG, "getApkSHA256Hash: 获取到的APK路径为空,无法读取文件计算哈希");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. 读取APK文件并计算SHA256哈希(完善流关闭)
|
||||
File apkFile = new File(apkPath);
|
||||
MessageDigest md = MessageDigest.getInstance(ALGORITHM_SHA256);
|
||||
fis = new FileInputStream(apkFile);
|
||||
byte[] buffer = new byte[BUFFER_8K];
|
||||
int readLen;
|
||||
while ((readLen = fis.read(buffer)) != -1) {
|
||||
md.update(buffer, 0, readLen);
|
||||
}
|
||||
LogUtils.d(TAG, "getApkSHA256Hash: APK文件读取完成,开始转换哈希结果");
|
||||
|
||||
// 3. 哈希字节数组转小写64位16进制字符串
|
||||
byte[] hashBytes = md.digest();
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (byte b : hashBytes) {
|
||||
sb.append(String.format("%02x", b));
|
||||
}
|
||||
String sha256Hash = sb.toString();
|
||||
LogUtils.d(TAG, "getApkSHA256Hash: APK SHA256哈希计算完成,成功返回结果");
|
||||
return sha256Hash;
|
||||
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
LogUtils.e(TAG, "getApkSHA256Hash: 获取SHA-256算法实例失败", e);
|
||||
} catch (Exception e) {
|
||||
LogUtils.e(TAG, "getApkSHA256Hash: 计算APK SHA256哈希发生未知异常", e);
|
||||
} finally {
|
||||
// 强制关闭流,避免重签名APK解析的流泄漏
|
||||
try {
|
||||
if (fis != null) fis.close();
|
||||
if (jarFile != null) jarFile.close();
|
||||
} catch (IOException e) {
|
||||
LogUtils.e(TAG, "getApkSHA256Hash: 关闭流资源异常", e);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ===================================== 内部核心工具方法(兼容重签名) =====================================
|
||||
/**
|
||||
* 方案1:遍历APK内META-INF所有.RSA/.rsa文件,读取第一个有效文件计算签名
|
||||
* @param context 上下文
|
||||
* @return 签名Base64,失败返回null
|
||||
*/
|
||||
private static String getSignFromApkRsaFile(Context context) {
|
||||
JarFile jarFile = null;
|
||||
InputStream is = null;
|
||||
try {
|
||||
// 获取APK路径
|
||||
ApplicationInfo appInfo = context.getApplicationContext().getApplicationInfo();
|
||||
String apkPath = appInfo.sourceDir;
|
||||
if (apkPath == null || apkPath.trim().isEmpty()) {
|
||||
LogUtils.w(TAG, "getSignFromApkRsaFile: APK路径为空,跳过该方案");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 打开APK的JarFile
|
||||
jarFile = new JarFile(apkPath);
|
||||
Enumeration<JarEntry> entries = jarFile.entries();
|
||||
JarEntry targetRsaEntry = null;
|
||||
|
||||
// 遍历所有条目,找到META-INF下第一个.RSA/.rsa文件
|
||||
while (entries.hasMoreElements()) {
|
||||
JarEntry entry = entries.nextElement();
|
||||
String entryName = entry.getName();
|
||||
// 过滤:META-INF目录下 + 以.RSA/.rsa结尾 + 非目录
|
||||
if (entryName.startsWith(META_INF_DIR) && !entry.isDirectory()
|
||||
&& (entryName.endsWith(RSA_SUFFIX_UPPER) || entryName.endsWith(RSA_SUFFIX_LOWER))) {
|
||||
targetRsaEntry = entry;
|
||||
LogUtils.d(TAG, "getSignFromApkRsaFile: 找到有效签名文件,name=" + entryName);
|
||||
break; // 取第一个有效RSA文件即可
|
||||
}
|
||||
}
|
||||
|
||||
// 未找到任何RSA文件
|
||||
if (targetRsaEntry == null) {
|
||||
LogUtils.w(TAG, "getSignFromApkRsaFile: 未在META-INF找到任何.RSA/.rsa签名文件");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 读取RSA文件原始字节
|
||||
is = jarFile.getInputStream(targetRsaEntry);
|
||||
byte[] certRawBytes = readStreamToBytes(is);
|
||||
if (certRawBytes == null || certRawBytes.length == 0) {
|
||||
LogUtils.w(TAG, "getSignFromApkRsaFile: 读取RSA文件字节为空");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 计算SHA1+Base64
|
||||
MessageDigest md = MessageDigest.getInstance(ALGORITHM_SHA1);
|
||||
byte[] signDigest = md.digest(certRawBytes);
|
||||
return Base64.encodeToString(signDigest, Base64.NO_WRAP);
|
||||
|
||||
} catch (Exception e) {
|
||||
LogUtils.e(TAG, "getSignFromApkRsaFile: 从APK内读取RSA文件失败", e);
|
||||
return null;
|
||||
} finally {
|
||||
// 强制关闭所有流
|
||||
try {
|
||||
if (is != null) is.close();
|
||||
if (jarFile != null) jarFile.close();
|
||||
} catch (IOException e) {
|
||||
LogUtils.e(TAG, "getSignFromApkRsaFile: 关闭流资源异常", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 方案2:兜底 - 通过PackageManager获取系统解析的应用签名
|
||||
* 避免APK文件读取失败(如权限、解析问题),兼容所有重签名场景
|
||||
* @param context 上下文
|
||||
* @return 签名Base64,失败返回null
|
||||
*/
|
||||
private static String getSignFromPackageManager(Context context) {
|
||||
try {
|
||||
// 获取当前应用包信息(包含签名)
|
||||
PackageInfo packageInfo = context.getPackageManager()
|
||||
.getPackageInfo(context.getPackageName(), PackageManager.GET_SIGNATURES);
|
||||
if (packageInfo == null || packageInfo.signatures == null || packageInfo.signatures.length == 0) {
|
||||
LogUtils.w(TAG, "getSignFromPackageManager: 未获取到应用签名信息");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 取第一个签名(重签名后一般只有一个签名)
|
||||
Signature signature = packageInfo.signatures[0];
|
||||
byte[] signBytes = signature.toByteArray();
|
||||
|
||||
// 计算SHA1+Base64,与服务端逻辑对齐
|
||||
MessageDigest md = MessageDigest.getInstance(ALGORITHM_SHA1);
|
||||
byte[] signDigest = md.digest(signBytes);
|
||||
return Base64.encodeToString(signDigest, Base64.NO_WRAP);
|
||||
|
||||
} catch (PackageManager.NameNotFoundException e) {
|
||||
LogUtils.e(TAG, "getSignFromPackageManager: 包名未找到,无法获取签名", e);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
LogUtils.e(TAG, "getSignFromPackageManager: 获取SHA1算法实例失败", e);
|
||||
} catch (Exception e) {
|
||||
LogUtils.e(TAG, "getSignFromPackageManager: PackageManager获取签名失败", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 输入流转字节数组,通用工具方法(完善try-finally)
|
||||
* 4K缓冲区,适配小文件读取(如RSA签名文件),保证流资源正常关闭
|
||||
* @param is 待读取的输入流
|
||||
* @return 转换后的字节数组,流为null/读取失败返回空字节数组
|
||||
* @throws IOException 流读取相关异常向上抛出
|
||||
*/
|
||||
private static byte[] readStreamToBytes(InputStream is) throws IOException {
|
||||
if (is == null) {
|
||||
LogUtils.w(TAG, "readStreamToBytes: 入参输入流为null,返回空字节数组");
|
||||
return new byte[0];
|
||||
}
|
||||
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||||
byte[] buffer = new byte[BUFFER_4K];
|
||||
int readLen;
|
||||
try {
|
||||
while ((readLen = is.read(buffer)) != -1) {
|
||||
bos.write(buffer, 0, readLen);
|
||||
}
|
||||
return bos.toByteArray();
|
||||
} finally {
|
||||
// 强制关闭所有流
|
||||
is.close();
|
||||
bos.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
package cc.winboll.studio.libappbase.utils;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Environment;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.Charset;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
import cc.winboll.studio.libappbase.LogUtils;
|
||||
import cc.winboll.studio.libappbase.models.SFTPAuthModel;
|
||||
|
||||
/**
|
||||
* 文件备份工具类(单例模式)
|
||||
* 区分应用Data目录/应用专属外部文件目录双Map管理备份文件路径
|
||||
* 核心功能:文件添加/移除 + ZIP打包(分data/sdcard目录) + SFTP分步式上传(登录→传输→登出)
|
||||
* 依赖:FTPUtils(单例)、SFTPAuthModel(外部实体类)、Android上下文
|
||||
* 兼容:Java7、Android 6.0+,无第三方依赖(ZIP为原生实现),免动态读写权限
|
||||
* @Author 豆包&ZhanGSKen<zhangsken@qq.com>
|
||||
* @Date 2026/01/30 20:18:00
|
||||
* @LastEditTime 2026/02/01 02:05:00
|
||||
*/
|
||||
public class BackupUtils {
|
||||
public static final String TAG = "BackupUtils";
|
||||
// ZIP内部分级目录常量(统一维护,便于修改)
|
||||
private static final String ZIP_DIR_DATA = "data/";
|
||||
private static final String ZIP_DIR_SDCARD = "sdcard/";
|
||||
|
||||
// 单例实例(双重校验锁,volatile保证可见性,线程安全)
|
||||
private static volatile BackupUtils sInstance;
|
||||
|
||||
// 双Map分目录管理:key=文件唯一标识,value=对应目录下的相对路径
|
||||
private final Map<String, String> mDataDirFileMap; // 基础根目录:应用私有Data目录(/data/data/[包名]/files)
|
||||
private final Map<String, String> mSdcardFileMap; // 基础根目录:应用专属外部文件目录(/storage/emulated/0/Android/data/[包名]/files)
|
||||
|
||||
// 全局上下文(持有Application上下文,避免Activity内存泄漏)
|
||||
private Context mAppContext;
|
||||
// SFTP认证配置(直接引用外部实体类,无内部封装)
|
||||
private SFTPAuthModel mFtpAuthModel;
|
||||
// SFTP服务器指定上传目录(独立参数传入,标准化后作为成员变量)
|
||||
private String mFtpTargetDir;
|
||||
// 应用专属外部文件目录(SDCard Map的基础根目录,初始化时赋值,避免重复创建)
|
||||
private File mAppExternalFilesDir;
|
||||
|
||||
// 私有构造器:新增双Map入参,空值则使用内部默认初始化,非空则用入参初始化
|
||||
private BackupUtils(Context context, SFTPAuthModel ftpAuthModel, String ftpTargetDir,
|
||||
Map<String, String> dataDirFileMap, Map<String, String> sdcardFileMap) {
|
||||
this.mAppContext = context.getApplicationContext();
|
||||
this.mFtpAuthModel = ftpAuthModel;
|
||||
// 初始化SDCard Map的基础根目录:应用专属外部文件目录(/storage/emulated/0/Android/data/[包名]/files)
|
||||
this.mAppExternalFilesDir = mAppContext.getExternalFilesDir(null);
|
||||
// 标准化SFTP上传目录:空则默认/,非空则补全结尾斜杠
|
||||
this.mFtpTargetDir = TextUtils.isEmpty(ftpTargetDir) ? "/" : (ftpTargetDir.endsWith("/") ? ftpTargetDir : ftpTargetDir + "/");
|
||||
|
||||
// 核心修改:入参Map非空且非空集合时,使用入参初始化;否则内部new HashMap()
|
||||
this.mDataDirFileMap = (dataDirFileMap != null && !dataDirFileMap.isEmpty())
|
||||
? new HashMap<>(dataDirFileMap) // 新建Map避免外部篡改内部数据
|
||||
: new HashMap<>();
|
||||
this.mSdcardFileMap = (sdcardFileMap != null && !sdcardFileMap.isEmpty())
|
||||
? new HashMap<>(sdcardFileMap) // 深拷贝,隔离外部引用
|
||||
: new HashMap<>();
|
||||
|
||||
LogUtils.d(TAG, "BackupUtils初始化完成 → SFTP服务器:" + ftpAuthModel.getFtpServer() + ":" + ftpAuthModel.getFtpPort() + " | 上传目录:" + mFtpTargetDir);
|
||||
LogUtils.d(TAG, "SDCard Map基础根目录:" + (mAppExternalFilesDir == null ? "获取失败" : mAppExternalFilesDir.getAbsolutePath()));
|
||||
LogUtils.d(TAG, "初始化后DataMap大小:" + mDataDirFileMap.size() + " | SdcardMap大小:" + mSdcardFileMap.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* 单例初始化方法(必须先调用,否则getInstance()会抛异常)
|
||||
* 新增双Map入参,支持外部初始化待备份文件列表
|
||||
* @param context 上下文(推荐传Application,避免内存泄漏)
|
||||
* @param ftpAuthModel 外部SFTP认证实体类(含服务器/账号/端口等)
|
||||
* @param ftpTargetDir SFTP服务器指定上传目录(如/backup,自动补全斜杠)
|
||||
* @param dataDirFileMap 外部传入的Data目录文件Map,null/空则内部默认初始化
|
||||
* @param sdcardFileMap 外部传入的SDCard目录文件Map,null/空则内部默认初始化
|
||||
* @return BackupUtils单例实例
|
||||
*/
|
||||
public static BackupUtils getInstance(Context context, SFTPAuthModel ftpAuthModel, String ftpTargetDir,
|
||||
Map<String, String> dataDirFileMap, Map<String, String> sdcardFileMap) {
|
||||
if (sInstance == null) {
|
||||
synchronized (BackupUtils.class) {
|
||||
if (sInstance == null) {
|
||||
// 前置强校验:避免空参数导致后续空指针
|
||||
if (context == null) {
|
||||
throw new IllegalArgumentException("初始化失败:Context 不能为空");
|
||||
}
|
||||
if (ftpAuthModel == null || TextUtils.isEmpty(ftpAuthModel.getFtpServer())) {
|
||||
throw new IllegalArgumentException("初始化失败:SFTPAuthModel/ftpServer 不能为空");
|
||||
}
|
||||
// 透传新增的双Map入参至构造器
|
||||
sInstance = new BackupUtils(context, ftpAuthModel, ftpTargetDir, dataDirFileMap, sdcardFileMap);
|
||||
}
|
||||
}
|
||||
}
|
||||
return sInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重载默认初始化方法:兼容原有调用逻辑,无需传入Map,内部默认初始化
|
||||
* 避免修改后影响原有代码调用
|
||||
*/
|
||||
public static BackupUtils getInstance(Context context, SFTPAuthModel ftpAuthModel, String ftpTargetDir) {
|
||||
return getInstance(context, ftpAuthModel, ftpTargetDir, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单例实例(需先调用带参getInstance初始化)
|
||||
* @return BackupUtils单例实例
|
||||
*/
|
||||
public static BackupUtils getInstance() {
|
||||
if (sInstance == null) {
|
||||
throw new IllegalStateException("BackupUtils未初始化,请先调用getInstance(Context, SFTPAuthModel, String[, Map, Map])");
|
||||
}
|
||||
return sInstance;
|
||||
}
|
||||
|
||||
// ====================================== 以下原有方法均未修改 ======================================
|
||||
public void addDataDirFile(String key, String relativePath) {
|
||||
if (!TextUtils.isEmpty(key) && !TextUtils.isEmpty(relativePath)) {
|
||||
mDataDirFileMap.put(key, relativePath);
|
||||
LogUtils.d(TAG, "添加Data目录文件:" + key + " → " + relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
public void removeDataDirFile(String key) {
|
||||
if (!TextUtils.isEmpty(key) && mDataDirFileMap.containsKey(key)) {
|
||||
mDataDirFileMap.remove(key);
|
||||
LogUtils.d(TAG, "移除Data目录文件:" + key);
|
||||
}
|
||||
}
|
||||
|
||||
public String getDataDirFile(String key) {
|
||||
return mDataDirFileMap.get(key);
|
||||
}
|
||||
|
||||
public Map<String, String> getAllDataDirFiles() {
|
||||
return new HashMap<>(mDataDirFileMap);
|
||||
}
|
||||
|
||||
public void clearDataDirFiles() {
|
||||
mDataDirFileMap.clear();
|
||||
LogUtils.d(TAG, "清空Data目录所有备份文件");
|
||||
}
|
||||
|
||||
public void addSdcardFile(String key, String relativePath) {
|
||||
if (!TextUtils.isEmpty(key) && !TextUtils.isEmpty(relativePath) && mAppExternalFilesDir != null) {
|
||||
mSdcardFileMap.put(key, relativePath);
|
||||
LogUtils.d(TAG, "添加外部文件目录文件:" + key + " → " + relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
public void removeSdcardFile(String key) {
|
||||
if (!TextUtils.isEmpty(key) && mSdcardFileMap.containsKey(key)) {
|
||||
mSdcardFileMap.remove(key);
|
||||
LogUtils.d(TAG, "移除外部文件目录文件:" + key);
|
||||
}
|
||||
}
|
||||
|
||||
public String getSdcardFile(String key) {
|
||||
return mSdcardFileMap.get(key);
|
||||
}
|
||||
|
||||
public Map<String, String> getAllSdcardFiles() {
|
||||
return new HashMap<>(mSdcardFileMap);
|
||||
}
|
||||
|
||||
public void clearSdcardFiles() {
|
||||
mSdcardFileMap.clear();
|
||||
LogUtils.d(TAG, "清空外部文件目录所有备份文件");
|
||||
}
|
||||
|
||||
public boolean packAndUploadByFtp() {
|
||||
if (mDataDirFileMap.isEmpty() && mSdcardFileMap.isEmpty()) {
|
||||
LogUtils.e(TAG, "SFTP上传失败:无待备份文件(DataDir+外部文件目录均为空)");
|
||||
return false;
|
||||
}
|
||||
if (mAppExternalFilesDir == null) {
|
||||
LogUtils.e(TAG, "SFTP上传失败:应用专属外部文件目录获取失败,无法访问文件");
|
||||
return false;
|
||||
}
|
||||
|
||||
String zipFileName = UUID.randomUUID().toString().replace("-", "")
|
||||
+ "-" + System.currentTimeMillis() + ".zip";
|
||||
File tempZipFile = new File(mAppContext.getExternalCacheDir(), zipFileName);
|
||||
String remoteFtpFilePath = mFtpTargetDir + zipFileName;
|
||||
|
||||
FTPUtils ftpUtils = FTPUtils.getInstance();
|
||||
boolean isUploadSuccess = false;
|
||||
|
||||
try {
|
||||
LogUtils.d(TAG, "开始SFTP登录:" + mFtpAuthModel.getFtpServer() + ":" + mFtpAuthModel.getFtpPort());
|
||||
boolean isFtpLogin = ftpUtils.login(mFtpAuthModel);
|
||||
if (!isFtpLogin) {
|
||||
LogUtils.e(TAG, "SFTP上传失败:SFTP登录失败(账号/密码/服务器/端口错误)");
|
||||
return false;
|
||||
}
|
||||
LogUtils.i(TAG, "SFTP登录成功,准备打包文件:" + zipFileName);
|
||||
|
||||
LogUtils.d(TAG, "开始本地ZIP打包(分data/sdcard目录),临时文件路径:" + tempZipFile.getAbsolutePath());
|
||||
boolean isPackSuccess = packFilesToZip(tempZipFile);
|
||||
if (!isPackSuccess || !tempZipFile.exists() || tempZipFile.length() == 0) {
|
||||
LogUtils.e(TAG, "SFTP上传失败:ZIP打包失败(文件不存在/空文件)");
|
||||
return false;
|
||||
}
|
||||
LogUtils.i(TAG, "ZIP打包成功,文件大小:" + tempZipFile.length() / 1024 + "KB");
|
||||
|
||||
LogUtils.d(TAG, "开始SFTP上传:本地→SFTP" + remoteFtpFilePath);
|
||||
isUploadSuccess = ftpUtils.uploadFile(tempZipFile.getAbsolutePath(), remoteFtpFilePath);
|
||||
if (isUploadSuccess) {
|
||||
LogUtils.i(TAG, "SFTP上传全流程成功:" + remoteFtpFilePath);
|
||||
} else {
|
||||
LogUtils.e(TAG, "SFTP上传失败:文件传输到服务器失败(响应码异常/权限不足)");
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
LogUtils.e(TAG, "SFTP上传异常:" + e.getMessage(), e);
|
||||
isUploadSuccess = false;
|
||||
} finally {
|
||||
if (ftpUtils.isConnected()) {
|
||||
ftpUtils.logout();
|
||||
}
|
||||
ftpUtils.disconnect();
|
||||
if (tempZipFile.exists()) {
|
||||
boolean isDelete = tempZipFile.delete();
|
||||
LogUtils.d(TAG, "本地临时ZIP文件删除:" + (isDelete ? "成功" : "失败"));
|
||||
}
|
||||
System.gc();
|
||||
}
|
||||
|
||||
return isUploadSuccess;
|
||||
}
|
||||
|
||||
private boolean packFilesToZip(File zipFile) {
|
||||
ZipOutputStream zos = null;
|
||||
try {
|
||||
zos = new ZipOutputStream(new FileOutputStream(zipFile), Charset.forName("UTF-8"));
|
||||
zos.setLevel(ZipOutputStream.DEFLATED);
|
||||
|
||||
if (!mDataDirFileMap.isEmpty()) {
|
||||
packDirFilesToZip(zos, mDataDirFileMap, mAppContext.getFilesDir(), ZIP_DIR_DATA);
|
||||
LogUtils.d(TAG, "Data目录文件已打包到ZIP→" + ZIP_DIR_DATA + "子目录");
|
||||
}
|
||||
if (!mSdcardFileMap.isEmpty() && mAppExternalFilesDir != null) {
|
||||
packDirFilesToZip(zos, mSdcardFileMap, mAppExternalFilesDir, ZIP_DIR_SDCARD);
|
||||
LogUtils.d(TAG, "应用专属外部文件目录文件已打包到ZIP→" + ZIP_DIR_SDCARD + "子目录");
|
||||
}
|
||||
|
||||
zos.flush();
|
||||
return true;
|
||||
} catch (IOException e) {
|
||||
LogUtils.e(TAG, "ZIP打包IO异常:" + e.getMessage(), e);
|
||||
return false;
|
||||
} finally {
|
||||
if (zos != null) {
|
||||
try {
|
||||
zos.close();
|
||||
} catch (IOException e) {
|
||||
LogUtils.e(TAG, "关闭ZIP流异常:" + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void packDirFilesToZip(ZipOutputStream zos, Map<String, String> fileMap, File baseDir, String zipSubDir) {
|
||||
for (Map.Entry<String, String> entry : fileMap.entrySet()) {
|
||||
String relativePath = entry.getValue();
|
||||
if (TextUtils.isEmpty(relativePath)) {
|
||||
continue;
|
||||
}
|
||||
File localFile = new File(baseDir, relativePath);
|
||||
if (!localFile.exists() || !localFile.isFile()) {
|
||||
LogUtils.w(TAG, "跳过无效文件:" + localFile.getAbsolutePath());
|
||||
continue;
|
||||
}
|
||||
String zipInnerPath = zipSubDir + relativePath;
|
||||
try {
|
||||
addSingleFileToZip(zos, localFile, zipInnerPath);
|
||||
} catch (IOException e) {
|
||||
LogUtils.e(TAG, "打包单个文件失败:" + zipInnerPath, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void addSingleFileToZip(ZipOutputStream zos, File localFile, String zipInnerPath) throws IOException {
|
||||
ZipEntry zipEntry = new ZipEntry(zipInnerPath);
|
||||
zos.putNextEntry(zipEntry);
|
||||
FileInputStream fis = new FileInputStream(localFile);
|
||||
byte[] buffer = new byte[4096];
|
||||
int len;
|
||||
while ((len = fis.read(buffer)) != -1) {
|
||||
zos.write(buffer, 0, len);
|
||||
}
|
||||
fis.close();
|
||||
zos.closeEntry();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,487 @@
|
||||
package cc.winboll.studio.libappbase.utils;
|
||||
|
||||
import cc.winboll.studio.libappbase.LogUtils;
|
||||
import cc.winboll.studio.libappbase.models.SFTPAuthModel;
|
||||
import com.jcraft.jsch.ChannelSftp;
|
||||
import com.jcraft.jsch.JSch;
|
||||
import com.jcraft.jsch.JSchException;
|
||||
import com.jcraft.jsch.Session;
|
||||
import com.jcraft.jsch.SftpATTRS;
|
||||
import com.jcraft.jsch.SftpException;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.util.Properties;
|
||||
import java.util.Vector;
|
||||
|
||||
/**
|
||||
* SFTP/FTP工具类(单例模式)- Java7兼容 · 适配FTPAuthModel实体类
|
||||
* 底层严格基于JSch 0.1.54原生ChannelSftp+SftpException接口实现,替换原commons-net FTP
|
||||
* 核心功能:登录/登出、文件上传/下载、文件夹列举、文件/文件夹存在性判断
|
||||
* 依赖:com.jcraft:jsch:0.1.54
|
||||
* @Author 豆包&ZhanGSKen<zhangsken@qq.com>
|
||||
* @Date 2026/01/30 19:04
|
||||
*/
|
||||
public class FTPUtils {
|
||||
// 单例实例(双重校验锁 volatile 保证可见性,Java7兼容)
|
||||
private static volatile FTPUtils sInstance;
|
||||
// JSch核心对象:Session(连接会话)、ChannelSftp(SFTP通道)
|
||||
private JSch mJSch;
|
||||
private Session mSession;
|
||||
private ChannelSftp mSftpChannel;
|
||||
// 日志TAG
|
||||
public static final String TAG = "FTPUtils";
|
||||
// SFTP默认端口(FTPAuthModel未设置时使用)
|
||||
private static final int DEFAULT_SFTP_PORT = 22;
|
||||
// 连接超时时间 5s(Java7原生Socket超时)
|
||||
private static final int CONNECT_TIMEOUT = 5000;
|
||||
|
||||
// 私有构造器:禁止外部实例化
|
||||
private FTPUtils() {
|
||||
initSftpClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单例实例(双重校验锁,线程安全,Java7兼容)
|
||||
* @return FTPUtils 单例
|
||||
*/
|
||||
public static FTPUtils getInstance() {
|
||||
if (sInstance == null) {
|
||||
synchronized (FTPUtils.class) {
|
||||
if (sInstance == null) {
|
||||
sInstance = new FTPUtils();
|
||||
}
|
||||
}
|
||||
}
|
||||
return sInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化SFTP客户端(JSch),创建核心原生对象
|
||||
*/
|
||||
private void initSftpClient() {
|
||||
if (mJSch == null) {
|
||||
mJSch = new JSch();
|
||||
LogUtils.d(TAG, "SFTP客户端(JSch)初始化完成");
|
||||
}
|
||||
// 重置会话和通道,避免连接残留
|
||||
mSession = null;
|
||||
mSftpChannel = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 【推荐】SFTP登录(基于FTPAuthModel实体类,完全兼容原有参数)
|
||||
* @param ftpAuthModel 登录配置实体类(不能为空,端口默认22,编码默认UTF-8)
|
||||
* @return 登录成功返回true,失败false
|
||||
*/
|
||||
public boolean login(SFTPAuthModel ftpAuthModel) {
|
||||
// 1. 实体类非空校验
|
||||
if (ftpAuthModel == null) {
|
||||
LogUtils.e(TAG, "SFTP登录失败:FTPAuthModel实体类为null");
|
||||
return false;
|
||||
}
|
||||
// 2. 核心参数校验(服务器地址不能为空)
|
||||
if (isParamEmpty(ftpAuthModel.getFtpServer())) {
|
||||
LogUtils.e(TAG, "SFTP登录失败:服务器地址(ftpServer)不能为空");
|
||||
return false;
|
||||
}
|
||||
// 3. 若已连接,先断开
|
||||
if (isConnected()) {
|
||||
logout();
|
||||
}
|
||||
// 4. 重新初始化客户端
|
||||
initSftpClient();
|
||||
|
||||
try {
|
||||
// 获取服务器地址、端口(默认22)、账号、密码
|
||||
String host = ftpAuthModel.getFtpServer();
|
||||
int port = ftpAuthModel.getFtpPort() <= 0 ? DEFAULT_SFTP_PORT : ftpAuthModel.getFtpPort();
|
||||
String username = ftpAuthModel.getFtpUsername();
|
||||
String password = ftpAuthModel.getFtpPassword();
|
||||
|
||||
// SFTP不支持匿名登录,账号密码不能为空(原生接口无匿名登录能力)
|
||||
if (isParamEmpty(username) || isParamEmpty(password)) {
|
||||
LogUtils.e(TAG, "SFTP登录失败:SFTP不支持匿名登录,请配置有效账号密码");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 1. 创建JSch会话(原生接口)
|
||||
mSession = mJSch.getSession(username, host, port);
|
||||
mSession.setPassword(password);
|
||||
|
||||
// 2. 设置会话属性(跳过SSH密钥校验,适配大部分服务器)
|
||||
Properties sessionProps = new Properties();
|
||||
sessionProps.put("StrictHostKeyChecking", "no");
|
||||
sessionProps.put("PreferredAuthentications", "password");
|
||||
mSession.setConfig(sessionProps);
|
||||
|
||||
// 3. 设置会话连接超时(原生接口,底层Socket超时)
|
||||
mSession.setTimeout(CONNECT_TIMEOUT);
|
||||
|
||||
// 4. 建立会话连接(原生接口)
|
||||
mSession.connect();
|
||||
LogUtils.d(TAG, "SFTP会话连接成功:" + host + ":" + port);
|
||||
|
||||
// 5. 打开SFTP通道(类型:sftp,原生接口强转)
|
||||
mSftpChannel = (ChannelSftp) mSession.openChannel("sftp");
|
||||
mSftpChannel.connect();
|
||||
|
||||
// 6. 设置文件名编码(解决中文乱码,ChannelSftp原生接口)
|
||||
String charset = isParamEmpty(ftpAuthModel.getFtpCharset()) ? "UTF-8" : ftpAuthModel.getFtpCharset();
|
||||
mSftpChannel.setFilenameEncoding(charset);
|
||||
LogUtils.d(TAG, "SFTP文件名编码设置成功:" + charset);
|
||||
|
||||
LogUtils.i(TAG, "SFTP登录成功,服务器:" + host + ":" + port + ",用户名:" + username);
|
||||
return true;
|
||||
|
||||
} catch (JSchException e) {
|
||||
LogUtils.e(TAG, "SFTP登录JSch异常:" + e.getMessage(), e);
|
||||
logout();
|
||||
return false;
|
||||
} catch (SftpException e) {
|
||||
// 匹配SftpException原生属性和方法
|
||||
LogUtils.e(TAG, "SFTP通道初始化异常:id=" + e.id + ",msg=" + e.getMessage() + ",detail=" + e.toString());
|
||||
logout();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 【已废弃】原FTP多参数登录方法,适配JSch后保留,推荐使用login(FTPAuthModel)
|
||||
* @deprecated 请使用基于FTPAuthModel的登录方法
|
||||
*/
|
||||
@Deprecated
|
||||
public boolean login(String host, int port, String username, String password) {
|
||||
SFTPAuthModel ftpAuthModel = new SFTPAuthModel();
|
||||
ftpAuthModel.setFtpServer(host);
|
||||
ftpAuthModel.setFtpPort(port <= 0 ? DEFAULT_SFTP_PORT : port);
|
||||
ftpAuthModel.setFtpUsername(username);
|
||||
ftpAuthModel.setFtpPassword(password);
|
||||
return login(ftpAuthModel);
|
||||
}
|
||||
|
||||
/**
|
||||
* SFTP登出并断开连接,释放所有资源(严格调用原生disconnect接口)
|
||||
* @return 登出成功返回true,失败false
|
||||
*/
|
||||
public boolean logout() {
|
||||
boolean isSuccess = true;
|
||||
// 关闭SFTP通道(原生接口disconnect,非空判断即可)
|
||||
if (mSftpChannel != null) {
|
||||
try {
|
||||
mSftpChannel.disconnect();
|
||||
LogUtils.d(TAG, "SFTP通道已断开");
|
||||
} catch (Exception e) {
|
||||
LogUtils.e(TAG, "关闭SFTP通道异常:" + e.getMessage(), e);
|
||||
isSuccess = false;
|
||||
}
|
||||
}
|
||||
// 关闭JSch会话(原生接口disconnect,非空判断即可)
|
||||
if (mSession != null) {
|
||||
try {
|
||||
mSession.disconnect();
|
||||
LogUtils.d(TAG, "SFTP会话已断开");
|
||||
} catch (Exception e) {
|
||||
LogUtils.e(TAG, "关闭SFTP会话异常:" + e.getMessage(), e);
|
||||
isSuccess = false;
|
||||
}
|
||||
}
|
||||
// 重置客户端,避免资源残留
|
||||
initSftpClient();
|
||||
if (isSuccess) {
|
||||
LogUtils.i(TAG, "SFTP登出成功");
|
||||
} else {
|
||||
LogUtils.w(TAG, "SFTP登出失败:部分资源未正常释放");
|
||||
}
|
||||
return isSuccess;
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制断开连接(兜底资源释放),同logout方法
|
||||
*/
|
||||
public void disconnect() {
|
||||
logout();
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断SFTP是否已连接(会话+通道均调用原生isConnected接口)
|
||||
* @return 已连接返回true,否则false
|
||||
*/
|
||||
public boolean isConnected() {
|
||||
return mSession != null && mSession.isConnected()
|
||||
&& mSftpChannel != null && mSftpChannel.isConnected();
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件到SFTP指定路径(覆盖式上传,调用ChannelSftp原生put接口,OVERWRITE模式)
|
||||
* @param localFilePath 本地文件绝对路径(如/sdcard/test.apk)
|
||||
* @param remoteFilePath SFTP服务器目标路径(如/ftp/apk/test.apk,需包含文件名)
|
||||
* @return 上传成功返回true,失败false
|
||||
*/
|
||||
public boolean uploadFile(String localFilePath, String remoteFilePath) {
|
||||
// 前置校验
|
||||
if (!isConnected()) {
|
||||
LogUtils.e(TAG, "文件上传失败:SFTP未连接服务器");
|
||||
return false;
|
||||
}
|
||||
if (isParamEmpty(localFilePath) || isParamEmpty(remoteFilePath)) {
|
||||
LogUtils.e(TAG, "文件上传失败:本地/远程路径不能为空");
|
||||
return false;
|
||||
}
|
||||
File localFile = new File(localFilePath);
|
||||
if (!localFile.exists() || !localFile.isFile()) {
|
||||
LogUtils.e(TAG, "文件上传失败:本地文件不存在/非文件,路径:" + localFilePath);
|
||||
return false;
|
||||
}
|
||||
|
||||
InputStream fis = null;
|
||||
try {
|
||||
// 自动创建远程多级目录(基于原生mkdir/stat接口)
|
||||
createRemoteDir(remoteFilePath);
|
||||
// 读取本地文件,上传到SFTP(原生put接口,OVERWRITE覆盖模式)
|
||||
fis = new FileInputStream(localFile);
|
||||
mSftpChannel.put(fis, remoteFilePath, ChannelSftp.OVERWRITE);
|
||||
LogUtils.i(TAG, "文件上传成功:本地" + localFilePath + " → 远程" + remoteFilePath);
|
||||
return true;
|
||||
} catch (IOException e) {
|
||||
LogUtils.e(TAG, "文件上传IO异常:" + e.getMessage(), e);
|
||||
return false;
|
||||
} catch (SftpException e) {
|
||||
// 严格匹配SftpException原生属性:id、getMessage()、toString()
|
||||
LogUtils.e(TAG, "文件上传SFTP异常:id=" + e.id + ",msg=" + e.getMessage() + ",detail=" + e.toString());
|
||||
return false;
|
||||
} finally {
|
||||
// 关闭流资源,避免内存泄漏
|
||||
closeStream(fis, null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从SFTP下载文件到本地指定路径(覆盖式下载,调用ChannelSftp原生get接口)
|
||||
* @param remoteFilePath SFTP服务器文件路径(如/ftp/apk/test.apk)
|
||||
* @param localFilePath 本地目标路径(如/sdcard/test.apk,需包含文件名)
|
||||
* @return 下载成功返回true,失败false
|
||||
*/
|
||||
public boolean downloadFile(String remoteFilePath, String localFilePath) {
|
||||
// 前置校验
|
||||
if (!isConnected()) {
|
||||
LogUtils.e(TAG, "文件下载失败:SFTP未连接服务器");
|
||||
return false;
|
||||
}
|
||||
if (isParamEmpty(remoteFilePath) || isParamEmpty(localFilePath)) {
|
||||
LogUtils.e(TAG, "文件下载失败:远程/本地路径不能为空");
|
||||
return false;
|
||||
}
|
||||
// 校验远程文件是否存在(基于ChannelSftp原生stat接口)
|
||||
if (!isFileExists(remoteFilePath)) {
|
||||
LogUtils.e(TAG, "文件下载失败:远程文件不存在,路径:" + remoteFilePath);
|
||||
return false;
|
||||
}
|
||||
|
||||
OutputStream fos = null;
|
||||
try {
|
||||
// 创建本地多级目录
|
||||
File localFile = new File(localFilePath);
|
||||
File parentDir = localFile.getParentFile();
|
||||
if (!parentDir.exists() && !parentDir.mkdirs()) {
|
||||
LogUtils.e(TAG, "文件下载失败:创建本地目录失败,路径:" + parentDir.getAbsolutePath());
|
||||
return false;
|
||||
}
|
||||
// 从SFTP读取文件,写入本地(原生get接口)
|
||||
fos = new FileOutputStream(localFile);
|
||||
mSftpChannel.get(remoteFilePath, fos);
|
||||
LogUtils.i(TAG, "文件下载成功:远程" + remoteFilePath + " → 本地" + localFilePath);
|
||||
return true;
|
||||
} catch (IOException e) {
|
||||
LogUtils.e(TAG, "文件下载IO异常:" + e.getMessage(), e);
|
||||
// 删除未下载完成的本地文件
|
||||
new File(localFilePath).delete();
|
||||
return false;
|
||||
} catch (SftpException e) {
|
||||
// 严格匹配SftpException原生属性:id、getMessage()、toString()
|
||||
LogUtils.e(TAG, "文件下载SFTP异常:id=" + e.id + ",msg=" + e.getMessage() + ",detail=" + e.toString());
|
||||
// 删除未下载完成的本地文件
|
||||
new File(localFilePath).delete();
|
||||
return false;
|
||||
} finally {
|
||||
// 关闭流资源,避免内存泄漏
|
||||
closeStream(null, fos);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 列举SFTP指定文件夹下的所有文件/文件夹(返回ChannelSftp原生Vector,过滤.和..)
|
||||
* @param remoteDir SFTP服务器目录路径(如/ftp/apk/,结尾带/或不带均可)
|
||||
* @return 成功返回原生Vector<ChannelSftp.LsEntry>,失败返回空Vector
|
||||
*/
|
||||
@SuppressWarnings("rawtypes")
|
||||
public Vector listDir(String remoteDir) {
|
||||
Vector fileList = new Vector();
|
||||
// 前置校验
|
||||
if (!isConnected()) {
|
||||
LogUtils.e(TAG, "列举目录失败:SFTP未连接服务器");
|
||||
return fileList;
|
||||
}
|
||||
if (isParamEmpty(remoteDir)) {
|
||||
LogUtils.e(TAG, "列举目录失败:远程目录路径不能为空");
|
||||
return fileList;
|
||||
}
|
||||
// 校验目录是否存在(基于ChannelSftp原生stat接口)
|
||||
if (!isDirExists(remoteDir)) {
|
||||
LogUtils.e(TAG, "列举目录失败:远程目录不存在,路径:" + remoteDir);
|
||||
return fileList;
|
||||
}
|
||||
|
||||
try {
|
||||
// 列举目录下所有文件/文件夹(调用ChannelSftp原生ls接口,返回原生Vector)
|
||||
Vector vector = mSftpChannel.ls(remoteDir);
|
||||
if (vector != null && vector.size() > 0) {
|
||||
for (Object obj : vector) {
|
||||
// 过滤.和..上级目录,仅保留有效文件/目录
|
||||
ChannelSftp.LsEntry entry = (ChannelSftp.LsEntry) obj;
|
||||
String fileName = entry.getFilename();
|
||||
if (!".".equals(fileName) && !"..".equals(fileName)) {
|
||||
fileList.add(obj);
|
||||
}
|
||||
}
|
||||
}
|
||||
LogUtils.i(TAG, "列举目录成功:" + remoteDir + ",共" + fileList.size() + "个文件/文件夹");
|
||||
} catch (SftpException e) {
|
||||
// 严格匹配SftpException原生属性:id、getMessage()、toString()
|
||||
LogUtils.e(TAG, "列举目录SFTP异常:id=" + e.id + ",msg=" + e.getMessage() + ",detail=" + e.toString());
|
||||
}
|
||||
return fileList;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断SFTP服务器上**文件**是否存在(基于ChannelSftp原生stat接口,匹配SftpException原生异常)
|
||||
* @param remoteFilePath SFTP服务器文件路径(如/ftp/apk/test.apk)
|
||||
* @return 存在且为文件返回true,否则false
|
||||
*/
|
||||
public boolean isFileExists(String remoteFilePath) {
|
||||
// 前置校验
|
||||
if (!isConnected()) {
|
||||
LogUtils.e(TAG, "判断文件存在性失败:SFTP未连接服务器");
|
||||
return false;
|
||||
}
|
||||
if (isParamEmpty(remoteFilePath)) {
|
||||
LogUtils.e(TAG, "判断文件存在性失败:远程文件路径不能为空");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// 调用ChannelSftp原生stat接口获取属性,不存在会抛出SSH_FX_NO_SUCH_FILE异常
|
||||
SftpATTRS attrs = mSftpChannel.stat(remoteFilePath);
|
||||
// 原生isReg()判断是否为文件
|
||||
return attrs.isReg();
|
||||
} catch (SftpException e) {
|
||||
// 仅匹配原生异常码SSH_FX_NO_SUCH_FILE(2):文件/目录不存在,不记错误日志
|
||||
if (e.id != ChannelSftp.SSH_FX_NO_SUCH_FILE) {
|
||||
LogUtils.e(TAG, "判断文件存在性SFTP异常:id=" + e.id + ",msg=" + e.getMessage() + ",detail=" + e.toString());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断SFTP服务器上**文件夹**是否存在(基于ChannelSftp原生stat接口,匹配SftpException原生异常)
|
||||
* @param remoteDir SFTP服务器目录路径(如/ftp/apk/,结尾带/或不带均可)
|
||||
* @return 存在且为目录返回true,否则false
|
||||
*/
|
||||
public boolean isDirExists(String remoteDir) {
|
||||
// 前置校验
|
||||
if (!isConnected()) {
|
||||
LogUtils.e(TAG, "判断目录存在性失败:SFTP未连接服务器");
|
||||
return false;
|
||||
}
|
||||
if (isParamEmpty(remoteDir)) {
|
||||
LogUtils.e(TAG, "判断目录存在性失败:远程目录路径不能为空");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// 调用ChannelSftp原生stat接口获取属性,不存在会抛出SSH_FX_NO_SUCH_FILE异常
|
||||
SftpATTRS attrs = mSftpChannel.stat(remoteDir);
|
||||
// 原生isDir()判断是否为目录
|
||||
return attrs.isDir();
|
||||
} catch (SftpException e) {
|
||||
// 仅匹配原生异常码SSH_FX_NO_SUCH_FILE(2):文件/目录不存在,不记错误日志
|
||||
if (e.id != ChannelSftp.SSH_FX_NO_SUCH_FILE) {
|
||||
LogUtils.e(TAG, "判断目录存在性SFTP异常:id=" + e.id + ",msg=" + e.getMessage() + ",detail=" + e.toString());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ===================================== 内部工具方法(仅调用原生接口) =====================================
|
||||
/**
|
||||
* 递归创建SFTP远程多级目录(基于ChannelSftp原生mkdir/stat接口,不存在则创建)
|
||||
* @param remoteFilePath SFTP远程文件路径/目录路径
|
||||
*/
|
||||
private void createRemoteDir(String remoteFilePath) {
|
||||
if (!isConnected()) {
|
||||
LogUtils.e(TAG, "创建远程目录失败:SFTP未连接服务器");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// 提取目录路径(文件路径→目录路径,目录路径直接使用)
|
||||
String remoteDir = remoteFilePath.lastIndexOf("/") > 0
|
||||
? remoteFilePath.substring(0, remoteFilePath.lastIndexOf("/"))
|
||||
: remoteFilePath;
|
||||
// 按/分割多级目录,递归创建(避免多级目录不存在)
|
||||
String[] dirs = remoteDir.split("/");
|
||||
StringBuilder currentDir = new StringBuilder();
|
||||
for (String dir : dirs) {
|
||||
if (isParamEmpty(dir)) {
|
||||
continue;
|
||||
}
|
||||
currentDir.append("/").append(dir);
|
||||
String dirPath = currentDir.toString();
|
||||
// 目录不存在则调用ChannelSftp原生mkdir创建
|
||||
if (!isDirExists(dirPath)) {
|
||||
mSftpChannel.mkdir(dirPath);
|
||||
LogUtils.d(TAG, "创建SFTP远程目录成功:" + dirPath);
|
||||
}
|
||||
}
|
||||
} catch (SftpException e) {
|
||||
// 严格匹配SftpException原生属性:id、getMessage()、toString()
|
||||
LogUtils.e(TAG, "创建远程目录SFTP异常:id=" + e.id + ",msg=" + e.getMessage() + ",detail=" + e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭流资源(通用工具方法,Java7原生IO,避免内存泄漏)
|
||||
* @param is 输入流(可为null)
|
||||
* @param os 输出流(可为null)
|
||||
*/
|
||||
private void closeStream(InputStream is, OutputStream os) {
|
||||
if (is != null) {
|
||||
try {
|
||||
is.close();
|
||||
} catch (IOException e) {
|
||||
LogUtils.e(TAG, "关闭输入流异常:" + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
if (os != null) {
|
||||
try {
|
||||
os.close();
|
||||
} catch (IOException e) {
|
||||
LogUtils.e(TAG, "关闭输出流异常:" + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断参数是否为空(null/空字符串/全空格,Java7原生字符串操作)
|
||||
* @param param 待判断参数
|
||||
* @return 为空返回true,否则false
|
||||
*/
|
||||
private boolean isParamEmpty(String param) {
|
||||
return param == null || param.trim().isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,21 +51,21 @@ public class SignGetUtils {
|
||||
}
|
||||
|
||||
// 新增:直接返回签名字符串,供对话框调用
|
||||
public static String getSignStr(Context context) {
|
||||
if (context == null) return null;
|
||||
try {
|
||||
PackageManager pm = context.getPackageManager();
|
||||
PackageInfo pkgInfo = pm.getPackageInfo(context.getPackageName(), PackageManager.GET_SIGNATURES);
|
||||
Signature[] signatures = pkgInfo.signatures;
|
||||
if (signatures == null || signatures.length == 0) return null;
|
||||
|
||||
MessageDigest md = MessageDigest.getInstance("SHA1");
|
||||
md.update(signatures[0].toByteArray());
|
||||
return Base64.encodeToString(md.digest(), Base64.NO_WRAP);
|
||||
} catch (Exception e) {
|
||||
LogUtils.e(TAG, "获取签名字符串失败", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// public static String getSignStr(Context context) {
|
||||
// if (context == null) return null;
|
||||
// try {
|
||||
// PackageManager pm = context.getPackageManager();
|
||||
// PackageInfo pkgInfo = pm.getPackageInfo(context.getPackageName(), PackageManager.GET_SIGNATURES);
|
||||
// Signature[] signatures = pkgInfo.signatures;
|
||||
// if (signatures == null || signatures.length == 0) return null;
|
||||
//
|
||||
// MessageDigest md = MessageDigest.getInstance("SHA1");
|
||||
// md.update(signatures[0].toByteArray());
|
||||
// return Base64.encodeToString(md.digest(), Base64.NO_WRAP);
|
||||
// } catch (Exception e) {
|
||||
// LogUtils.e(TAG, "获取签名字符串失败", e);
|
||||
// return null;
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
|
||||
@@ -12,71 +12,81 @@ import android.widget.ImageButton;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
import cc.winboll.studio.libappbase.GlobalApplication;
|
||||
import cc.winboll.studio.libappbase.LogUtils;
|
||||
import cc.winboll.studio.libappbase.R;
|
||||
import cc.winboll.studio.libappbase.ToastUtils;
|
||||
import cc.winboll.studio.libappbase.dialogs.DebugHostDialog;
|
||||
import cc.winboll.studio.libappbase.dialogs.SignGetDialog;
|
||||
import cc.winboll.studio.libappbase.dialogs.AppValidationDialog;
|
||||
import cc.winboll.studio.libappbase.models.APPInfo;
|
||||
|
||||
/**
|
||||
* @Describe AboutView 原生实现关于页面,无第三方依赖,适配API30,抽象通用功能控件(邮件/网页跳转)
|
||||
* @Author 豆包&ZhanGSKen<zhangsken@qq.com>
|
||||
* @Date 2026/01/11 12:23:00
|
||||
* @LastEditTime 2026/01/20 20:45:00
|
||||
* @CreateTime 2026-01-11 12:23:00
|
||||
* @LastEditTime 2026-01-24 20:50:00
|
||||
* @Describe AboutView 原生实现关于页面,无第三方依赖,适配API30;抽象通用功能控件(邮件/网页跳转),支持调试工具入口动态显隐,集成应用正版校验、调试地址配置弹窗
|
||||
*/
|
||||
public class AboutView extends LinearLayout {
|
||||
// 全局常量区(标识、回调标识)
|
||||
// ===================================== 全局常量 =====================================
|
||||
public static final String TAG = "AboutView";
|
||||
public static final int MSG_APPUPDATE_CHECKED = 0;
|
||||
|
||||
// 固定链接常量
|
||||
// 固定链接/邮件常量
|
||||
private static final String WINBOLL_OFFICIAL_HOME = "https://www.winboll.cc";
|
||||
// 邮件相关常量(统一封装,便于维护)
|
||||
private static final String EMAIL_TITLE = "联系WinBoLLStudio";
|
||||
private static final String EMAIL_ADDRESS = "studio@winboll.cc";
|
||||
private static final String EMAIL_TYPE = "message/rfc822";
|
||||
|
||||
// 布局尺寸常量(统一管理,适配多屏幕,dp为基准单位)
|
||||
// 布局尺寸常量(dp)
|
||||
private static final int PADDING_LARGE = 32;
|
||||
private static final int PADDING_MID = 16;
|
||||
private static final int PADDING_SMALL = 8;
|
||||
private static final int ICON_SIZE = 48;
|
||||
private static final int ITEM_ICON_SIZE = 24;
|
||||
|
||||
// 成员属性区(按 核心依赖→业务配置→视图相关 归类排序,注释清晰)
|
||||
private Context mContext; // 上下文对象,全局复用
|
||||
private APPInfo mAPPInfo; // 应用核心信息实体
|
||||
private OnRequestDevUserInfoAutofillListener mOnRequestDevUserInfoAutofillListener; // 调试信息填充监听
|
||||
// 服务器默认地址常量
|
||||
private static final String SERVER_DEBUG_HOST = "https://yun-preivew.winboll.cc";
|
||||
private static final String SERVER_RELEASE_HOST = "https://yun.winboll.cc";
|
||||
|
||||
private String mszAppName = ""; // 应用名称
|
||||
private String mszAppVersionName = ""; // 应用版本号
|
||||
private String mszAppDescription = ""; // 应用描述文案
|
||||
private String mszHomePage = ""; // 应用主页/APK下载地址
|
||||
private String mszGitea = ""; // 应用Git源码地址
|
||||
private String mszAppGitName = ""; // 应用Git仓库名称
|
||||
private String mszAppAPKName = ""; // 应用APK基础名称
|
||||
private String mszAppAPKFolderName = ""; // 应用APK存储文件夹
|
||||
private String mszCurrentAppPackageName = "";// 当前APK完整文件名
|
||||
private String mszReleaseAPKName = ""; // 正式版APK完整文件名
|
||||
private volatile String mszNewestAppPackageName = ""; // 最新版APK文件名(支持异步更新)
|
||||
private String mszWinBoLLServerHost = ""; // 服务器地址
|
||||
private int mnAppIcon = 0; // 应用图标资源ID
|
||||
private boolean mIsAddDebugTools = false; // 是否启用调试工具标识
|
||||
private EditText metDevUserName; // 调试用户名输入框
|
||||
private EditText metDevUserPassword; // 调试密码输入框
|
||||
// ===================================== 核心成员属性 =====================================
|
||||
// 上下文与业务实体
|
||||
private Context mContext;
|
||||
private APPInfo mAPPInfo;
|
||||
private OnRequestDevUserInfoAutofillListener mOnRequestDevUserInfoAutofillListener;
|
||||
|
||||
// 视图绑定
|
||||
// 应用基础信息
|
||||
private String mszAppName = "";
|
||||
private String mszAppVersionName = "";
|
||||
private String mszAppDescription = "";
|
||||
private String mszHomePage = "";
|
||||
private String mszGitea = "";
|
||||
private String mszAppGitName = "";
|
||||
private String mszAppAPKName = "";
|
||||
private String mszAppAPKFolderName = "";
|
||||
private String mszCurrentAppPackageName = "";
|
||||
private String mszReleaseAPKName = "";
|
||||
private volatile String mszNewestAppPackageName = "";
|
||||
private String mszWinBoLLServerHost = "";
|
||||
private int mnAppIcon = 0;
|
||||
private boolean mIsAddDebugTools = false;
|
||||
|
||||
// 调试视图
|
||||
private EditText metDevUserName;
|
||||
private EditText metDevUserPassword;
|
||||
|
||||
// ===================================== 页面视图控件 =====================================
|
||||
private ImageView ivAppIcon;
|
||||
private TextView tvAppNameVersion;
|
||||
private TextView tvAppDesc;
|
||||
private LinearLayout llFunctionContainer;
|
||||
private ImageButton ibSigngetDialog;
|
||||
private ImageButton ibWinBoLLHostDialog;
|
||||
|
||||
// 构造方法区(按 参数从少到多 排序,适配 代码创建+XML引用 场景)
|
||||
// ===================================== 构造方法(按参数从少到多排序) =====================================
|
||||
public AboutView(Context context) {
|
||||
super(context);
|
||||
LogUtils.d(TAG, "AboutView(Context) 构造方法调用,代码创建视图场景");
|
||||
LogUtils.d(TAG, "AboutView(Context):代码创建视图,执行默认初始化");
|
||||
this.mContext = context;
|
||||
initDefaultParams();
|
||||
initViewFromXml();
|
||||
@@ -84,7 +94,7 @@ public class AboutView extends LinearLayout {
|
||||
|
||||
public AboutView(Context context, APPInfo appInfo) {
|
||||
super(context);
|
||||
LogUtils.d(TAG, "AboutView(Context,APPInfo) 构造调用,入参APPInfo:" + (appInfo == null ? "null" : appInfo.getAppName()));
|
||||
LogUtils.d(TAG, "AboutView(Context,APPInfo):传入应用信息,appName=" + (appInfo == null ? "null" : appInfo.getAppName()));
|
||||
this.mContext = context;
|
||||
this.mAPPInfo = appInfo;
|
||||
initViewFromXml();
|
||||
@@ -93,7 +103,7 @@ public class AboutView extends LinearLayout {
|
||||
|
||||
public AboutView(Context context, AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
LogUtils.d(TAG, "AboutView(Context,AttributeSet) 构造调用,XML布局引用场景");
|
||||
LogUtils.d(TAG, "AboutView(Context,AttributeSet):XML布局引用,执行默认初始化");
|
||||
this.mContext = context;
|
||||
initDefaultParams();
|
||||
initViewFromXml();
|
||||
@@ -101,70 +111,22 @@ public class AboutView extends LinearLayout {
|
||||
|
||||
public AboutView(Context context, AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
LogUtils.d(TAG, "AboutView(Context,AttributeSet,int) 构造调用,XML布局+样式配置,defStyleAttr:" + defStyleAttr);
|
||||
LogUtils.d(TAG, "AboutView(Context,AttributeSet,int):XML布局+样式配置,defStyleAttr=" + defStyleAttr);
|
||||
this.mContext = context;
|
||||
initDefaultParams();
|
||||
initViewFromXml();
|
||||
}
|
||||
|
||||
// 核心:加载xml布局并绑定视图
|
||||
// private void initViewFromXml() {
|
||||
// View.inflate(mContext, R.layout.layout_about_view, this);
|
||||
// ivAppIcon = findViewById(R.id.iv_app_icon);
|
||||
// tvAppNameVersion = findViewById(R.id.tv_app_name_version);
|
||||
// tvAppDesc = findViewById(R.id.tv_app_desc);
|
||||
// llFunctionContainer = findViewById(R.id.ll_function_container);
|
||||
// LogUtils.d(TAG, "initViewFromXml 布局加载+视图绑定完成");
|
||||
// }
|
||||
// 1. 新增视图绑定属性(加在原有视图属性后面)
|
||||
private ImageButton ibSigngetDialog;
|
||||
private ImageButton ibWinBoLLHostDialog;
|
||||
|
||||
// 2. 完善initViewFromXml方法,新增按钮绑定
|
||||
private void initViewFromXml() {
|
||||
View.inflate(mContext, R.layout.layout_about_view, this);
|
||||
ivAppIcon = findViewById(R.id.iv_app_icon);
|
||||
tvAppNameVersion = findViewById(R.id.tv_app_name_version);
|
||||
tvAppDesc = findViewById(R.id.tv_app_desc);
|
||||
llFunctionContainer = findViewById(R.id.ll_function_container);
|
||||
ibSigngetDialog = findViewById(R.id.ib_signgetdialog); // 新增按钮绑定
|
||||
ibWinBoLLHostDialog = findViewById(R.id.ib_winbollhostdialog); // 新增按钮绑定
|
||||
ibWinBoLLHostDialog.setVisibility(GlobalApplication.isDebugging()?View.VISIBLE:View.GONE);
|
||||
setBtnClickListener(); // 新增绑定点击事件
|
||||
LogUtils.d(TAG, "initViewFromXml 布局加载+视图绑定完成");
|
||||
}
|
||||
|
||||
// 3. 新增按钮点击事件方法(放在initViewFromXml下面即可)
|
||||
private void setBtnClickListener() {
|
||||
ibSigngetDialog.setOnClickListener(new OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
LogUtils.d(TAG, "签名获取按钮点击,弹出SignGetDialog");
|
||||
new SignGetDialog(mContext).show(); // 弹出对话框
|
||||
}
|
||||
});
|
||||
ibWinBoLLHostDialog.setOnClickListener(new OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
LogUtils.d(TAG, "签名获取按钮点击,弹出SignGetDialog");
|
||||
new DebugHostDialog(mContext).show(); // 弹出对话框
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// 对外公开方法区(供外部调用,职责单一,注释明确)
|
||||
// ===================================== 对外公开方法 =====================================
|
||||
/**
|
||||
* 一站式初始化所有关于页逻辑,包含参数、信息、视图全流程初始化
|
||||
* 一站式初始化所有关于页逻辑,包含参数、应用信息、页面视图全流程
|
||||
*/
|
||||
public void initAll() {
|
||||
LogUtils.d(TAG, "initAll() 一站式初始化调用,APPInfo是否为空:" + (mAPPInfo == null));
|
||||
LogUtils.d(TAG, "initAll():开始一站式初始化,APPInfo是否为空=" + (mAPPInfo == null));
|
||||
if (mAPPInfo == null) {
|
||||
LogUtils.w(TAG, "initAll() 初始化终止:APPInfo 为 null,无法获取应用核心信息");
|
||||
LogUtils.w(TAG, "initAll():初始化终止,APPInfo为null");
|
||||
return;
|
||||
}
|
||||
|
||||
// 按初始化流程执行,有序无冗余
|
||||
initDefaultParams();
|
||||
initAppBaseInfo();
|
||||
initAppVersionInfo();
|
||||
@@ -172,60 +134,81 @@ public class AboutView extends LinearLayout {
|
||||
initAppLinkInfo();
|
||||
initReleaseAPKInfo();
|
||||
initAboutPageView();
|
||||
LogUtils.d(TAG, "initAll() 所有初始化流程执行完成");
|
||||
LogUtils.d(TAG, "initAll():所有初始化流程执行完成");
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置应用信息并重新初始化关于页,支持动态更新页面内容
|
||||
* 重置应用信息并重新初始化页面,支持动态更新关于页内容
|
||||
* @param appInfo 新的应用信息实体
|
||||
*/
|
||||
public void setAPPInfoAndInit(APPInfo appInfo) {
|
||||
LogUtils.d(TAG, "setAPPInfoAndInit() 调用,传入新APPInfo:" + (appInfo == null ? "null" : appInfo.getAppName()));
|
||||
LogUtils.d(TAG, "setAPPInfoAndInit():重置应用信息,appName=" + (appInfo == null ? "null" : appInfo.getAppName()));
|
||||
this.mAPPInfo = appInfo;
|
||||
llFunctionContainer.removeAllViews();
|
||||
if (llFunctionContainer != null) llFunctionContainer.removeAllViews();
|
||||
initAll();
|
||||
LogUtils.d(TAG, "setAPPInfoAndInit() 应用信息重置+页面重构完成");
|
||||
LogUtils.d(TAG, "setAPPInfoAndInit():应用信息重置+页面重构完成");
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置应用信息(兼容旧调用逻辑),设置后自动重构页面
|
||||
* 设置应用信息,兼容旧调用逻辑,设置后自动重构页面
|
||||
* @param appInfo 应用核心信息实体
|
||||
*/
|
||||
public void setAPPInfo(APPInfo appInfo) {
|
||||
LogUtils.d(TAG, "setAPPInfo() 调用,传入APPInfo:" + (appInfo == null ? "null" : appInfo.getAppName()));
|
||||
LogUtils.d(TAG, "setAPPInfo():设置应用信息,appName=" + (appInfo == null ? "null" : appInfo.getAppName()));
|
||||
this.mAPPInfo = appInfo;
|
||||
llFunctionContainer.removeAllViews();
|
||||
if (llFunctionContainer != null) llFunctionContainer.removeAllViews();
|
||||
initAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置调试信息自动填充监听,用于调试场景的信息回调
|
||||
* 设置调试信息自动填充监听,供调试场景回调使用
|
||||
* @param l 监听回调接口实现
|
||||
*/
|
||||
public void setOnRequestDevUserInfoAutofillListener(OnRequestDevUserInfoAutofillListener l) {
|
||||
LogUtils.d(TAG, "setOnRequestDevUserInfoAutofillListener() 调试监听设置完成");
|
||||
LogUtils.d(TAG, "setOnRequestDevUserInfoAutofillListener():设置调试信息填充监听完成");
|
||||
this.mOnRequestDevUserInfoAutofillListener = l;
|
||||
}
|
||||
|
||||
// 内部初始化方法区(按 基础→业务→视图 流程排序,单一职责)
|
||||
// ===================================== 内部初始化方法 =====================================
|
||||
/**
|
||||
* 初始化默认兜底参数,防止空指针,为后续初始化做基础铺垫
|
||||
*/
|
||||
private void initDefaultParams() {
|
||||
LogUtils.d(TAG, "initDefaultParams() 执行默认参数初始化");
|
||||
mszWinBoLLServerHost = GlobalApplication.isDebugging() ? "https://yun-preivew.winboll.cc" : "https://yun.winboll.cc";
|
||||
mnAppIcon = mnAppIcon == 0 ? R.drawable.ic_winboll : mnAppIcon;
|
||||
LogUtils.d(TAG, "initDefaultParams():开始初始化默认参数");
|
||||
mszWinBoLLServerHost = GlobalApplication.isDebugging() ? SERVER_DEBUG_HOST : SERVER_RELEASE_HOST;
|
||||
mnAppIcon = (mnAppIcon == 0) ? R.drawable.ic_winboll : mnAppIcon;
|
||||
mIsAddDebugTools = false;
|
||||
LogUtils.d(TAG, "initDefaultParams() 完成,默认服务器地址:" + mszWinBoLLServerHost + ",默认图标ID:" + mnAppIcon);
|
||||
LogUtils.d(TAG, "initDefaultParams():默认参数初始化完成,服务器地址=" + mszWinBoLLServerHost + ",应用图标ID=" + mnAppIcon);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载XML布局并绑定所有视图控件,初始化按钮点击事件
|
||||
*/
|
||||
private void initViewFromXml() {
|
||||
LogUtils.d(TAG, "initViewFromXml():开始加载布局并绑定控件");
|
||||
View.inflate(mContext, R.layout.layout_about_view, this);
|
||||
// 基础控件绑定
|
||||
ivAppIcon = findViewById(R.id.iv_app_icon);
|
||||
tvAppNameVersion = findViewById(R.id.tv_app_name_version);
|
||||
tvAppDesc = findViewById(R.id.tv_app_desc);
|
||||
llFunctionContainer = findViewById(R.id.ll_function_container);
|
||||
// 功能按钮绑定
|
||||
ibSigngetDialog = findViewById(R.id.ib_signgetdialog);
|
||||
ibWinBoLLHostDialog = findViewById(R.id.ib_winbollhostdialog);
|
||||
// 调试地址按钮动态显隐
|
||||
ibWinBoLLHostDialog.setVisibility(GlobalApplication.isDebugging() ? View.VISIBLE : View.GONE);
|
||||
// 绑定按钮点击事件
|
||||
setBtnClickListener();
|
||||
LogUtils.d(TAG, "initViewFromXml():布局加载+控件绑定+事件初始化完成");
|
||||
}
|
||||
|
||||
/**
|
||||
* 从APPInfo实体读取应用基础核心配置,赋值到本地属性
|
||||
*/
|
||||
private void initAppBaseInfo() {
|
||||
LogUtils.d(TAG, "initAppBaseInfo() 读取APPInfo基础配置");
|
||||
LogUtils.d(TAG, "initAppBaseInfo():开始读取APPInfo基础配置");
|
||||
if (mAPPInfo == null) {
|
||||
LogUtils.w(TAG, "initAppBaseInfo() 跳过执行:APPInfo 为 null");
|
||||
LogUtils.w(TAG, "initAppBaseInfo():跳过执行,APPInfo为null");
|
||||
return;
|
||||
}
|
||||
mszAppName = mAPPInfo.getAppName() == null ? "" : mAPPInfo.getAppName();
|
||||
@@ -233,44 +216,44 @@ public class AboutView extends LinearLayout {
|
||||
mszAppAPKName = mAPPInfo.getAppAPKName() == null ? "" : mAPPInfo.getAppAPKName();
|
||||
mszAppGitName = mAPPInfo.getAppGitName() == null ? "" : mAPPInfo.getAppGitName();
|
||||
mszAppDescription = mAPPInfo.getAppDescription() == null ? "" : mAPPInfo.getAppDescription();
|
||||
mnAppIcon = mAPPInfo.getAppIcon() != 0 ? mAPPInfo.getAppIcon() : mnAppIcon;
|
||||
mnAppIcon = (mAPPInfo.getAppIcon() != 0) ? mAPPInfo.getAppIcon() : mnAppIcon;
|
||||
mIsAddDebugTools = mAPPInfo.isAddDebugTools();
|
||||
LogUtils.d(TAG, "initAppBaseInfo() 读取完成,应用名:" + mszAppName + ",调试开关:" + mIsAddDebugTools);
|
||||
LogUtils.d(TAG, "initAppBaseInfo():基础配置读取完成,应用名=" + mszAppName + ",调试开关=" + mIsAddDebugTools);
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化应用版本信息,从包管理中获取当前应用版本号
|
||||
* 从包管理中获取当前应用版本号,初始化版本相关信息
|
||||
*/
|
||||
private void initAppVersionInfo() {
|
||||
LogUtils.d(TAG, "initAppVersionInfo() 初始化应用版本信息");
|
||||
LogUtils.d(TAG, "initAppVersionInfo():开始初始化应用版本信息");
|
||||
try {
|
||||
mszAppVersionName = mContext.getPackageManager().getPackageInfo(mContext.getPackageName(), 0).versionName;
|
||||
} catch (PackageManager.NameNotFoundException e) {
|
||||
LogUtils.d(TAG, "initAppVersionInfo() 获取版本号失败,默认赋值unknown", e);
|
||||
LogUtils.e(TAG, "initAppVersionInfo():获取版本号失败,默认赋值unknown", e);
|
||||
mszAppVersionName = "unknown";
|
||||
}
|
||||
mszCurrentAppPackageName = String.format("%s_%s.apk", mszAppVersionName, mszAppVersionName);
|
||||
LogUtils.d(TAG, "initAppVersionInfo() 完成,版本号:" + mszAppVersionName + ",当前APK名:" + mszCurrentAppPackageName);
|
||||
LogUtils.d(TAG, "initAppVersionInfo():版本信息初始化完成,版本号=" + mszAppVersionName + ",当前APK名=" + mszCurrentAppPackageName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化服务器相关配置,预留扩展接口
|
||||
*/
|
||||
private void initServerConfig() {
|
||||
LogUtils.d(TAG, "initServerConfig() 服务器配置初始化(预留扩展)");
|
||||
LogUtils.d(TAG, "initServerConfig():服务器配置初始化,预留扩展接口");
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化应用相关链接(主页+Git源码地址),动态拼接Git地址
|
||||
* 初始化应用相关链接(主页+Git源码地址),根据分支配置动态拼接Git地址
|
||||
*/
|
||||
private void initAppLinkInfo() {
|
||||
LogUtils.d(TAG, "initAppLinkInfo() 初始化应用链接信息");
|
||||
LogUtils.d(TAG, "initAppLinkInfo():开始初始化应用链接信息");
|
||||
if (mAPPInfo == null) {
|
||||
LogUtils.w(TAG, "initAppLinkInfo() 跳过执行:APPInfo 为 null");
|
||||
LogUtils.w(TAG, "initAppLinkInfo():跳过执行,APPInfo为null");
|
||||
return;
|
||||
}
|
||||
mszHomePage = mAPPInfo.getAppHomePage() == null ? "" : mAPPInfo.getAppHomePage();
|
||||
// 分场景拼接Git地址,兼容无分支配置场景
|
||||
// 拼接Git地址,兼容无分支配置场景
|
||||
if (mAPPInfo.getAppGitAPPBranch() == null || mAPPInfo.getAppGitAPPBranch().trim().isEmpty()) {
|
||||
mszGitea = String.format("https://gitea.winboll.cc/%s/%s", mAPPInfo.getAppGitOwner(), mszAppGitName);
|
||||
} else {
|
||||
@@ -278,31 +261,31 @@ public class AboutView extends LinearLayout {
|
||||
mAPPInfo.getAppGitOwner(), mszAppGitName,
|
||||
mAPPInfo.getAppGitAPPBranch(), mAPPInfo.getAppGitAPPSubProjectFolder());
|
||||
}
|
||||
LogUtils.d(TAG, "initAppLinkInfo() 完成,应用主页:" + mszHomePage + ",Git地址:" + mszGitea);
|
||||
LogUtils.d(TAG, "initAppLinkInfo():链接信息初始化完成,应用主页=" + mszHomePage + ",Git地址=" + mszGitea);
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化正式版APK信息,去除beta后缀适配正式包命名规范
|
||||
*/
|
||||
private void initReleaseAPKInfo() {
|
||||
LogUtils.d(TAG, "initReleaseAPKInfo() 初始化正式版APK信息");
|
||||
LogUtils.d(TAG, "initReleaseAPKInfo():开始初始化正式版APK信息");
|
||||
String szReleaseAppVersionName = "unknown";
|
||||
try {
|
||||
String szSubBetaSuffix = subBetaSuffix(mContext.getPackageName());
|
||||
szReleaseAppVersionName = mContext.getPackageManager().getPackageInfo(szSubBetaSuffix, 0).versionName;
|
||||
} catch (PackageManager.NameNotFoundException e) {
|
||||
LogUtils.d(TAG, "initReleaseAPKInfo() 获取正式版版本号失败", e);
|
||||
LogUtils.e(TAG, "initReleaseAPKInfo():获取正式版版本号失败", e);
|
||||
}
|
||||
mszReleaseAPKName = String.format("%s_%s.apk", mszAppAPKName, szReleaseAppVersionName);
|
||||
LogUtils.d(TAG, "initReleaseAPKInfo() 完成,正式版APK名:" + mszReleaseAPKName);
|
||||
LogUtils.d(TAG, "initReleaseAPKInfo():正式版APK信息初始化完成,APK名=" + mszReleaseAPKName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 核心视图组装:赋值基础信息+添加功能项
|
||||
* 核心视图组装:赋值基础信息到控件,添加通用功能项到容器
|
||||
*/
|
||||
private void initAboutPageView() {
|
||||
LogUtils.d(TAG, "initAboutPageView() 开始组装关于页视图");
|
||||
// 基础信息赋值
|
||||
LogUtils.d(TAG, "initAboutPageView():开始组装关于页视图");
|
||||
// 赋值基础信息
|
||||
ivAppIcon.setImageResource(mnAppIcon);
|
||||
tvAppNameVersion.setText(String.format("%s %s", mszAppName, mszAppVersionName));
|
||||
if (mszAppDescription.isEmpty()) {
|
||||
@@ -311,8 +294,7 @@ public class AboutView extends LinearLayout {
|
||||
tvAppDesc.setVisibility(VISIBLE);
|
||||
tvAppDesc.setText(mszAppDescription);
|
||||
}
|
||||
|
||||
// 通用功能控件:网页跳转类+邮件类,复用抽象控件
|
||||
// 添加通用功能项
|
||||
addFunctionView(new WebJumpFunctionItemView(mContext, "WinBoLL 主页", WINBOLL_OFFICIAL_HOME, R.drawable.ic_winboll));
|
||||
addFunctionView(new EmailFunctionItemView(mContext, "联系邮箱", "WinBoLLStudio<studio@winboll.cc>", R.drawable.ic_winboll));
|
||||
if (!mszHomePage.isEmpty()) {
|
||||
@@ -321,19 +303,46 @@ public class AboutView extends LinearLayout {
|
||||
if (!mszGitea.isEmpty()) {
|
||||
addFunctionView(new WebJumpFunctionItemView(mContext, "应用Git源码地址", mszGitea, R.drawable.ic_winboll));
|
||||
}
|
||||
LogUtils.d(TAG, "initAboutPageView() 视图组装完成,功能项加载完毕");
|
||||
LogUtils.d(TAG, "initAboutPageView():视图组装完成,功能项加载完毕");
|
||||
}
|
||||
|
||||
// 添加功能项到容器
|
||||
// ===================================== 内部工具/事件方法 =====================================
|
||||
/**
|
||||
* 绑定功能按钮点击事件,处理正版校验、调试地址配置弹窗唤起
|
||||
*/
|
||||
private void setBtnClickListener() {
|
||||
LogUtils.d(TAG, "setBtnClickListener():开始绑定功能按钮点击事件");
|
||||
// 正版校验弹窗
|
||||
ibSigngetDialog.setOnClickListener(new OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
LogUtils.d(TAG, "ibSigngetDialog onClick:唤起应用正版校验弹窗");
|
||||
new AppValidationDialog(mContext, mszAppGitName, mszAppVersionName).show();
|
||||
}
|
||||
});
|
||||
// 调试地址配置弹窗
|
||||
ibWinBoLLHostDialog.setOnClickListener(new OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
LogUtils.d(TAG, "ibWinBoLLHostDialog onClick:唤起调试地址配置弹窗");
|
||||
new DebugHostDialog(mContext).show();
|
||||
}
|
||||
});
|
||||
LogUtils.d(TAG, "setBtnClickListener():功能按钮点击事件绑定完成");
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加功能项视图到容器,统一设置间距
|
||||
* @param view 功能项视图
|
||||
*/
|
||||
private void addFunctionView(View view) {
|
||||
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
|
||||
params.topMargin = dp2px(PADDING_SMALL);
|
||||
llFunctionContainer.addView(view, params);
|
||||
}
|
||||
|
||||
// 工具方法区(通用工具+业务工具,静态优先,便于复用)
|
||||
/**
|
||||
* dp 转 px 工具方法,适配不同屏幕密度,保证布局一致性
|
||||
* dp转px工具方法,适配不同屏幕密度,保证布局一致性
|
||||
* @param dpValue dp单位尺寸
|
||||
* @return 转换后的px单位尺寸
|
||||
*/
|
||||
@@ -348,17 +357,20 @@ public class AboutView extends LinearLayout {
|
||||
* @return 去除beta后缀后的正式包名
|
||||
*/
|
||||
public static String subBetaSuffix(String input) {
|
||||
LogUtils.d(TAG, "subBetaSuffix() 执行包名beta后缀去除,原始包名:" + input);
|
||||
LogUtils.d(TAG, "subBetaSuffix():执行包名beta后缀去除,原始包名=" + input);
|
||||
if (input != null && input.endsWith(".beta")) {
|
||||
String result = input.substring(0, input.length() - ".beta".length());
|
||||
LogUtils.d(TAG, "subBetaSuffix() 处理成功,正式包名:" + result);
|
||||
LogUtils.d(TAG, "subBetaSuffix():处理成功,正式包名=" + result);
|
||||
return result;
|
||||
}
|
||||
LogUtils.d(TAG, "subBetaSuffix() 无需处理,包名不含beta后缀");
|
||||
LogUtils.d(TAG, "subBetaSuffix():无需处理,包名不含beta后缀");
|
||||
return input == null ? "" : input;
|
||||
}
|
||||
|
||||
// 内部抽象通用功能项基类 - 统一样式,减少冗余
|
||||
// ===================================== 内部抽象通用功能项基类 =====================================
|
||||
/**
|
||||
* 通用功能项基类,统一样式、布局、视图构建,减少冗余代码
|
||||
*/
|
||||
private abstract class BaseFunctionItemView extends LinearLayout implements OnClickListener {
|
||||
protected Context mItemContext;
|
||||
protected String mTitle;
|
||||
@@ -376,7 +388,9 @@ public class AboutView extends LinearLayout {
|
||||
setOnClickListener(this);
|
||||
}
|
||||
|
||||
// 统一布局配置
|
||||
/**
|
||||
* 统一初始化功能项布局属性
|
||||
*/
|
||||
private void initItemLayout() {
|
||||
setOrientation(HORIZONTAL);
|
||||
setGravity(Gravity.CENTER_VERTICAL);
|
||||
@@ -386,7 +400,9 @@ public class AboutView extends LinearLayout {
|
||||
setBackgroundResource(android.R.drawable.list_selector_background);
|
||||
}
|
||||
|
||||
// 统一视图构建
|
||||
/**
|
||||
* 统一构建功能项视图(左侧图标+右侧标题/内容)
|
||||
*/
|
||||
private void initItemViews() {
|
||||
// 左侧图标
|
||||
if (mIconRes != 0) {
|
||||
@@ -397,20 +413,17 @@ public class AboutView extends LinearLayout {
|
||||
ivIcon.setImageResource(mIconRes);
|
||||
addView(ivIcon);
|
||||
}
|
||||
|
||||
// 右侧文本容器
|
||||
LinearLayout llText = new LinearLayout(mItemContext);
|
||||
llText.setOrientation(VERTICAL);
|
||||
llText.setLayoutParams(new LayoutParams(0, LayoutParams.WRAP_CONTENT, 1.0f));
|
||||
addView(llText);
|
||||
|
||||
// 标题
|
||||
TextView tvTitle = new TextView(mItemContext);
|
||||
tvTitle.setText(mTitle);
|
||||
tvTitle.setTextSize(16);
|
||||
tvTitle.setTextColor(mItemContext.getResources().getColor(R.color.gray_900));
|
||||
llText.addView(tvTitle);
|
||||
|
||||
// 内容
|
||||
TextView tvContent = new TextView(mItemContext);
|
||||
tvContent.setText(mContent);
|
||||
@@ -420,11 +433,17 @@ public class AboutView extends LinearLayout {
|
||||
llText.addView(tvContent);
|
||||
}
|
||||
|
||||
// 子类指定内容文本颜色
|
||||
/**
|
||||
* 子类抽象方法:指定内容文本颜色
|
||||
* @return 颜色值
|
||||
*/
|
||||
protected abstract int getContentTextColor();
|
||||
}
|
||||
|
||||
// 邮件类功能控件 - 专属邮件唤起逻辑
|
||||
// ===================================== 内部邮件功能项子类 =====================================
|
||||
/**
|
||||
* 邮件类功能控件,实现专属邮件唤起逻辑,双方案兼容(纯邮件客户端/通用邮件应用)
|
||||
*/
|
||||
private class EmailFunctionItemView extends BaseFunctionItemView {
|
||||
public EmailFunctionItemView(Context context, String title, String content, int iconRes) {
|
||||
super(context, title, content, iconRes);
|
||||
@@ -437,36 +456,37 @@ public class AboutView extends LinearLayout {
|
||||
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
LogUtils.d(TAG, "EmailFunctionItemView onClick 触发邮件唤起");
|
||||
// 双方案邮件唤起逻辑
|
||||
LogUtils.d(TAG, "EmailFunctionItemView onClick:触发邮件唤起逻辑");
|
||||
// 方案1:纯邮件客户端唤起
|
||||
Intent emailIntent = new Intent(Intent.ACTION_SENDTO);
|
||||
emailIntent.setData(Uri.parse("mailto:" + EMAIL_ADDRESS));
|
||||
emailIntent.putExtra(Intent.EXTRA_SUBJECT, EMAIL_TITLE);
|
||||
emailIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
|
||||
if (emailIntent.resolveActivity(mItemContext.getPackageManager()) != null) {
|
||||
mItemContext.startActivity(emailIntent);
|
||||
LogUtils.d(TAG, "邮件唤起成功:系统纯邮件客户端");
|
||||
LogUtils.d(TAG, "EmailFunctionItemView:纯邮件客户端唤起成功");
|
||||
return;
|
||||
}
|
||||
|
||||
// 方案2:通用邮件应用兜底
|
||||
Intent fallbackIntent = new Intent(Intent.ACTION_SEND);
|
||||
fallbackIntent.setType(EMAIL_TYPE);
|
||||
fallbackIntent.putExtra(Intent.EXTRA_EMAIL, new String[]{EMAIL_ADDRESS});
|
||||
fallbackIntent.putExtra(Intent.EXTRA_SUBJECT, EMAIL_TITLE);
|
||||
fallbackIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
|
||||
if (fallbackIntent.resolveActivity(mItemContext.getPackageManager()) != null) {
|
||||
mItemContext.startActivity(fallbackIntent);
|
||||
LogUtils.d(TAG, "邮件唤起成功:通用邮件应用");
|
||||
LogUtils.d(TAG, "EmailFunctionItemView:通用邮件应用唤起成功");
|
||||
} else {
|
||||
ToastUtils.show("未找到可发送邮件的应用");
|
||||
LogUtils.w(TAG, "邮件唤起失败:无可用邮件相关应用");
|
||||
LogUtils.w(TAG, "EmailFunctionItemView:邮件唤起失败,无可用邮件应用");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 网页跳转类功能控件 - 专属网页跳转逻辑
|
||||
// ===================================== 内部网页跳转功能项子类 =====================================
|
||||
/**
|
||||
* 网页跳转类功能控件,实现专属网页唤起逻辑,包含空地址校验、异常捕获
|
||||
*/
|
||||
private class WebJumpFunctionItemView extends BaseFunctionItemView {
|
||||
public WebJumpFunctionItemView(Context context, String title, String content, int iconRes) {
|
||||
super(context, title, content, iconRes);
|
||||
@@ -479,25 +499,28 @@ public class AboutView extends LinearLayout {
|
||||
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
LogUtils.d(TAG, "WebJumpFunctionItemView onClick 触发网页跳转,地址:" + mContent);
|
||||
LogUtils.d(TAG, "WebJumpFunctionItemView onClick:触发网页跳转,地址=" + mContent);
|
||||
if (mContent.isEmpty()) {
|
||||
ToastUtils.show("跳转地址为空");
|
||||
LogUtils.w(TAG, "网页跳转失败:地址为空");
|
||||
LogUtils.w(TAG, "WebJumpFunctionItemView:网页跳转失败,地址为空");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(mContent));
|
||||
browserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
mItemContext.startActivity(browserIntent);
|
||||
LogUtils.d(TAG, "网页跳转成功");
|
||||
LogUtils.d(TAG, "WebJumpFunctionItemView:网页跳转成功");
|
||||
} catch (Exception e) {
|
||||
LogUtils.d(TAG, "网页跳转失败,异常捕获", e);
|
||||
LogUtils.e(TAG, "WebJumpFunctionItemView:网页跳转失败", e);
|
||||
ToastUtils.show("链接无法打开");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 内部接口区(置于类末尾,逻辑闭环)
|
||||
// ===================================== 内部回调接口 =====================================
|
||||
/**
|
||||
* 调试信息自动填充回调接口
|
||||
*/
|
||||
public interface OnRequestDevUserInfoAutofillListener {
|
||||
void requestAutofill(EditText etDevUserName, EditText etDevUserPassword);
|
||||
}
|
||||
|
||||
18
libappbase/src/main/res/drawable/shape_edittext_bg.xml
Normal file
18
libappbase/src/main/res/drawable/shape_edittext_bg.xml
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_focused="true">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#FFFFFF"/>
|
||||
<stroke android:width="1dp" android:color="#007AFF"/>
|
||||
<corners android:radius="8dp"/>
|
||||
</shape>
|
||||
</item>
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#FFFFFF"/>
|
||||
<stroke android:width="1dp" android:color="#E5E5E5"/>
|
||||
<corners android:radius="8dp"/>
|
||||
</shape>
|
||||
</item>
|
||||
</selector>
|
||||
|
||||
30
libappbase/src/main/res/layout/activity_ftp_backups.xml
Normal file
30
libappbase/src/main/res/layout/activity_ftp_backups.xml
Normal file
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:orientation="vertical"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<LinearLayout
|
||||
android:orientation="horizontal"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="right">
|
||||
|
||||
<Button
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:text="⚙️"
|
||||
android:onClick="onSFTPSettings"/>
|
||||
|
||||
<Button
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="48dp"
|
||||
android:text="Backups"
|
||||
android:onClick="onBackups"/>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
163
libappbase/src/main/res/layout/dialog_sftp_backup_settings.xml
Normal file
163
libappbase/src/main/res/layout/dialog_sftp_backup_settings.xml
Normal file
@@ -0,0 +1,163 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="20dp"
|
||||
android:gravity="center_horizontal">
|
||||
|
||||
<!-- 标题 -->
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="SFTP备份配置"
|
||||
android:textSize="18sp"
|
||||
android:textColor="#333333"
|
||||
android:layout_marginBottom="20dp"/>
|
||||
|
||||
<!-- 基础配置区域 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:layout_marginBottom="15dp">
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="基础配置"
|
||||
android:textSize="16sp"
|
||||
android:textColor="#666666"
|
||||
android:layout_marginBottom="10dp"/>
|
||||
|
||||
<EditText
|
||||
android:id="@+id/et_sftp_server"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="48dp"
|
||||
android:hint="SFTP服务器地址(IP/域名)"
|
||||
android:paddingHorizontal="15dp"
|
||||
android:background="@drawable/shape_edittext_bg"
|
||||
android:layout_marginBottom="8dp"/>
|
||||
|
||||
<EditText
|
||||
android:id="@+id/et_sftp_port"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="48dp"
|
||||
android:hint="端口号(默认22)"
|
||||
android:inputType="number"
|
||||
android:paddingHorizontal="15dp"
|
||||
android:background="@drawable/shape_edittext_bg"
|
||||
android:layout_marginBottom="8dp"/>
|
||||
|
||||
<EditText
|
||||
android:id="@+id/et_sftp_username"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="48dp"
|
||||
android:hint="登录用户名"
|
||||
android:paddingHorizontal="15dp"
|
||||
android:background="@drawable/shape_edittext_bg"
|
||||
android:layout_marginBottom="8dp"/>
|
||||
|
||||
<EditText
|
||||
android:id="@+id/et_sftp_pwd"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="48dp"
|
||||
android:hint="登录密码"
|
||||
android:inputType="textPassword"
|
||||
android:paddingHorizontal="15dp"
|
||||
android:background="@drawable/shape_edittext_bg"/>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 秘钥配置区域 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:layout_marginBottom="15dp">
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="秘钥配置(可选,优先账号密码)"
|
||||
android:textSize="16sp"
|
||||
android:textColor="#666666"
|
||||
android:layout_marginBottom="10dp"/>
|
||||
|
||||
<EditText
|
||||
android:id="@+id/et_sftp_key_path"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="48dp"
|
||||
android:hint="秘钥本地路径(如/sdcard/key.pem)"
|
||||
android:paddingHorizontal="15dp"
|
||||
android:background="@drawable/shape_edittext_bg"
|
||||
android:layout_marginBottom="8dp"/>
|
||||
|
||||
<EditText
|
||||
android:id="@+id/et_sftp_key_pwd"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="48dp"
|
||||
android:hint="秘钥密码(无则留空)"
|
||||
android:inputType="textPassword"
|
||||
android:paddingHorizontal="15dp"
|
||||
android:background="@drawable/shape_edittext_bg"/>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 高级配置区域 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:layout_marginBottom="20dp">
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="高级配置"
|
||||
android:textSize="16sp"
|
||||
android:textColor="#666666"
|
||||
android:layout_marginBottom="10dp"/>
|
||||
|
||||
<EditText
|
||||
android:id="@+id/et_sftp_charset"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="48dp"
|
||||
android:hint="编码(默认UTF-8)"
|
||||
android:paddingHorizontal="15dp"
|
||||
android:background="@drawable/shape_edittext_bg"
|
||||
android:text="UTF-8"/>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center"
|
||||
android:spacing="10dp">
|
||||
<Button
|
||||
android:id="@+id/btn_sftp_clear"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_weight="1"
|
||||
android:text="清空"
|
||||
android:backgroundTint="#FF9500"
|
||||
android:textColor="#FFFFFF"/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_sftp_cancel"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_weight="1"
|
||||
android:text="取消"
|
||||
android:backgroundTint="#999999"
|
||||
android:textColor="#FFFFFF"/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_sftp_save"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_weight="1"
|
||||
android:text="保存"
|
||||
android:backgroundTint="#007AFF"
|
||||
android:textColor="#FFFFFF"/>
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
@@ -13,6 +13,9 @@ WinBoLL 网站浏览器。
|
||||
阶段版编译命令 :bash .winboll/bashPublishAPKAddTag.sh winboll
|
||||
|
||||
#### 使用说明
|
||||
3. Termux应用配置:
|
||||
- 已安装Termux(包名 com.termux );
|
||||
- 执行 echo "allow-external-apps = true" > ~/.termux/termux.properties
|
||||
|
||||
#### 参与贡献
|
||||
|
||||
|
||||
@@ -73,12 +73,13 @@ dependencies {
|
||||
implementation 'com.alibaba:fastjson:1.2.76'
|
||||
|
||||
// AndroidX 类库
|
||||
api 'androidx.appcompat:appcompat:1.1.0'
|
||||
/*api 'androidx.appcompat:appcompat:1.1.0'
|
||||
//api 'com.google.android.material:material:1.4.0'
|
||||
//api 'androidx.viewpager:viewpager:1.0.0'
|
||||
//api 'androidx.vectordrawable:vectordrawable:1.1.0'
|
||||
//api 'androidx.vectordrawable:vectordrawable-animated:1.1.0'
|
||||
//api 'androidx.fragment:fragment:1.1.0'
|
||||
//api 'androidx.fragment:fragment:1.1.0'*/
|
||||
|
||||
|
||||
// 米盟
|
||||
api 'com.miui.zeus:mimo-ad-sdk:5.3.+'//请使用最新版sdk
|
||||
@@ -88,14 +89,31 @@ dependencies {
|
||||
api 'com.google.code.gson:gson:2.8.5'
|
||||
api 'com.github.bumptech.glide:glide:4.9.0'
|
||||
//annotationProcessor 'com.github.bumptech.glide:compiler:4.9.0'
|
||||
|
||||
implementation "androidx.annotation:annotation:1.3.0"
|
||||
implementation "androidx.core:core:1.6.0"
|
||||
implementation "androidx.drawerlayout:drawerlayout:1.1.1"
|
||||
implementation "androidx.preference:preference:1.1.1"
|
||||
implementation "androidx.viewpager:viewpager:1.0.0"
|
||||
implementation "com.google.android.material:material:1.4.0"
|
||||
implementation "com.google.guava:guava:24.1-jre"
|
||||
/*
|
||||
implementation "io.noties.markwon:core:$markwonVersion"
|
||||
implementation "io.noties.markwon:ext-strikethrough:$markwonVersion"
|
||||
implementation "io.noties.markwon:linkify:$markwonVersion"
|
||||
implementation "io.noties.markwon:recycler:$markwonVersion"
|
||||
*/
|
||||
implementation 'com.termux:terminal-emulator:0.118.0'
|
||||
implementation 'com.termux:terminal-view:0.118.0'
|
||||
implementation 'com.termux:termux-shared:0.118.0'
|
||||
|
||||
// WinBoLL库 nexus.winboll.cc 地址
|
||||
api 'cc.winboll.studio:libaes:15.12.13'
|
||||
api 'cc.winboll.studio:libappbase:15.14.2'
|
||||
api 'cc.winboll.studio:libaes:15.15.2'
|
||||
api 'cc.winboll.studio:libappbase:15.15.11'
|
||||
|
||||
// WinBoLL备用库 jitpack.io 地址
|
||||
//api 'com.github.ZhanGSKen:AES:aes-v15.12.9'
|
||||
//api 'com.github.ZhanGSKen:APPBase:appbase-v15.14.1'
|
||||
//api 'com.github.ZhanGSKen:AES:aes-v15.15.7'
|
||||
//api 'com.github.ZhanGSKen:APPBase:appbase-v15.15.4'
|
||||
|
||||
api fileTree(dir: 'libs', include: ['*.jar'])
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#Created by .winboll/winboll_app_build.gradle
|
||||
#Mon Jan 12 05:14:13 GMT 2026
|
||||
stageCount=9
|
||||
#Thu Jan 29 17:03:53 HKT 2026
|
||||
stageCount=15
|
||||
libraryProject=
|
||||
baseVersion=15.11
|
||||
publishVersion=15.11.8
|
||||
buildCount=15
|
||||
baseBetaVersion=15.11.9
|
||||
publishVersion=15.11.14
|
||||
buildCount=0
|
||||
baseBetaVersion=15.11.15
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<manifest
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="cc.winboll.studio.winboll">
|
||||
package="cc.winboll.studio.winboll"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:sharedUserId="com.termux">
|
||||
|
||||
<!-- 拥有完全的网络访问权限 -->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
@@ -11,7 +13,12 @@
|
||||
|
||||
<!-- 对正在运行的应用重新排序 -->
|
||||
<uses-permission android:name="android.permission.REORDER_TASKS"/>
|
||||
|
||||
<!-- Android 11+ 查询已安装应用权限 -->
|
||||
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||
tools:ignore="QueryAllPackagesPermission" />
|
||||
<!-- 可选:兼容低版本系统 -->
|
||||
<uses-permission android:name="android.permission.GET_PACKAGE_SIZE" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
@@ -282,6 +289,8 @@
|
||||
|
||||
<activity android:name="cc.winboll.studio.winboll.activities.WXPayActivity"/>
|
||||
|
||||
<activity android:name="cc.winboll.studio.winboll.unittest.TermuxEnvTestActivity"/>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
</manifest>
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
package cc.winboll.studio.winboll;
|
||||
|
||||
/**
|
||||
* @Author ZhanGSKen<zhangsken@188.com>
|
||||
* @Date 2025/05/22 13:08
|
||||
*/
|
||||
import android.content.Context;
|
||||
import android.content.res.TypedArray;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.widget.TextView;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
|
||||
public class CustomToolbar extends Toolbar {
|
||||
|
||||
private View viewMain;
|
||||
|
||||
public CustomToolbar(Context context) {
|
||||
this(context, null);
|
||||
}
|
||||
|
||||
public CustomToolbar(Context context, @Nullable AttributeSet attrs) {
|
||||
this(context, attrs, 0);
|
||||
}
|
||||
|
||||
public CustomToolbar(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
initView(context, attrs);
|
||||
}
|
||||
|
||||
private void initView(Context context, AttributeSet attrs) {
|
||||
TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.CustomToolbar);
|
||||
|
||||
// 获取属性值
|
||||
String toolbarTitle = typedArray.getString(R.styleable.CustomToolbar_toolbarTitle);
|
||||
int toolbarTitleColor = typedArray.getColor(R.styleable.CustomToolbar_toolbarTitleColor, android.graphics.Color.WHITE);
|
||||
int toolbarBackgroundColor = typedArray.getColor(R.styleable.CustomToolbar_toolbarBackgroundColor, android.graphics.Color.BLUE);
|
||||
|
||||
// 加载布局
|
||||
viewMain = LayoutInflater.from(context).inflate(R.layout.view_toolbar, this, true);
|
||||
|
||||
// 应用属性值
|
||||
TextView toolbarTitleTextView = viewMain.findViewById(R.id.toolbar_title);
|
||||
toolbarTitleTextView.setText(toolbarTitle);
|
||||
toolbarTitleTextView.setTextColor(toolbarTitleColor);
|
||||
viewMain.setBackgroundColor(toolbarBackgroundColor);
|
||||
|
||||
// 释放 TypedArray
|
||||
typedArray.recycle();
|
||||
}
|
||||
}
|
||||
@@ -8,16 +8,15 @@ import android.view.Menu;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.widget.AdapterView;
|
||||
import cc.winboll.studio.libaes.activitys.AboutActivity;
|
||||
import cc.winboll.studio.libaes.activitys.DrawerFragmentActivity;
|
||||
import cc.winboll.studio.libaes.models.APPInfo;
|
||||
import cc.winboll.studio.libaes.models.DrawerMenuBean;
|
||||
import cc.winboll.studio.libaes.utils.WinBoLLActivityManager;
|
||||
import cc.winboll.studio.libappbase.LogUtils;
|
||||
import cc.winboll.studio.winboll.R;
|
||||
import cc.winboll.studio.winboll.activities.AboutActivity;
|
||||
import cc.winboll.studio.winboll.activities.SettingsActivity;
|
||||
import cc.winboll.studio.winboll.activities.WXPayActivity;
|
||||
import cc.winboll.studio.winboll.fragments.BrowserFragment;
|
||||
import cc.winboll.studio.winboll.unittest.TermuxEnvTestActivity;
|
||||
import java.util.ArrayList;
|
||||
|
||||
public class MainActivity extends DrawerFragmentActivity {
|
||||
@@ -123,6 +122,9 @@ public class MainActivity extends DrawerFragmentActivity {
|
||||
@Override
|
||||
public boolean onCreateOptionsMenu(Menu menu) {
|
||||
getMenuInflater().inflate(R.menu.toolbar_main, menu);
|
||||
if (App.isDebugging()) {
|
||||
getMenuInflater().inflate(R.menu.toolbar_test, menu);
|
||||
}
|
||||
return super.onCreateOptionsMenu(menu);
|
||||
}
|
||||
|
||||
@@ -149,12 +151,13 @@ public class MainActivity extends DrawerFragmentActivity {
|
||||
}
|
||||
} else if (nItemId == R.id.item_settings) {
|
||||
WinBoLLActivityManager.getInstance().startWinBoLLActivity(getApplicationContext(), SettingsActivity.class);
|
||||
} else if (nItemId == R.id.item_wxpayactivity) {
|
||||
WinBoLLActivityManager.getInstance().startWinBoLLActivity(getApplicationContext(), WXPayActivity.class);
|
||||
} else if (nItemId == cc.winboll.studio.libaes.R.id.item_about) {
|
||||
} else if (nItemId == R.id.item_about) {
|
||||
Intent intent = new Intent(getApplicationContext(), AboutActivity.class);
|
||||
APPInfo appInfo = genDefaultAPPInfo();
|
||||
intent.putExtra(AboutActivity.EXTRA_APPINFO, appInfo);
|
||||
|
||||
WinBoLLActivityManager.getInstance().startWinBoLLActivity(getApplicationContext(), intent, AboutActivity.class);
|
||||
} else if (nItemId == R.id.item_termux_env_test) {
|
||||
Intent intent = new Intent(getApplicationContext(), TermuxEnvTestActivity.class);
|
||||
|
||||
WinBoLLActivityManager.getInstance().startWinBoLLActivity(getApplicationContext(), intent, AboutActivity.class);
|
||||
} else {
|
||||
return super.onOptionsItemSelected(item);
|
||||
@@ -163,23 +166,6 @@ public class MainActivity extends DrawerFragmentActivity {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
APPInfo genDefaultAPPInfo() {
|
||||
String szBranchName = "winboll";
|
||||
APPInfo appInfo = new APPInfo();
|
||||
appInfo.setAppName("WinBoLL");
|
||||
appInfo.setAppIcon(cc.winboll.studio.libaes.R.drawable.ic_winboll);
|
||||
appInfo.setAppDescription("WinBoLL 网站浏览器。");
|
||||
appInfo.setAppGitName("WinBoLL");
|
||||
appInfo.setAppGitOwner("Studio");
|
||||
appInfo.setAppGitAPPBranch(szBranchName);
|
||||
appInfo.setAppGitAPPSubProjectFolder(szBranchName);
|
||||
appInfo.setAppHomePage("https://www.winboll.cc/apks/index.php?project=WinBoLL");
|
||||
appInfo.setAppAPKName("WinBoLL");
|
||||
appInfo.setAppAPKFolderName("WinBoLL");
|
||||
return appInfo;
|
||||
}
|
||||
|
||||
// ------------------- 新增:对外提供Handler(供其他组件发送消息,如BrowserFragment) -------------------
|
||||
public Handler getMainHandler() {
|
||||
return _mMainHandler;
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package cc.winboll.studio.winboll.activities;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import cc.winboll.studio.libaes.utils.WinBoLLActivityManager;
|
||||
import cc.winboll.studio.libappbase.LogUtils;
|
||||
import cc.winboll.studio.libappbase.models.APPInfo;
|
||||
import cc.winboll.studio.libappbase.views.AboutView;
|
||||
import cc.winboll.studio.winboll.MainActivity;
|
||||
import cc.winboll.studio.winboll.R;
|
||||
import android.app.Activity;
|
||||
|
||||
/**
|
||||
* @Author 豆包&ZhanGSKen<zhangsken@qq.com>
|
||||
* @Date 2026/01/13 11:54
|
||||
* @Describe 应用介绍窗口
|
||||
*/
|
||||
public class AboutActivity extends BaseWinBoLLActivity {
|
||||
|
||||
@Override
|
||||
public Activity getActivity() {
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
public static final String TAG = "AboutActivity";
|
||||
|
||||
private Toolbar mToolbar;
|
||||
|
||||
@Override
|
||||
public String getTag() {
|
||||
return TAG;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_about);
|
||||
|
||||
// 设置工具栏
|
||||
initToolbar();
|
||||
|
||||
AboutView aboutView = getActivity().findViewById(R.id.aboutview);
|
||||
aboutView.setAPPInfo(genDefaultAppInfo());
|
||||
}
|
||||
|
||||
private void initToolbar() {
|
||||
LogUtils.d(TAG, "initToolbar() 开始初始化");
|
||||
mToolbar = (Toolbar) findViewById(R.id.toolbar);
|
||||
if (mToolbar == null) {
|
||||
LogUtils.e(TAG, "initToolbar() | Toolbar未找到");
|
||||
return;
|
||||
}
|
||||
setSupportActionBar(mToolbar);
|
||||
mToolbar.setSubtitle(getTag());
|
||||
((AppCompatActivity)getActivity()).getSupportActionBar().setDisplayHomeAsUpEnabled(true);
|
||||
mToolbar.setNavigationOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
LogUtils.d(TAG, "导航栏 点击返回按钮");
|
||||
getActivity().finish();
|
||||
}
|
||||
});
|
||||
LogUtils.d(TAG, "initToolbar() 配置完成");
|
||||
}
|
||||
|
||||
private APPInfo genDefaultAppInfo() {
|
||||
LogUtils.d(TAG, "genDefaultAppInfo() 调用");
|
||||
String branchName = "winboll";
|
||||
APPInfo appInfo = new APPInfo();
|
||||
appInfo.setAppName(getActivity().getString(R.string.app_name));
|
||||
appInfo.setAppIcon(R.drawable.ic_winboll);
|
||||
appInfo.setAppDescription(getActivity().getString(R.string.app_description));
|
||||
appInfo.setAppGitName("WinBoLL");
|
||||
appInfo.setAppGitOwner("Studio");
|
||||
appInfo.setAppGitAPPBranch(branchName);
|
||||
appInfo.setAppGitAPPSubProjectFolder(branchName);
|
||||
appInfo.setAppHomePage("https://www.winboll.cc/apks/index.php?project=WinBoLL");
|
||||
appInfo.setAppAPKName("WinBoLL");
|
||||
appInfo.setAppAPKFolderName("WinBoLL");
|
||||
LogUtils.d(TAG, "genDefaultAppInfo: 应用信息已生成");
|
||||
return appInfo;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package cc.winboll.studio.winboll.activities;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.os.Bundle;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import cc.winboll.studio.libaes.interfaces.IWinBoLLActivity;
|
||||
import cc.winboll.studio.libaes.models.AESThemeBean;
|
||||
import cc.winboll.studio.libaes.utils.AESThemeUtil;
|
||||
import cc.winboll.studio.libaes.utils.WinBoLLActivityManager;
|
||||
|
||||
/**
|
||||
* @Author 豆包&ZhanGSKen<zhangsken@qq.com>
|
||||
* @Date 2026/01/13 16:33
|
||||
* BaseWinBollActivity 【继承AppCompatActivity,保留核心能力,不额外暴露方法】
|
||||
* 继承链路:BaseWinBoLLActivity → AppCompatActivity → FragmentActivity,AppCompat能力天然继承可用
|
||||
*/
|
||||
public abstract class BaseWinBoLLActivity extends AppCompatActivity implements IWinBoLLActivity {
|
||||
public static final String TAG = "BaseWinBoLLActivity";
|
||||
|
||||
protected volatile AESThemeBean.ThemeType mThemeType;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
mThemeType = getThemeType();
|
||||
setThemeStyle();
|
||||
super.onCreate(savedInstanceState);
|
||||
WinBoLLActivityManager.getInstance().add(this);
|
||||
//ToastUtils.show(getTag() + ": onCreate");
|
||||
}
|
||||
|
||||
AESThemeBean.ThemeType getThemeType() {
|
||||
/*SharedPreferences sharedPreferences = getSharedPreferences(
|
||||
SHAREDPREFERENCES_NAME, MODE_PRIVATE);
|
||||
return AESThemeBean.ThemeType.values()[((sharedPreferences.getInt(DRAWER_THEME_TYPE, AESThemeBean.ThemeType.DEFAULT.ordinal())))];
|
||||
*/
|
||||
return AESThemeBean.getThemeStyleType(AESThemeUtil.getThemeTypeID(getApplicationContext()));
|
||||
}
|
||||
|
||||
void setThemeStyle() {
|
||||
//setTheme(AESThemeBean.getThemeStyle(getThemeType()));
|
||||
setTheme(AESThemeUtil.getThemeTypeID(getApplicationContext()));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
WinBoLLActivityManager.getInstance().registeRemove(this);
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
// 子类必须实现getTag(),确保唯一标识
|
||||
@Override
|
||||
public abstract String getTag();
|
||||
|
||||
public abstract Activity getActivity();
|
||||
}
|
||||
|
||||
@@ -5,20 +5,32 @@ package cc.winboll.studio.winboll.activities;
|
||||
* @Date 2025/03/25 11:46:40
|
||||
* @Describe 测试窗口2
|
||||
*/
|
||||
import android.app.Activity;
|
||||
import android.os.Bundle;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.widget.Toolbar;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import cc.winboll.studio.winboll.R;
|
||||
import android.app.Activity;
|
||||
|
||||
public class New2Activity extends BaseWinBoLLActivity {
|
||||
|
||||
@Override
|
||||
public Activity getActivity() {
|
||||
return this;
|
||||
}
|
||||
|
||||
public class New2Activity extends WinBoLLActivity {
|
||||
|
||||
public static final String TAG = "New2Activity";
|
||||
|
||||
Toolbar mToolbar;
|
||||
|
||||
@Override
|
||||
public String getTag() {
|
||||
return TAG;
|
||||
}
|
||||
|
||||
//LogView mLogView;
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
@@ -28,15 +40,10 @@ public class New2Activity extends WinBoLLActivity {
|
||||
// mLogView = findViewById(R.id.logview);
|
||||
// mLogView.start();
|
||||
mToolbar = findViewById(R.id.toolbar);
|
||||
setActionBar(mToolbar);
|
||||
setSupportActionBar(mToolbar);
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
//mLogView.start();
|
||||
}
|
||||
|
||||
public void onCloseThisActivity(View view) {
|
||||
//WinBoLLActivityManager.getInstance().finish(this);
|
||||
@@ -49,17 +56,4 @@ public class New2Activity extends WinBoLLActivity {
|
||||
public void onNewActivity(View view) {
|
||||
//WinBoLLActivityManager.getInstance().startWinBoLLActivity(this, NewActivity.class);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public boolean onCreateOptionsMenu(Menu menu) {
|
||||
//getMenuInflater().inflate(R.menu.toolbar_main, menu);
|
||||
return super.onCreateOptionsMenu(menu);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(MenuItem item) {
|
||||
// 在switch语句中处理每个ID,并在处理完后返回true,未处理的情况返回false。
|
||||
return super.onOptionsItemSelected(item);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,29 +5,26 @@ package cc.winboll.studio.winboll.activities;
|
||||
* @Date 2025/03/25 05:04:22
|
||||
* @Describe
|
||||
*/
|
||||
import android.app.Activity;
|
||||
import android.os.Bundle;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.widget.Toolbar;
|
||||
import cc.winboll.studio.libaes.interfaces.IWinBoLLActivity;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import cc.winboll.studio.libaes.utils.WinBoLLActivityManager;
|
||||
import cc.winboll.studio.winboll.R;
|
||||
import cc.winboll.studio.winboll.App;
|
||||
import android.app.Activity;
|
||||
|
||||
public class NewActivity extends BaseWinBoLLActivity {
|
||||
|
||||
@Override
|
||||
public Activity getActivity() {
|
||||
return this;
|
||||
}
|
||||
|
||||
public class NewActivity extends WinBoLLActivity implements IWinBoLLActivity {
|
||||
|
||||
public static final String TAG = "NewActivity";
|
||||
|
||||
Toolbar mToolbar;
|
||||
//LogView mLogView;
|
||||
|
||||
@Override
|
||||
public Activity getActivity() {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTag() {
|
||||
return TAG;
|
||||
@@ -40,15 +37,10 @@ public class NewActivity extends WinBoLLActivity implements IWinBoLLActivity {
|
||||
// mLogView = findViewById(R.id.logview);
|
||||
// mLogView.start();
|
||||
mToolbar = findViewById(R.id.toolbar);
|
||||
setActionBar(mToolbar);
|
||||
setSupportActionBar(mToolbar);
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
//mLogView.start();
|
||||
}
|
||||
|
||||
public void onCloseThisActivity(View view) {
|
||||
WinBoLLActivityManager.getInstance().finish(this);
|
||||
@@ -61,17 +53,4 @@ public class NewActivity extends WinBoLLActivity implements IWinBoLLActivity {
|
||||
public void onNew2Activity(View view) {
|
||||
// WinBoLLActivityManager.getInstance().startWinBoLLActivity(App.getInstance(), New2Activity.class);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public boolean onCreateOptionsMenu(Menu menu) {
|
||||
//getMenuInflater().inflate(R.menu.toolbar_main, menu);
|
||||
return super.onCreateOptionsMenu(menu);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(MenuItem item) {
|
||||
// 在switch语句中处理每个ID,并在处理完后返回true,未处理的情况返回false。
|
||||
return super.onOptionsItemSelected(item);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
package cc.winboll.studio.winboll.activities;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import cc.winboll.studio.libaes.interfaces.IWinBoLLActivity;
|
||||
import cc.winboll.studio.libaes.utils.AESThemeUtil;
|
||||
import cc.winboll.studio.winboll.R;
|
||||
import android.app.Activity;
|
||||
|
||||
/**
|
||||
* @Author ZhanGSKen&豆包大模型<zhangsken@qq.com>
|
||||
* @Date 2025/12/05 18:48
|
||||
* @Describe Settings Activity
|
||||
*/
|
||||
public class SettingsActivity extends WinBoLLActivity implements IWinBoLLActivity {
|
||||
|
||||
public static final String TAG = "SettingsActivity";
|
||||
public class SettingsActivity extends BaseWinBoLLActivity {
|
||||
|
||||
@Override
|
||||
public Activity getActivity() {
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
public static final String TAG = "SettingsActivity";
|
||||
|
||||
@Override
|
||||
public String getTag() {
|
||||
return TAG;
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
package cc.winboll.studio.winboll.activities;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.graphics.Bitmap;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.os.Message;
|
||||
import android.view.View;
|
||||
import android.widget.Button;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
import cc.winboll.studio.libaes.interfaces.IWinBoLLActivity;
|
||||
import cc.winboll.studio.winboll.R;
|
||||
import cc.winboll.studio.winboll.WxPayConfig;
|
||||
import cc.winboll.studio.winboll.utils.SpecUtil;
|
||||
import cc.winboll.studio.winboll.utils.WxPayApi;
|
||||
import cc.winboll.studio.winboll.utils.ZXingUtils;
|
||||
import java.util.Timer;
|
||||
import java.util.TimerTask;
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 主界面:生成二维码 + 轮询查询支付结果
|
||||
* @Author ZhanGSKen<zhangsken@qq.com>
|
||||
* @Date 2026/01/07
|
||||
*/
|
||||
public class WXPayActivity extends WinBoLLActivity implements IWinBoLLActivity {
|
||||
|
||||
private static final String TAG = "WXPayActivity";
|
||||
|
||||
// Handler消息标识
|
||||
private static final int MSG_POLL_TIMEOUT = 1001;
|
||||
private static final int MSG_POLL_SUCCESS = 1002;
|
||||
private static final int MSG_POLL_FAILED = 1003;
|
||||
|
||||
private ImageView mIvQrCode;
|
||||
private TextView mTvOrderNo;
|
||||
private TextView mTvPayStatus;
|
||||
private Button mBtnCreateOrder;
|
||||
|
||||
private String mOutTradeNo; // 商户订单号
|
||||
private Timer mPollTimer; // 轮询定时器
|
||||
private long mPollStartTime; // 轮询开始时间
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public Activity getActivity() {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTag() {
|
||||
return TAG;
|
||||
}
|
||||
|
||||
private Handler mPollHandler = new Handler() {
|
||||
@Override
|
||||
public void handleMessage(Message msg) {
|
||||
super.handleMessage(msg);
|
||||
switch (msg.what) {
|
||||
case MSG_POLL_TIMEOUT:
|
||||
stopPoll();
|
||||
mTvPayStatus.setText("支付状态:轮询超时");
|
||||
mTvPayStatus.setTextColor(getResources().getColor(android.R.color.darker_gray));
|
||||
Toast.makeText(WXPayActivity.this, "轮询超时,请手动查询", Toast.LENGTH_SHORT).show();
|
||||
break;
|
||||
case MSG_POLL_SUCCESS:
|
||||
boolean isPaySuccess = (boolean) msg.obj;
|
||||
String tradeState = (String) msg.getData().getString("tradeState");
|
||||
stopPoll();
|
||||
if (isPaySuccess) {
|
||||
mTvPayStatus.setText("支付状态:支付成功 ✅");
|
||||
mTvPayStatus.setTextColor(getResources().getColor(android.R.color.holo_green_dark));
|
||||
Toast.makeText(WXPayActivity.this, "支付成功!", Toast.LENGTH_SHORT).show();
|
||||
} else {
|
||||
mTvPayStatus.setText("支付状态:" + tradeState);
|
||||
mTvPayStatus.setTextColor(getResources().getColor(android.R.color.holo_red_dark));
|
||||
}
|
||||
break;
|
||||
case MSG_POLL_FAILED:
|
||||
String errorMsg = (String) msg.obj;
|
||||
mTvPayStatus.setText("查询失败:" + errorMsg);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_wxpay);
|
||||
|
||||
initView();
|
||||
initListener();
|
||||
}
|
||||
|
||||
private void initView() {
|
||||
mIvQrCode = findViewById(R.id.iv_qrcode);
|
||||
mTvOrderNo = findViewById(R.id.tv_order_no);
|
||||
mTvPayStatus = findViewById(R.id.tv_pay_status);
|
||||
mBtnCreateOrder = findViewById(R.id.btn_create_order);
|
||||
}
|
||||
|
||||
private void initListener() {
|
||||
mBtnCreateOrder.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
createOrder();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一下单,生成二维码
|
||||
*/
|
||||
private void createOrder() {
|
||||
mBtnCreateOrder.setEnabled(false);
|
||||
mTvPayStatus.setText("支付状态:生成订单中...");
|
||||
mIvQrCode.setImageBitmap(null);
|
||||
|
||||
WxPayApi.createOrder(new WxPayApi.OnCreateOrderCallback() {
|
||||
@Override
|
||||
public void onSuccess(String outTradeNo, String codeUrl) {
|
||||
mOutTradeNo = outTradeNo;
|
||||
mTvOrderNo.setText("商户订单号:" + outTradeNo);
|
||||
mTvPayStatus.setText("支付状态:未支付,请扫码");
|
||||
|
||||
// 生成二维码
|
||||
Bitmap qrCodeBitmap = ZXingUtils.createQRCodeBitmap(codeUrl, 250, 250);
|
||||
if (qrCodeBitmap != null) {
|
||||
mIvQrCode.setImageBitmap(qrCodeBitmap);
|
||||
// 开始轮询查询支付结果
|
||||
startPoll();
|
||||
} else {
|
||||
mTvPayStatus.setText("支付状态:生成二维码失败");
|
||||
mBtnCreateOrder.setEnabled(true);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(String errorMsg) {
|
||||
SpecUtil.WWSpecLogError(TAG, "统一下单失败:" + errorMsg);
|
||||
mTvPayStatus.setText("生成订单失败:" + errorMsg);
|
||||
mBtnCreateOrder.setEnabled(true);
|
||||
Toast.makeText(WXPayActivity.this, errorMsg, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始轮询查询支付结果
|
||||
*/
|
||||
private void startPoll() {
|
||||
stopPoll(); // 先停止之前的轮询
|
||||
mPollStartTime = System.currentTimeMillis();
|
||||
mPollTimer = new Timer();
|
||||
mPollTimer.scheduleAtFixedRate(new TimerTask() {
|
||||
@Override
|
||||
public void run() {
|
||||
// 检查是否超时
|
||||
long elapsedTime = System.currentTimeMillis() - mPollStartTime;
|
||||
if (elapsedTime >= WxPayConfig.POLL_TIMEOUT) {
|
||||
mPollHandler.sendEmptyMessage(MSG_POLL_TIMEOUT);
|
||||
return;
|
||||
}
|
||||
|
||||
// 查询订单状态
|
||||
WxPayApi.queryOrder(mOutTradeNo, new WxPayApi.OnQueryOrderCallback() {
|
||||
@Override
|
||||
public void onSuccess(boolean isPaySuccess, String tradeState) {
|
||||
Message msg = Message.obtain();
|
||||
msg.what = MSG_POLL_SUCCESS;
|
||||
msg.obj = isPaySuccess;
|
||||
Bundle bundle = new Bundle();
|
||||
bundle.putString("tradeState", tradeState);
|
||||
msg.setData(bundle);
|
||||
mPollHandler.sendMessage(msg);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(String errorMsg) {
|
||||
Message msg = Message.obtain();
|
||||
msg.what = MSG_POLL_FAILED;
|
||||
msg.obj = errorMsg;
|
||||
mPollHandler.sendMessage(msg);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 0, WxPayConfig.POLL_INTERVAL);
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止轮询
|
||||
*/
|
||||
private void stopPoll() {
|
||||
if (mPollTimer != null) {
|
||||
mPollTimer.cancel();
|
||||
mPollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
super.onDestroy();
|
||||
stopPoll();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
package cc.winboll.studio.winboll.activities;
|
||||
|
||||
/**
|
||||
* @Author ZhanGSKen<zhangsken@qq.com>
|
||||
* @Date 2025/05/10 09:48
|
||||
* @Describe WinBoLL 窗口基础类
|
||||
*/
|
||||
import android.app.Activity;
|
||||
import android.os.Bundle;
|
||||
import android.view.MenuItem;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import cc.winboll.studio.libappbase.LogUtils;
|
||||
|
||||
public class WinBoLLActivity extends AppCompatActivity {
|
||||
|
||||
public static final String TAG = "WinBoLLActivity";
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(MenuItem item) {
|
||||
/*if (item.getItemId() == R.id.item_log) {
|
||||
WinBoLLActivityManager.getInstance().startLogActivity(this);
|
||||
return true;
|
||||
} else if (item.getItemId() == R.id.item_home) {
|
||||
startActivity(new Intent(this, MainActivity.class));
|
||||
return true;
|
||||
}*/
|
||||
// 在switch语句中处理每个ID,并在处理完后返回true,未处理的情况返回false。
|
||||
return super.onOptionsItemSelected(item);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPostCreate(Bundle savedInstanceState) {
|
||||
super.onPostCreate(savedInstanceState);
|
||||
//WinBoLLActivityManager.getInstance().add(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
super.onDestroy();
|
||||
//WinBoLLActivityManager.getInstance().registeRemove(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package cc.winboll.studio.winboll.termux;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.os.Build;
|
||||
import cc.winboll.studio.libappbase.LogUtils; // 替换 Log 为 LogUtils(与 Activity 一致)
|
||||
import com.termux.shared.termux.TermuxConstants;
|
||||
import com.termux.shared.shell.command.ExecutionCommand.Runner;
|
||||
|
||||
/**
|
||||
* Termux 命令调用工具类(基于 RunCommandService 原型封装)
|
||||
* 用于向 Termux 发送命令执行请求,兼容 Termux RUN_COMMAND Intent 规范
|
||||
* @Author 豆包&ZhanGSKen<zhangsken@qq.com>
|
||||
* @Date 2026/01/19 16:30:00
|
||||
* @LastEditTime 2026/01/20 10:15:00
|
||||
*/
|
||||
public class TermuxCommandExecutor {
|
||||
private static final String TAG = "TermuxCommandExecutor";
|
||||
// 核心修复:Termux 官方包名(无 .app 后缀)
|
||||
private static final String TERMUX_PACKAGE_NAME = "com.termux";
|
||||
// Termux RunCommandService 完整类名(包名+类名)
|
||||
private static final String TERMUX_RUN_CMD_SERVICE_CLASS = "com.termux.app.RunCommandService";
|
||||
private static final String TERMUX_RUN_CMD_ACTION = TermuxConstants.TERMUX_APP.RUN_COMMAND_SERVICE.ACTION_RUN_COMMAND;
|
||||
|
||||
/**
|
||||
* 执行 Termux 命令(核心方法)
|
||||
* @param context 上下文(如 Activity、Service)
|
||||
* @param command 要执行的命令路径(如 "/bin/ls"、"/data/data/com.termux/files/usr/bin/bash")
|
||||
* @param args 命令参数(如 ["-l", "/data/data/com.termux/files/home"])
|
||||
* @param workDir 工作目录(可为 null,默认 Termux 主目录)
|
||||
* @param isBackground 是否后台执行(true=后台,false=终端会话执行)
|
||||
* @param resultDir 命令结果输出目录(可为 null,不输出到文件)
|
||||
* @return 是否成功发送命令请求
|
||||
*/
|
||||
public static boolean executeCommand(Context context, String command, String[] args, String workDir, boolean isBackground, String resultDir) {
|
||||
// 1. 校验上下文和命令合法性
|
||||
if (context == null || command == null || command.isEmpty()) {
|
||||
LogUtils.e(TAG, "执行命令失败:上下文或命令为空");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 校验 Termux 是否安装(新增:提前校验,避免白跑流程)
|
||||
if (!isTermuxInstalled(context)) {
|
||||
LogUtils.e(TAG, "执行命令失败:Termux 未安装");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. 创建 Intent 并设置目标 Service
|
||||
Intent intent = new Intent(TERMUX_RUN_CMD_ACTION);
|
||||
intent.setClassName(TERMUX_PACKAGE_NAME, TERMUX_RUN_CMD_SERVICE_CLASS); // 用正确包名
|
||||
intent.setPackage(TERMUX_PACKAGE_NAME); // 明确包名,避免歧义
|
||||
|
||||
// 4. 设置核心命令参数(遵循 Termux RunCommandService 规范)
|
||||
intent.putExtra(TermuxConstants.TERMUX_APP.RUN_COMMAND_SERVICE.EXTRA_COMMAND_PATH, command);
|
||||
if (args != null && args.length > 0) {
|
||||
intent.putExtra(TermuxConstants.TERMUX_APP.RUN_COMMAND_SERVICE.EXTRA_ARGUMENTS, args);
|
||||
LogUtils.d(TAG, "命令参数:" + String.join(",", args));
|
||||
}
|
||||
if (workDir != null && !workDir.isEmpty()) {
|
||||
intent.putExtra(TermuxConstants.TERMUX_APP.RUN_COMMAND_SERVICE.EXTRA_WORKDIR, workDir);
|
||||
LogUtils.d(TAG, "工作目录:" + workDir);
|
||||
}
|
||||
|
||||
// 5. 设置执行模式(后台/终端会话)
|
||||
intent.putExtra(TermuxConstants.TERMUX_APP.RUN_COMMAND_SERVICE.EXTRA_BACKGROUND, isBackground);
|
||||
String runner = isBackground ? Runner.APP_SHELL.getName() : Runner.TERMINAL_SESSION.getName();
|
||||
intent.putExtra(TermuxConstants.TERMUX_APP.RUN_COMMAND_SERVICE.EXTRA_RUNNER, runner);
|
||||
LogUtils.d(TAG, "执行模式:" + (isBackground ? "后台" : "终端会话") + ",Runner:" + runner);
|
||||
|
||||
// 6. 设置命令结果输出(可选,输出到文件)
|
||||
if (resultDir != null && !resultDir.isEmpty()) {
|
||||
intent.putExtra(TermuxConstants.TERMUX_APP.RUN_COMMAND_SERVICE.EXTRA_RESULT_DIRECTORY, resultDir);
|
||||
intent.putExtra(TermuxConstants.TERMUX_APP.RUN_COMMAND_SERVICE.EXTRA_RESULT_SINGLE_FILE, true);
|
||||
intent.putExtra(TermuxConstants.TERMUX_APP.RUN_COMMAND_SERVICE.EXTRA_RESULT_FILE_BASENAME, "authcenter_cmd_result");
|
||||
LogUtils.d(TAG, "结果输出目录:" + resultDir);
|
||||
}
|
||||
|
||||
// 7. 允许替换参数中的逗号替代字符
|
||||
intent.putExtra(TermuxConstants.TERMUX_APP.RUN_COMMAND_SERVICE.EXTRA_REPLACE_COMMA_ALTERNATIVE_CHARS_IN_ARGUMENTS, true);
|
||||
|
||||
// 8. 发送请求(区分 Android O 及以上的前台服务)
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent);
|
||||
LogUtils.d(TAG, "Android O+ 启动前台服务发送命令");
|
||||
} else {
|
||||
context.startService(intent);
|
||||
LogUtils.d(TAG, "启动普通服务发送命令");
|
||||
}
|
||||
LogUtils.i(TAG, "命令发送成功:command=" + command);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
LogUtils.e(TAG, "命令发送失败:" + e.getMessage(), e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 简化方法:执行 Termux 终端命令(默认工作目录,终端会话执行)
|
||||
* @param context 上下文
|
||||
* @param command 命令(如 "ls -l /home"、"echo 'hello termux'")
|
||||
* @return 是否成功发送
|
||||
*/
|
||||
public static boolean executeTerminalCommand(Context context, String command) {
|
||||
LogUtils.d(TAG, "调用 executeTerminalCommand,命令:" + command);
|
||||
if (command == null || command.isEmpty()) {
|
||||
LogUtils.e(TAG, "命令为空,执行失败");
|
||||
return false;
|
||||
}
|
||||
// 通过 bash 执行任意终端命令
|
||||
String[] args = {"-c", command};
|
||||
return executeCommand(
|
||||
context,
|
||||
"/data/data/com.termux/files/usr/bin/bash", // Termux 默认 bash 路径(正确)
|
||||
args,
|
||||
"/data/data/com.termux/files/home", // 默认工作目录
|
||||
false, // 终端会话执行(可见)
|
||||
null // 不输出到文件
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 简化方法:后台执行 Termux 命令(无输出文件)
|
||||
* @param context 上下文
|
||||
* @param command 命令路径
|
||||
* @param args 命令参数
|
||||
* @return 是否成功发送
|
||||
*/
|
||||
public static boolean executeBackgroundCommand(Context context, String command, String[] args) {
|
||||
LogUtils.d(TAG, "调用 executeBackgroundCommand,command=" + command);
|
||||
return executeCommand(
|
||||
context,
|
||||
command,
|
||||
args,
|
||||
null,
|
||||
true, // 后台执行
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 Termux 是否安装(修复核心错误)
|
||||
* @param context 上下文
|
||||
* @return Termux 是否已安装
|
||||
*/
|
||||
public static boolean isTermuxInstalled(Context context) {
|
||||
LogUtils.d(TAG, "校验 Termux 是否安装,包名:" + TERMUX_PACKAGE_NAME);
|
||||
if (context == null) {
|
||||
LogUtils.e(TAG, "校验失败:上下文为空");
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
// 用正确的 Termux 包名查询(com.termux)
|
||||
context.getPackageManager().getPackageInfo(TERMUX_PACKAGE_NAME, PackageManager.GET_ACTIVITIES);
|
||||
LogUtils.d(TAG, "Termux 已安装");
|
||||
return true;
|
||||
} catch (PackageManager.NameNotFoundException e) {
|
||||
LogUtils.w(TAG, "Termux 未安装:" + e.getMessage());
|
||||
return false;
|
||||
} catch (Exception e) {
|
||||
LogUtils.e(TAG, "校验 Termux 安装状态异常:" + e.getMessage(), e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 Termux 是否允许外部应用调用
|
||||
* @return 校验提示信息
|
||||
*/
|
||||
public static String checkTermuxExternalAppPermission() {
|
||||
String tip = "请确保 Termux 已开启「允许外部应用调用」权限:\n1. 打开 Termux 输入:termux-setup-storage\n2. 编辑配置文件:echo \"allow-external-apps = true\" > ~/.termux/termux.properties\n3. 重启 Termux 生效";
|
||||
LogUtils.d(TAG, "外部应用调用权限提示:" + tip);
|
||||
return tip;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
package cc.winboll.studio.winboll.unittest;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.view.View;
|
||||
import android.widget.TextView;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import cc.winboll.studio.libaes.utils.WinBoLLActivityManager;
|
||||
import cc.winboll.studio.libappbase.LogUtils;
|
||||
import cc.winboll.studio.winboll.MainActivity;
|
||||
import cc.winboll.studio.winboll.R;
|
||||
import cc.winboll.studio.winboll.activities.BaseWinBoLLActivity;
|
||||
import cc.winboll.studio.winboll.termux.TermuxCommandExecutor;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import android.app.Activity;
|
||||
|
||||
/**
|
||||
* @Author 豆包&ZhanGSKen<zhangsken@qq.com>
|
||||
* @Date 2026/01/19 11:11:00
|
||||
* @LastEditTime 2026/01/21 17:45:00
|
||||
* @Describe Termux环境测试工具(跨包+sharedUserId模式)
|
||||
* 适配不同应用包名(当前包:cc.winboll.studio.winboll.beta / Termux包:com.termux)
|
||||
* 支持Termux目录读取、Gradle命令实时输出执行(唤起窗口),基于sharedUserId实现跨包权限适配
|
||||
*/
|
||||
public class TermuxEnvTestActivity extends BaseWinBoLLActivity {
|
||||
|
||||
@Override
|
||||
public Activity getActivity() {
|
||||
return this;
|
||||
}
|
||||
|
||||
// 常量属性(置顶排列)
|
||||
public static final String TAG = "TermuxEnvTestActivity";
|
||||
private static final String TERMUX_HOME_PATH = "/sdcard/WinBoLLStudio";
|
||||
private static final String CMD_RESULT_FILE = TERMUX_HOME_PATH + "/.authcenter_cmd_result.tmp";
|
||||
|
||||
// 成员属性(常量后排列)
|
||||
private Toolbar mToolbar;
|
||||
private TextView tvMessage;
|
||||
private Handler mainHandler;
|
||||
|
||||
@Override
|
||||
public String getTag() {
|
||||
return TAG;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
LogUtils.d(TAG, "onCreate() 调用,初始化Activity");
|
||||
setContentView(R.layout.activity_termux_env_test);
|
||||
initView();
|
||||
initToolbar();
|
||||
initTermuxDirectory();
|
||||
LogUtils.d(TAG, "onCreate() 执行完成");
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化视图组件
|
||||
*/
|
||||
private void initView() {
|
||||
LogUtils.d(TAG, "initView() 开始初始化视图");
|
||||
tvMessage = (TextView) findViewById(R.id.tv_message);
|
||||
mainHandler = new Handler(Looper.getMainLooper());
|
||||
|
||||
// 初始化提示信息
|
||||
StringBuilder initMsg = new StringBuilder();
|
||||
initMsg.append("Termux 测试工具(跨包+sharedUserId模式)\n");
|
||||
initMsg.append("-------------------------\n");
|
||||
initMsg.append("当前应用包名:");
|
||||
initMsg.append(getPackageName());
|
||||
initMsg.append("\n");
|
||||
initMsg.append("Termux应用包名:com.termux\n");
|
||||
initMsg.append("sharedUserId:com.termux(需一致)\n");
|
||||
initMsg.append("支持功能:目录读取、Gradle命令实时输出(唤起Termux窗口)\n");
|
||||
initMsg.append("-------------------------\n");
|
||||
tvMessage.setText(initMsg.toString());
|
||||
|
||||
LogUtils.d(TAG, "initView() 初始化完成,tvMessage初始值:" + initMsg.toString().trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化Toolbar组件
|
||||
*/
|
||||
private void initToolbar() {
|
||||
LogUtils.d(TAG, "initToolbar() 开始初始化Toolbar");
|
||||
mToolbar = (Toolbar) findViewById(R.id.toolbar);
|
||||
|
||||
if (mToolbar == null) {
|
||||
LogUtils.e(TAG, "initToolbar() 错误:未找到Toolbar组件");
|
||||
return;
|
||||
}
|
||||
|
||||
setSupportActionBar(mToolbar);
|
||||
mToolbar.setSubtitle(getTag());
|
||||
((AppCompatActivity) getActivity()).getSupportActionBar().setDisplayHomeAsUpEnabled(true);
|
||||
|
||||
mToolbar.setNavigationOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
LogUtils.d(TAG, "initToolbar() 导航栏返回按钮点击");
|
||||
getActivity().finish();
|
||||
}
|
||||
});
|
||||
|
||||
LogUtils.d(TAG, "initToolbar() 初始化完成");
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化Termux目标目录(跨包+sharedUserId模式)
|
||||
*/
|
||||
private void initTermuxDirectory() {
|
||||
LogUtils.d(TAG, "initTermuxDirectory() 开始初始化Termux目录,路径:" + TERMUX_HOME_PATH);
|
||||
File termuxDir = new File(TERMUX_HOME_PATH);
|
||||
|
||||
if (termuxDir.exists()) {
|
||||
LogUtils.d(TAG, "initTermuxDirectory() Termux目录已存在,无需创建");
|
||||
return;
|
||||
}
|
||||
|
||||
tvMessage.append("正在创建Termux目标目录...\n");
|
||||
boolean createSuccess = termuxDir.mkdirs();
|
||||
if (createSuccess) {
|
||||
LogUtils.d(TAG, "initTermuxDirectory() Termux目录创建成功:" + TERMUX_HOME_PATH);
|
||||
tvMessage.append("Termux目录创建成功:");
|
||||
tvMessage.append(TERMUX_HOME_PATH);
|
||||
tvMessage.append("\n");
|
||||
} else {
|
||||
LogUtils.e(TAG, "initTermuxDirectory() 错误:Termux目录创建失败");
|
||||
tvMessage.append("警告:Termux目录创建失败!\n");
|
||||
tvMessage.append("请检查:\n");
|
||||
tvMessage.append("1.AndroidManifest.xml中sharedUserId是否为com.termux\n");
|
||||
tvMessage.append("2.设备是否已root(部分机型需root才能跨包写私有目录)\n");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试读取Termux目录(按钮点击事件)
|
||||
*/
|
||||
public void onTestTermuxEnv(View view) {
|
||||
LogUtils.d(TAG, "onTestTermuxEnv() 按钮点击,开始读取Termux目录");
|
||||
tvMessage.append("\n【测试:读取Termux目录】\n");
|
||||
|
||||
String fileListStr = readTermuxHomeFileList();
|
||||
tvMessage.append(fileListStr);
|
||||
tvMessage.append("\n-------------------------\n");
|
||||
|
||||
LogUtils.d(TAG, "onTestTermuxEnv() 执行完成,读取结果长度:" + fileListStr.length() + "字符");
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试执行Gradle命令(实时输出版,唤起Termux窗口)
|
||||
*/
|
||||
public void onTestTermuxCMD(View view) {
|
||||
LogUtils.d(TAG, "onTestTermuxCMD() 按钮点击,执行Gradle命令(实时输出)");
|
||||
tvMessage.append("\n【测试:执行Gradle命令(实时输出)】\n");
|
||||
|
||||
// 1. 校验Termux是否安装
|
||||
if (!TermuxCommandExecutor.isTermuxInstalled(this)) {
|
||||
LogUtils.e(TAG, "onTestTermuxCMD() 错误:未安装Termux应用");
|
||||
tvMessage.append("错误:未安装Termux应用(包名:com.termux)\n");
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 定义核心路径(确保路径与Termux中一致)
|
||||
String gradleFullPath = "/data/data/com.termux/files/home/gradle/gradle-7.5.1/bin/gradle";
|
||||
String projectPath = TERMUX_HOME_PATH + "/Sources/WinBoLL"; // 项目目录
|
||||
|
||||
// 3. 构造命令(核心:用stdbuf禁用缓冲,实现实时输出)
|
||||
String targetCmd = "";
|
||||
// 步骤1:进入项目目录(不存在则创建)
|
||||
//targetCmd += "cd " + projectPath + " || (mkdir -p " + projectPath + " && cd " + projectPath + ") && ";
|
||||
targetCmd += "cd " + projectPath + " && ";
|
||||
// 步骤2:加载环境变量
|
||||
targetCmd += "source ~/.bashrc && ";
|
||||
// 步骤3:显式配置PATH
|
||||
targetCmd += "export PATH=/data/data/com.termux/files/usr/bin:/data/data/com.termux/files/home/gradle/gradle-7.5.1/bin:$PATH && ";
|
||||
// 步骤4:用stdbuf禁用stdout/stderr缓冲(关键!),执行Gradle命令
|
||||
// -o0:stdout无缓冲;-e0:stderr无缓冲;-i0:stdin无缓冲
|
||||
//targetCmd += "stdbuf -o0 -e0 -i0 " + gradleFullPath + " task --all | grep assemble && ";
|
||||
targetCmd += "stdbuf -o0 -e0 -i0 " + gradleFullPath + " -Pandroid.aapt2FromMavenOverride=/data/data/com.termux/files/home/android-sdk/build-tools/34.0.4/aapt2 assembleBetaDebug && ";
|
||||
// 步骤5:执行成功提示
|
||||
targetCmd += "echo '\n✅ 命令执行完成!' && echo '\n📌 项目目录:" + projectPath + "' && read -p '按回车键关闭终端...'";
|
||||
|
||||
LogUtils.d(TAG, "onTestTermuxCMD() 执行命令:" + targetCmd);
|
||||
|
||||
// 4. 执行命令(终端会话模式,唤起Termux窗口)
|
||||
boolean cmdSuccess = TermuxCommandExecutor.executeTerminalCommand(this, targetCmd);
|
||||
if (!cmdSuccess) {
|
||||
LogUtils.e(TAG, "onTestTermuxCMD() 错误:命令发送失败");
|
||||
tvMessage.append("命令发送失败!\n");
|
||||
tvMessage.append("可能原因:\n");
|
||||
tvMessage.append("1.Termux未开启外部应用调用权限(执行termux-setup-storage后配置)\n");
|
||||
tvMessage.append("2.sharedUserId配置不一致\n");
|
||||
tvMessage.append("3.Termux未安装stdbuf(执行pkg install coreutils)\n");
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. 应用内提示(说明实时输出特性)
|
||||
tvMessage.append("已唤起Termux窗口,执行说明:\n");
|
||||
tvMessage.append("1. 自动创建/进入项目目录:");
|
||||
tvMessage.append(projectPath);
|
||||
tvMessage.append("\n");
|
||||
tvMessage.append("2. 禁用输出缓冲(stdbuf),实现Gradle实时输出\n");
|
||||
tvMessage.append("3. 筛选assemble相关任务(grep assemble)\n");
|
||||
tvMessage.append("⚙️ Gradle路径:");
|
||||
tvMessage.append(gradleFullPath);
|
||||
tvMessage.append("\n");
|
||||
tvMessage.append("💡 若未实时输出,请在Termux中执行:pkg install coreutils(安装stdbuf)\n");
|
||||
tvMessage.append("-------------------------\n");
|
||||
}
|
||||
|
||||
public void onOpenTermuxBash(View view) {
|
||||
LogUtils.d(TAG, "onTestTermuxCMD() 按钮点击,执行Gradle命令(实时输出)");
|
||||
tvMessage.append("\n【测试:执行Gradle命令(实时输出)】\n");
|
||||
|
||||
// 1. 校验Termux是否安装
|
||||
if (!TermuxCommandExecutor.isTermuxInstalled(this)) {
|
||||
LogUtils.e(TAG, "onTestTermuxCMD() 错误:未安装Termux应用");
|
||||
tvMessage.append("错误:未安装Termux应用(包名:com.termux)\n");
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 定义核心路径(确保路径与Termux中一致)
|
||||
String gradleFullPath = "/data/data/com.termux/files/home/gradle/gradle-7.5.1/bin/gradle";
|
||||
String projectPath = TERMUX_HOME_PATH + "/Sources/WinBoLL"; // 项目目录
|
||||
|
||||
// 3. 构造命令(核心:用stdbuf禁用缓冲,实现实时输出)
|
||||
String targetCmd = "";
|
||||
// 步骤1:进入项目目录(不存在则创建)
|
||||
targetCmd += "cd " + projectPath + " && ";
|
||||
// 步骤2:加载环境变量
|
||||
targetCmd += "source ~/.bashrc && ";
|
||||
// 步骤3:显式配置PATH
|
||||
targetCmd += "export PATH=/data/data/com.termux/files/usr/bin:/data/data/com.termux/files/home/gradle/gradle-7.5.1/bin:$PATH && ";
|
||||
// 步骤4:用stdbuf禁用stdout/stderr缓冲(关键!),执行Gradle命令
|
||||
// -o0:stdout无缓冲;-e0:stderr无缓冲;-i0:stdin无缓冲
|
||||
//targetCmd += "stdbuf -o0 -e0 -i0 " + gradleFullPath + " task --all | grep assemble && ";
|
||||
//targetCmd += "stdbuf -o0 -e0 -i0 " + gradleFullPath + " -Pandroid.aapt2FromMavenOverride=/data/data/com.termux/files/home/android-sdk/build-tools/34.0.4/aapt2 assembleBetaDebug && ";
|
||||
targetCmd += "stdbuf -o0 -e0 -i0 bash && ";
|
||||
// 步骤5:执行成功提示
|
||||
targetCmd += "echo '\n✅ 命令执行完成!' && echo '\n📌 项目目录:" + projectPath + "' && read -p '按回车键关闭终端...'";
|
||||
|
||||
LogUtils.d(TAG, "onTestTermuxCMD() 执行命令:" + targetCmd);
|
||||
|
||||
// 4. 执行命令(终端会话模式,唤起Termux窗口)
|
||||
boolean cmdSuccess = TermuxCommandExecutor.executeTerminalCommand(this, targetCmd);
|
||||
if (!cmdSuccess) {
|
||||
LogUtils.e(TAG, "onTestTermuxCMD() 错误:命令发送失败");
|
||||
tvMessage.append("命令发送失败!\n");
|
||||
tvMessage.append("可能原因:\n");
|
||||
tvMessage.append("1.Termux未开启外部应用调用权限(执行termux-setup-storage后配置)\n");
|
||||
tvMessage.append("2.sharedUserId配置不一致\n");
|
||||
tvMessage.append("3.Termux未安装stdbuf(执行pkg install coreutils)\n");
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. 应用内提示(说明实时输出特性)
|
||||
tvMessage.append("已唤起Termux窗口,执行说明:\n");
|
||||
tvMessage.append("1. 自动创建/进入项目目录:");
|
||||
tvMessage.append(projectPath);
|
||||
tvMessage.append("\n");
|
||||
tvMessage.append("2. 禁用输出缓冲(stdbuf),实现Gradle实时输出\n");
|
||||
tvMessage.append("3. 筛选assemble相关任务(grep assemble)\n");
|
||||
tvMessage.append("⚙️ Gradle路径:");
|
||||
tvMessage.append(gradleFullPath);
|
||||
tvMessage.append("\n");
|
||||
tvMessage.append("💡 若未实时输出,请在Termux中执行:pkg install coreutils(安装stdbuf)\n");
|
||||
tvMessage.append("-------------------------\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* 跨包读取Termux命令结果文件(保留原功能,兼容其他场景)
|
||||
*/
|
||||
private String readCmdResultDirectly() {
|
||||
LogUtils.d(TAG, "readCmdResultDirectly() 开始读取命令结果,文件路径:" + CMD_RESULT_FILE);
|
||||
File resultFile = new File(CMD_RESULT_FILE);
|
||||
|
||||
// 校验文件存在性
|
||||
if (!resultFile.exists()) {
|
||||
LogUtils.e(TAG, "readCmdResultDirectly() 错误:结果文件不存在");
|
||||
return "错误:结果文件不存在\n可能原因:\n1.命令执行失败\n2.延迟时间不足\n3.跨包写权限被拒绝(需root)";
|
||||
}
|
||||
|
||||
// 校验文件可读性
|
||||
if (!resultFile.canRead()) {
|
||||
LogUtils.e(TAG, "readCmdResultDirectly() 错误:无结果文件读取权限");
|
||||
return "错误:无结果文件读取权限(sharedUserId配置错误或未root)";
|
||||
}
|
||||
|
||||
// 读取文件内容
|
||||
StringBuilder result = new StringBuilder();
|
||||
BufferedReader br = null;
|
||||
try {
|
||||
br = new BufferedReader(new InputStreamReader(new FileInputStream(resultFile), StandardCharsets.UTF_8));
|
||||
String line;
|
||||
while ((line = br.readLine()) != null) {
|
||||
result.append(line);
|
||||
result.append("\n");
|
||||
}
|
||||
LogUtils.d(TAG, "readCmdResultDirectly() 文件读取完成,结果长度:" + result.length() + "字符");
|
||||
} catch (IOException e) {
|
||||
LogUtils.e(TAG, "readCmdResultDirectly() 错误:文件读取异常", e);
|
||||
return "错误:读取文件异常 → " + e.getMessage();
|
||||
} finally {
|
||||
if (br != null) {
|
||||
try {
|
||||
br.close();
|
||||
LogUtils.d(TAG, "readCmdResultDirectly() BufferedReader资源已关闭");
|
||||
} catch (IOException e) {
|
||||
LogUtils.e(TAG, "readCmdResultDirectly() 错误:BufferedReader关闭异常", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String resultStr = result.toString().trim();
|
||||
return resultStr.isEmpty() ? "命令执行成功,但无输出" : resultStr;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除命令结果临时文件(保留原功能)
|
||||
*/
|
||||
private void deleteCmdResultFile() {
|
||||
LogUtils.d(TAG, "deleteCmdResultFile() 开始删除临时文件,路径:" + CMD_RESULT_FILE);
|
||||
File resultFile = new File(CMD_RESULT_FILE);
|
||||
|
||||
if (!resultFile.exists()) {
|
||||
LogUtils.d(TAG, "deleteCmdResultFile() 临时文件不存在,无需删除");
|
||||
return;
|
||||
}
|
||||
|
||||
if (resultFile.canWrite()) {
|
||||
boolean deleteSuccess = resultFile.delete();
|
||||
if (deleteSuccess) {
|
||||
LogUtils.d(TAG, "deleteCmdResultFile() 临时文件删除成功");
|
||||
} else {
|
||||
LogUtils.w(TAG, "deleteCmdResultFile() 警告:临时文件删除失败");
|
||||
tvMessage.append("警告:临时结果文件删除失败\n");
|
||||
}
|
||||
} else {
|
||||
LogUtils.w(TAG, "deleteCmdResultFile() 警告:无临时文件删除权限");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 跨包读取Termux目录文件列表(保留原功能)
|
||||
*/
|
||||
private String readTermuxHomeFileList() {
|
||||
LogUtils.d(TAG, "readTermuxHomeFileList() 开始读取目录,路径:" + TERMUX_HOME_PATH);
|
||||
File termuxHomeDir = new File(TERMUX_HOME_PATH);
|
||||
StringBuilder result = new StringBuilder();
|
||||
|
||||
// 基础校验
|
||||
if (!termuxHomeDir.exists()) {
|
||||
LogUtils.e(TAG, "readTermuxHomeFileList() 错误:目录不存在");
|
||||
return "错误:Termux目录不存在 → " + TERMUX_HOME_PATH;
|
||||
}
|
||||
if (!termuxHomeDir.isDirectory()) {
|
||||
LogUtils.e(TAG, "readTermuxHomeFileList() 错误:指定路径不是目录");
|
||||
return "错误:指定路径不是目录 → " + TERMUX_HOME_PATH;
|
||||
}
|
||||
if (!termuxHomeDir.canRead()) {
|
||||
LogUtils.e(TAG, "readTermuxHomeFileList() 错误:无目录读取权限");
|
||||
return "错误:无目录读取权限(需满足:1.sharedUserId=com.termux 2.设备root)";
|
||||
}
|
||||
|
||||
// 获取文件列表
|
||||
File[] files = termuxHomeDir.listFiles();
|
||||
if (files == null || files.length == 0) {
|
||||
LogUtils.d(TAG, "readTermuxHomeFileList() 目录为空");
|
||||
return "Termux目录为空 → " + TERMUX_HOME_PATH;
|
||||
}
|
||||
|
||||
// 拼接结果字符串
|
||||
result.append("Termux主目录:");
|
||||
result.append(TERMUX_HOME_PATH);
|
||||
result.append("\n");
|
||||
result.append("文件/子目录总数:");
|
||||
result.append(files.length);
|
||||
result.append("\n");
|
||||
result.append("目录权限:");
|
||||
result.append("读:");
|
||||
result.append(termuxHomeDir.canRead());
|
||||
result.append(" | 写:");
|
||||
result.append(termuxHomeDir.canWrite());
|
||||
result.append("\n");
|
||||
result.append("-------------------------\n");
|
||||
|
||||
for (File file : files) {
|
||||
String fileType = file.isDirectory() ? "[目录]" : "[文件]";
|
||||
String fileName = file.getName();
|
||||
String filePath = file.getAbsolutePath();
|
||||
String fileSize = file.isFile() ? " | 大小:" + formatFileSize(file.length()) : "";
|
||||
String filePerm = " | 权限:r:" + file.canRead() + "/w:" + file.canWrite();
|
||||
|
||||
result.append(fileType);
|
||||
result.append(" ");
|
||||
result.append(fileName);
|
||||
result.append(fileSize);
|
||||
result.append(filePerm);
|
||||
result.append(" → ");
|
||||
result.append(filePath);
|
||||
result.append("\n");
|
||||
}
|
||||
|
||||
LogUtils.d(TAG, "readTermuxHomeFileList() 读取完成,结果长度:" + result.length() + "字符");
|
||||
return result.toString().trim();
|
||||
}
|
||||
/**
|
||||
* 格式化文件大小(B→KB→MB)
|
||||
*/
|
||||
private String formatFileSize(long length) {
|
||||
LogUtils.d(TAG, "formatFileSize() 调用,文件长度:" + length + "B");
|
||||
String sizeStr;
|
||||
if (length < 1024) {
|
||||
sizeStr = length + "B";
|
||||
} else if (length < 1024 * 1024) {
|
||||
sizeStr = String.format("%.1fKB", length / 1024.0);
|
||||
} else {
|
||||
sizeStr = String.format("%.1fMB", length / (1024.0 * 1024));
|
||||
}
|
||||
LogUtils.d(TAG, "formatFileSize() 格式化结果:" + sizeStr);
|
||||
return sizeStr;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
super.onDestroy();
|
||||
LogUtils.d(TAG, "onDestroy() 调用,清理资源");
|
||||
deleteCmdResultFile();
|
||||
if (mainHandler != null) {
|
||||
mainHandler.removeCallbacksAndMessages(null);
|
||||
LogUtils.d(TAG, "onDestroy() Handler资源已释放");
|
||||
}
|
||||
LogUtils.d(TAG, "onDestroy() 执行完成");
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,10 @@ import android.os.Message;
|
||||
import android.view.View;
|
||||
import android.widget.Button;
|
||||
import android.widget.Toast;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import cc.winboll.studio.libaes.interfaces.IWinBoLLActivity;
|
||||
import cc.winboll.studio.libappbase.LogUtils;
|
||||
import cc.winboll.studio.winboll.R;
|
||||
import cc.winboll.studio.winboll.activities.WinBoLLActivity;
|
||||
|
||||
/**
|
||||
* @Author 豆包&ZhanGSKen<zhangsken@qq.com>
|
||||
@@ -18,7 +18,7 @@ import cc.winboll.studio.winboll.activities.WinBoLLActivity;
|
||||
* @Describe 企业微信SDK接口测试(基础调试版)
|
||||
* 包含:SDK初始化、基础接口调用、日志输出、主线程回调处理
|
||||
*/
|
||||
public class TestWeWorkSpecSDK extends WinBoLLActivity implements IWinBoLLActivity, View.OnClickListener {
|
||||
public class TestWeWorkSpecSDK extends AppCompatActivity implements IWinBoLLActivity, View.OnClickListener {
|
||||
|
||||
public static final String TAG = "TestWeWorkSpecSDK";
|
||||
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
package com.tencent.wework;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.HashMap;;
|
||||
|
||||
/**
|
||||
* @warning: 1. 不要修改成员变量名,native方法内有反射调用
|
||||
* 2. 调用本地方法需保持包结构,本工具需放在包com.tencent.wework内
|
||||
* 3. 不允许继承,类名和函数名均不可修改,会影响本地方法的引用,详见:javah生成本地方法头文件
|
||||
*/
|
||||
public final class SpecCallbackSDK {
|
||||
|
||||
/**
|
||||
* @description 调用本地方法后实例化的对象指针
|
||||
*/
|
||||
private long specCallbackSDKptr = 0;
|
||||
|
||||
public long GetPtr() { return specCallbackSDKptr; }
|
||||
|
||||
/**
|
||||
* @description: 回包的headers
|
||||
*/
|
||||
private Map<String, String> responseHeaders;
|
||||
|
||||
public Map<String, String> GetResponseHeaders() { return responseHeaders; }
|
||||
|
||||
/**
|
||||
* @description: 回包的加密后的body
|
||||
*/
|
||||
private String responseBody;
|
||||
|
||||
public String GetResponseBody() { return responseBody; }
|
||||
|
||||
/**
|
||||
* @description: 每个请求构造一个SpecCallbackSDK示例,
|
||||
* SpecCallbackSDK仅持有headers和body的引用,
|
||||
* 因此需保证headers和body的生存期比SpecCallbackSDK长
|
||||
* @param method: 请求方法GET/POST
|
||||
* @param headers: 请求header
|
||||
* @param body: 请求body
|
||||
* @example:
|
||||
* SpecCallbackSDK sdk = new SpecCallbackSDK(method, headers, body);
|
||||
* if (sdk.IsOk()) {
|
||||
* String corpid = sdk.GetCorpId();
|
||||
* String agentid = sdk.GetAgentId();
|
||||
* String call_type = sdk.GetCallType();
|
||||
* String data = sdk.GetData();
|
||||
* //do something...
|
||||
* }
|
||||
* String response = ...;
|
||||
* sdk.BuildResponseHeaderBody(response);
|
||||
* Map<String, String> responseHeaders = sdk.GetResponseHeaders();
|
||||
* String body = sdk.GetResponseBody();
|
||||
* //do response
|
||||
*
|
||||
* @return errorcode 示例如下:
|
||||
* -920001: 未设置请求方法
|
||||
* -920002: 未设置请求header
|
||||
* -920003: 未设置请求body
|
||||
* */
|
||||
public SpecCallbackSDK(String method, Map<String, String> headers, String body) {
|
||||
try {
|
||||
specCallbackSDKptr = NewCallbackSDK(method, headers, body);
|
||||
} catch (Exception e) {
|
||||
SpecUtil.WWSpecLogError("SpecCallbackSDK exception caught", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private native long NewCallbackSDK(String method, Map<String, String> headers, String body);
|
||||
|
||||
/**
|
||||
* @usage 在Java对象的内存回收前析构C++对象
|
||||
*/
|
||||
@Override
|
||||
protected void finalize() throws Throwable {
|
||||
DeleteCPPInstance(specCallbackSDKptr);
|
||||
super.finalize();
|
||||
}
|
||||
|
||||
private native void DeleteCPPInstance(long specCallbackSDKptr);
|
||||
|
||||
/**
|
||||
* @description: 判断构造函数中传入的请求是否解析成功
|
||||
* @return: 成功与否
|
||||
* */
|
||||
public boolean IsOk() {
|
||||
return IsOk(specCallbackSDKptr);
|
||||
}
|
||||
|
||||
private native boolean IsOk(long specCallbackSDKptr);
|
||||
|
||||
/**
|
||||
* @description: 获取请求的企业
|
||||
* @require: 仅当IsOk() == true可调用
|
||||
* @return: corpid
|
||||
* */
|
||||
public String GetCorpId() {
|
||||
return GetCorpId(specCallbackSDKptr);
|
||||
}
|
||||
|
||||
private native String GetCorpId(long specCallbackSDKptr);
|
||||
|
||||
/**
|
||||
* @description: 获取请求的应用
|
||||
* @require: 仅当IsOk() == true可调用
|
||||
* @return: agentid
|
||||
* */
|
||||
public long GetAgentId() {
|
||||
return GetAgentId(specCallbackSDKptr);
|
||||
}
|
||||
|
||||
private native long GetAgentId(long specCallbackSDKptr);
|
||||
|
||||
/**
|
||||
* @description: 获取请求的类型
|
||||
* @require: 仅当IsOk() == true可调用
|
||||
* @return: 1 - 来自[应用调用专区]的请求
|
||||
* 2 - 来自企业微信的回调事件
|
||||
* */
|
||||
public long GetCallType() {
|
||||
return GetCallType(specCallbackSDKptr);
|
||||
}
|
||||
|
||||
private native long GetCallType(long specCallbackSDKptr);
|
||||
|
||||
/**
|
||||
* @description: 获取请求数据
|
||||
* @require: 仅当IsOk() == true可调用
|
||||
* @return: 请求数据,根据call_type可能是:
|
||||
* - 企业微信回调事件
|
||||
* - [应用调用专区]接口中的request_data
|
||||
* */
|
||||
public String GetData() {
|
||||
return GetData(specCallbackSDKptr);
|
||||
}
|
||||
|
||||
private native String GetData(long specCallbackSDKptr);
|
||||
|
||||
/**
|
||||
* @description: 是否异步请求
|
||||
* @require: 仅当IsOk() == true可调用
|
||||
* @return: 是否异步请求
|
||||
* */
|
||||
public boolean GetIsAsync() {
|
||||
return GetIsAsync(specCallbackSDKptr);
|
||||
}
|
||||
|
||||
private native boolean GetIsAsync(long specCallbackSDKptr);
|
||||
|
||||
/**
|
||||
* @description: 获取请求的job_info,
|
||||
* @require: 仅当IsOk() == true可调用
|
||||
* @return: job_info,无需理解内容,
|
||||
* 在同一个请求上下文中使用SpecSDK的时候传入
|
||||
* */
|
||||
public String GetJobInfo() {
|
||||
return GetJobInfo(specCallbackSDKptr);
|
||||
}
|
||||
|
||||
private native String GetJobInfo(long specCallbackSDKptr);
|
||||
|
||||
/**
|
||||
* @description: 获取请求的ability_id,[应用调用专区]接口时指定
|
||||
* @require: 仅当IsOk() == true可调用
|
||||
* @return: ability_id
|
||||
* */
|
||||
public String GetAbilityId() {
|
||||
return GetAbilityId(specCallbackSDKptr);
|
||||
}
|
||||
|
||||
private native String GetAbilityId(long specCallbackSDKptr);
|
||||
|
||||
/**
|
||||
* @description: 获取请求的notify_id,用于[应用同步调用专区程序]接口
|
||||
* @require: 仅当IsOk() == true可调用
|
||||
* @return: notify_id
|
||||
* */
|
||||
public String GetNotifyId() {
|
||||
return GetNotifyId(specCallbackSDKptr);
|
||||
}
|
||||
|
||||
private native String GetNotifyId(long specCallbackSDKptr);
|
||||
|
||||
/**
|
||||
* @description: 对返回包计算签名&加密
|
||||
* @param response: 待加密的回包明文.如果IsOk()==false,传入空串即可
|
||||
* @note 本接口的执行问题可查看日志
|
||||
* */
|
||||
public void BuildResponseHeaderBody(String response) {
|
||||
try {
|
||||
responseHeaders = new HashMap<String, String>();
|
||||
responseBody = "";
|
||||
BuildResponseHeaderBody(specCallbackSDKptr, response);
|
||||
} catch (Exception e) {
|
||||
SpecUtil.WWSpecLogError("SpecCallbackSDK exception caught", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private native void BuildResponseHeaderBody(long specCallbackSDKptr, String response);
|
||||
|
||||
// 静态代码块内还无法调用native日志函数,这里的日志在管理系统无法查询
|
||||
static {
|
||||
try {
|
||||
Class.forName("com.tencent.wework.SpecUtil");
|
||||
} catch (ClassNotFoundException e) {
|
||||
e.printStackTrace();
|
||||
System.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
package com.tencent.wework;
|
||||
|
||||
/**
|
||||
* @warning: 1. 不要修改成员变量名,native方法内有反射调用
|
||||
* 2. 调用本地方法需保持包结构,本工具需放在包com.tencent.wework内
|
||||
* 3. 不允许继承,类名和函数名均不可修改,会影响本地方法的引用,详见:javah生成本地方法头文件
|
||||
*/
|
||||
public final class SpecSDK {
|
||||
|
||||
/**
|
||||
* @description 调用本地方法后实例化的对象指针
|
||||
*/
|
||||
private long specSDKptr = 0;
|
||||
|
||||
/**
|
||||
* @usage invoke的请求
|
||||
* @example "{\"limit\":1}
|
||||
*/
|
||||
private String request;
|
||||
|
||||
public void SetRequest(String request) {
|
||||
this.request = request;
|
||||
}
|
||||
|
||||
/**
|
||||
* @usage 访问上一次invoke的结果
|
||||
*/
|
||||
private String response;
|
||||
|
||||
public String GetResponse() {
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param corpid: 企业corpid,必选参数
|
||||
* @param agentid: 应用id,必选参数
|
||||
* @param ability_id: 能力ID,可选参数
|
||||
* @param job_info: job_info,可选参数
|
||||
* */
|
||||
public SpecSDK(String corpId, long agentId) {
|
||||
specSDKptr = NewSDK1(corpId, agentId);
|
||||
}
|
||||
|
||||
private native long NewSDK1(String corpId, long agentId);
|
||||
|
||||
public SpecSDK(String corpId, long agentId, String abilityId) {
|
||||
specSDKptr = NewSDK2(corpId, agentId, abilityId);
|
||||
}
|
||||
|
||||
private native long NewSDK2(String corpId, long agentId, String abilityId);
|
||||
|
||||
public SpecSDK(String corpId, long agentId, String abilityId, String jobInfo) {
|
||||
specSDKptr = NewSDK3(corpId, agentId, abilityId, jobInfo);
|
||||
}
|
||||
|
||||
private native long NewSDK3(String corpId, long agentId, String abilityId, String jobInfo);
|
||||
|
||||
/**
|
||||
* @description 使用callback的请求来初始化
|
||||
* @param callback_sdk: 要求IsOk()==true
|
||||
* @return C++内部指针,创建失败时指针仍为0,并输出错误日志
|
||||
* */
|
||||
public SpecSDK(SpecCallbackSDK callbackSDK) {
|
||||
specSDKptr = NewSDK4(callbackSDK.GetPtr());
|
||||
}
|
||||
|
||||
private native long NewSDK4(long callbackSDK);
|
||||
|
||||
/**
|
||||
* @usage 在Java对象的内存回收前析构C++对象
|
||||
*/
|
||||
@Override
|
||||
protected void finalize() throws Throwable {
|
||||
DeleteCPPInstance(specSDKptr);
|
||||
super.finalize();
|
||||
}
|
||||
|
||||
private native void DeleteCPPInstance(long specSDKptr);
|
||||
|
||||
/**
|
||||
* @description 用于在专区内调用企业微信接口
|
||||
* @param api_name 接口名
|
||||
* @param request json格式的请求数据
|
||||
* @param response json格式的返回数据
|
||||
* @return errorcode 参考如下:
|
||||
* 0: 成功
|
||||
* -910001: SDK没有初始化
|
||||
* -910002: 没有设置请求体
|
||||
* -910003: 没有设置请求的API
|
||||
* -910004: 在SDK成员内找不到成员"response",注意lib内有反射机制,不要修改成员变量名
|
||||
* -910005: 使用未初始化的callback初始化SDK
|
||||
* -910006: invoke调用失败,应检查日志查看具体原因
|
||||
* -910007: 响应体为空
|
||||
* @note 当返回0时,表示没有网络或请求协议层面或调用方法的失败,
|
||||
* 调用方需继续检查response中的errcode字段确保业务层面的成功
|
||||
*
|
||||
* @usage 当前版本sdk支持的接口列表,每个接口的具体协议请查看企业微信文档:
|
||||
* https://developer.work.weixin.qq.com/document/path/91201
|
||||
*
|
||||
* +--------------------------------+--------------------------------+
|
||||
* |接口名 |描述 |
|
||||
* |--------------------------------|--------------------------------|
|
||||
* |program_async_job_call_back |上报异步任务结果 |
|
||||
* |sync_msg |获取会话记录 |
|
||||
* |get_group_chat |获取内部群信息 |
|
||||
* |get_agree_status_single |获取单聊会话同意情况 |
|
||||
* |get_agree_status_room |获取群聊会话同意情况 |
|
||||
* |set_hide_sensitiveinfo_config |设置成员会话组件敏感信息隐藏配置|
|
||||
* |get_hide_sensitiveinfo_config |获取成员会话组件敏感信息隐藏配置|
|
||||
* |search_chat |会话名称搜索 |
|
||||
* |search_msg |会话消息搜索 |
|
||||
* |create_rule |新增关键词规则 |
|
||||
* |get_rule_list |获取关键词列表 |
|
||||
* |get_rule_detail |获取关键词规则详情 |
|
||||
* |update_rule |修改关键词规则 |
|
||||
* |delete_rule |删除关键词规则 |
|
||||
* |get_hit_msg_list |获取命中关键词规则的会话记录 |
|
||||
* |create_sentiment_task |创建情感分析任务 |
|
||||
* |get_sentiment_result |获取情感分析结果 |
|
||||
* |create_summary_task |创建摘要提取任务 |
|
||||
* |get_summary_result |获取摘要提取结果 |
|
||||
* |create_customer_tag_task |创建标签匹配任务 |
|
||||
* |get_customer_tag_result |获取标签任务结果 |
|
||||
* |create_recommend_dialog_task |创建话术推荐任务 |
|
||||
* |get_recommend_dialog_result |获取话术推荐结果 |
|
||||
* |create_private_task |创建自定义模型任务 |
|
||||
* |get_private_task_result |获取自定义模型结果 |
|
||||
* |(废弃)document_list |获取知识集列表 |
|
||||
* |create_spam_task |会话反垃圾创建分析任务 |
|
||||
* |get_spam_result |会话反垃圾获取任务结果 |
|
||||
* |create_chatdata_export_job |创建会话内容导出任务 |
|
||||
* |get_chatdata_export_job_status |获取会话内容导出任务结果 |
|
||||
* |spec_notify_app |专区通知应用 |
|
||||
* |create_program_task |创建自定义程序任务 |
|
||||
* |get_program_task_result |获取自定义程序结果 |
|
||||
* |knowledge_base_list |获取企业授权给应用的知识集列表 |
|
||||
* |knowledge_base_create |创建知识集 |
|
||||
* |knowledge_base_detail |获取知识集详情 |
|
||||
* |knowledge_base_add_doc |添加知识集內容 |
|
||||
* |knowledge_base_remove_doc |删除知识集內容 |
|
||||
* |knowledge_base_modify_name |修改知识集名称 |
|
||||
* |knowledge_base_delete |删除知识集 |
|
||||
* |search_contact_or_customer |员工或者客户名称搜索 |
|
||||
* |create_ww_model_task |创建企微通用模型任务 |
|
||||
* |get_ww_model_result |获取企微通用模型结果 |
|
||||
* |get_msg_list_by_page_id |page_id获取消息列表 |
|
||||
* +-----------------------------------------------------------------+
|
||||
* */
|
||||
public int Invoke(String apiName) {
|
||||
return Invoke(specSDKptr, apiName, request);
|
||||
}
|
||||
|
||||
private native int Invoke(long sdk, String apiName, String request);
|
||||
|
||||
// 静态代码块内还无法调用native日志函数,这里的日志在管理系统无法查询
|
||||
static {
|
||||
try {
|
||||
Class.forName("com.tencent.wework.SpecUtil");
|
||||
} catch (ClassNotFoundException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
package com.tencent.wework;
|
||||
|
||||
//import java.lang.management.ManagementFactory;
|
||||
//import java.lang.management.RuntimeMXBean;
|
||||
|
||||
/**
|
||||
* @warning: 1. 不要修改成员变量名,native方法内有反射调用
|
||||
* 2. 调用本地方法需保持包结构,本工具需放在包com.tencent.wework内
|
||||
* 3. 不允许继承,类名和函数名均不可修改,会影响本地方法的引用,详见:javah生成本地方法头文件
|
||||
* 4. 使用其他工具打印的日志将无法被查询,如需使用SLF4j风格的日志或性能更好的日志框架,
|
||||
* 请自行封装SpecUtil.SpecLog或SpecUtil.SpecLogNative方法
|
||||
*
|
||||
* @usage: 1. 获取SDK的版本号
|
||||
* 2. 打印三个级别的日志
|
||||
* 3. 开启调试模式
|
||||
*/
|
||||
public final class SpecUtil {
|
||||
|
||||
/**
|
||||
* @description SDK版本号
|
||||
* @usage 可用于校对不同SDK版本,或后续针对不同的SDK版本添加业务逻辑
|
||||
*/
|
||||
private static final String SDK_VERSION = "1.4.0";
|
||||
|
||||
public static String GetSDKVersion() {
|
||||
return SDK_VERSION;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 正确的包名,SDK必须存放在"com.tencent.wework"下,否则会影响本地方法的调用
|
||||
*/
|
||||
private static final String EXPECTED_PACKAGE_NAME = "com.tencent.wework";
|
||||
|
||||
public static String GetExpectedPackageName() {
|
||||
return EXPECTED_PACKAGE_NAME;
|
||||
}
|
||||
|
||||
private static final String LINE_SEPERATOR = System.getProperty("line.separator");
|
||||
|
||||
public static void WWSpecLogInfo(String... args) {
|
||||
SpecLog('I', args);
|
||||
}
|
||||
|
||||
public static void WWSpecLogError(String... args) {
|
||||
SpecLog('E', args);
|
||||
}
|
||||
|
||||
public static void WWSpecLogDebug(String... args) {
|
||||
SpecLog('D', args);
|
||||
}
|
||||
|
||||
public static void WWSpecLogInfoWithReqId(String reqId, String... args) {
|
||||
SpecLogWithReqId(reqId, 'I', args);
|
||||
}
|
||||
|
||||
public static void WWSpecLogErrorWithReqId(String reqId, String... args) {
|
||||
SpecLogWithReqId(reqId, 'E', args);
|
||||
}
|
||||
|
||||
public static void WWSpecLogDebugWithReqId(String reqId, String... args) {
|
||||
SpecLogWithReqId(reqId, 'D', args);
|
||||
}
|
||||
|
||||
/**
|
||||
* @usage 打印标准日志
|
||||
* @note 只有使用SpecLog和SpecLogNative函数打印的日志能被调试平台查询,其他框架的日志仅能本地查看
|
||||
* @param logLevel 日志级别,使用char传递,目前支持I——INFO、E——ERROR、D——DEBUG
|
||||
* @param args 自定义参数
|
||||
*/
|
||||
public static void SpecLog(char logLevel, String... args) {
|
||||
StackTraceElement element = Thread.currentThread().getStackTrace()[3];
|
||||
SpecLogNative(
|
||||
logLevel,
|
||||
element.getFileName(),
|
||||
element.getLineNumber(),
|
||||
String.join(",", args).replace(LINE_SEPERATOR, " ")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @usage 打印标准日志
|
||||
* @note 只有使用SpecLog和SpecLogNative函数打印的日志能被调试平台查询,其他框架的日志仅能本地查看
|
||||
* @param reqid 请求id
|
||||
* @param logLevel 日志级别,使用char传递,目前支持I——INFO、E——ERROR、D——DEBUG
|
||||
* @param args 自定义参数
|
||||
*/
|
||||
public static void SpecLogWithReqId(String reqId, char logLevel, String... args) {
|
||||
StackTraceElement element = Thread.currentThread().getStackTrace()[3];
|
||||
SpecLogNativeWithReqId(
|
||||
reqId,
|
||||
logLevel,
|
||||
element.getFileName(),
|
||||
element.getLineNumber(),
|
||||
String.join(",", args).replace(LINE_SEPERATOR, " ")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @usage 打印标准日志
|
||||
* @note 只有使用SpecLog和SpecLogNative函数打印的日志能被调试平台查询,其他框架的日志仅能本地查看
|
||||
* 如需SLF4J风格的接口或对日志性能有进一步需求,开发者可以自行封装该函数
|
||||
* @param logLevel 日志级别,使用char传递,目前支持I——INFO、E——ERROR、D——DEBUG
|
||||
* @param fileName 文件名(类名)
|
||||
* @param lineNumber 行号
|
||||
* @param argsString 自定义参数
|
||||
*/
|
||||
public static native void SpecLogNative(char logLevel, String fileName, int lineNumber, String argsString);
|
||||
|
||||
/**
|
||||
* @usage 打印标准日志
|
||||
* @note 只有使用SpecLog和SpecLogNative函数打印的日志能被调试平台查询,其他框架的日志仅能本地查看
|
||||
* 如需SLF4J风格的接口或对日志性能有进一步需求,开发者可以自行封装该函数
|
||||
* @param reqid 请求id
|
||||
* @param logLevel 日志级别,使用char传递,目前支持I——INFO、E——ERROR、D——DEBUG
|
||||
* @param fileName 文件名(类名)
|
||||
* @param lineNumber 行号
|
||||
* @param argsString 自定义参数
|
||||
*/
|
||||
public static native void SpecLogNativeWithReqId(String reqId, char logLevel, String fileName, int lineNumber, String argsString);
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* @usage 开启调试模式,进程级别开关
|
||||
* @param debugToken 调试凭证,在管理端获取
|
||||
* @param accessToken 应用access token
|
||||
* @return 是否开启成功
|
||||
*/
|
||||
public static boolean SpecOpenDebugMode(String debugToken, String accessToken) {
|
||||
return SpecOpenDebugModeNative(debugToken, accessToken);
|
||||
}
|
||||
|
||||
private static native boolean SpecOpenDebugModeNative(String debugToken, String accessToken);
|
||||
|
||||
/**
|
||||
* @usage 生成notify id。用户可调用本接口生成notify id,也可完全自定义生成
|
||||
* @return 新的notify id,支持纳秒级隔离,内部异常时会输出日志并返回空串
|
||||
* @note 1. 用户可先生成notify id,将其与回调数据关联存储后,再使用该notify id通知应用,
|
||||
* 从而保证回调数据被请求时已存储完毕
|
||||
*/
|
||||
public static String GenerateNotifyId() {
|
||||
return GenerateNotifyIdNative();
|
||||
}
|
||||
|
||||
private static native String GenerateNotifyIdNative();
|
||||
|
||||
static {
|
||||
// 检查包名
|
||||
String packageName = SpecUtil.class.getPackage().getName();
|
||||
if (!EXPECTED_PACKAGE_NAME.equals(packageName)) {
|
||||
// 静态代码块内还无法调用native日志函数,这里的日志在管理系统无法查询
|
||||
System.out.println("SpecUtil class must be in package com.tencent.wework");
|
||||
System.exit(1);
|
||||
}
|
||||
|
||||
// 加载so库
|
||||
try {
|
||||
System.loadLibrary("WeWorkSpecSDK");
|
||||
} catch (UnsatisfiedLinkError e) {
|
||||
System.out.println("libWeWorkSpecSDK.so not found in java.library.path");
|
||||
e.printStackTrace();
|
||||
System.exit(1);
|
||||
} catch (Exception e) {
|
||||
System.out.println("unexpected exception: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
System.exit(1);
|
||||
}
|
||||
|
||||
SpecUtil.WWSpecLogInfo("SDK init done", "packageName=" + packageName, "SDK_VERSION=" + SDK_VERSION);
|
||||
}
|
||||
}
|
||||
20
winboll/src/main/res/layout/activity_about.xml
Normal file
20
winboll/src/main/res/layout/activity_about.xml
Normal file
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:orientation="vertical"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<cc.winboll.studio.libaes.views.ASupportToolbar
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:id="@+id/toolbar"/>
|
||||
|
||||
<cc.winboll.studio.libappbase.views.AboutView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1.0"
|
||||
android:id="@+id/aboutview"/>
|
||||
|
||||
</LinearLayout>
|
||||
@@ -6,14 +6,6 @@
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
<cc.winboll.studio.winboll.CustomToolbar
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
app:toolbarTitle="@string/app_name"
|
||||
app:toolbarTitleColor="@color/colorAccent"
|
||||
app:toolbarBackgroundColor="@color/colorPrimary"
|
||||
android:id="@+id/toolbar"/>
|
||||
|
||||
<LinearLayout
|
||||
android:orientation="vertical"
|
||||
android:layout_width="match_parent"
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<android.widget.Toolbar
|
||||
<androidx.appcompat.widget.Toolbar
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:id="@+id/toolbar"/>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<android.widget.Toolbar
|
||||
<androidx.appcompat.widget.Toolbar
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:id="@+id/toolbar"/>
|
||||
|
||||
55
winboll/src/main/res/layout/activity_termux_env_test.xml
Normal file
55
winboll/src/main/res/layout/activity_termux_env_test.xml
Normal file
@@ -0,0 +1,55 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:orientation="vertical"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<cc.winboll.studio.libaes.views.ASupportToolbar
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:id="@+id/toolbar"/>
|
||||
|
||||
<LinearLayout
|
||||
android:orientation="vertical"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="right">
|
||||
|
||||
<Button
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="OpenTermuxBash"
|
||||
android:onClick="onOpenTermuxBash"/>
|
||||
|
||||
<Button
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="TestTermuxCMD"
|
||||
android:onClick="onTestTermuxCMD"/>
|
||||
|
||||
<Button
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="TestTermuxEnv"
|
||||
android:onClick="onTestTermuxEnv"/>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1.0">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Text"
|
||||
android:id="@+id/tv_message"
|
||||
android:textIsSelectable="true"/>
|
||||
|
||||
</ScrollView>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
android:id="@+id/item_settings"
|
||||
android:title="Settings"/>
|
||||
<item
|
||||
android:id="@+id/item_wxpayactivity"
|
||||
android:title="WXPayActivity"/>
|
||||
android:id="@+id/item_about"
|
||||
android:title="About"/>
|
||||
</menu>
|
||||
|
||||
12
winboll/src/main/res/menu/toolbar_test.xml
Normal file
12
winboll/src/main/res/menu/toolbar_test.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<item android:title="UnitTest">
|
||||
<menu >
|
||||
<item
|
||||
android:id="@+id/item_termux_env_test"
|
||||
android:title="TermuxEnvTestActivity"/>
|
||||
</menu>
|
||||
</item>
|
||||
</menu>
|
||||
@@ -1,5 +1,6 @@
|
||||
<resources>
|
||||
<string name="app_name">WinBoLL</string>
|
||||
<string name="app_description">WinBoLL 网站浏览器。</string>
|
||||
<string name="app_name_cn1">筋斗云</string>
|
||||
<string name="app_name_cn2">金抖云</string>
|
||||
<string name="switchto_en1">WinBoLL</string>
|
||||
|
||||
@@ -11,5 +11,11 @@
|
||||
<certificates src="system" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
<!-- 允许特定域名的明文请求(仅本地127.0.0.1) -->
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="true">127.0.0.1</domain>
|
||||
<domain includeSubdomains="true">localhost</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user