Compare commits

...

20 Commits

Author SHA1 Message Date
57cf264e44 fix(企业微信服务): 修复未读聊天检测与点击逻辑
调整变量命名以准确反映收集的是聊天行节点而非红点节点
新增两种点击方式的兜底逻辑,提升点击成功率
添加页面跳转验证,避免点击未生效的假成功情况
优化异常处理流程,点击失败时直接返回false
2026-05-11 16:29:11 +08:00
tanjianbin
9bfb1a9040 update 2026-05-11 16:25:47 +08:00
cad16fa7ca fix(WeworkService): 修复重连后无法检测新消息的问题
重连成功时恢复主循环运行状态并重新发送循环消息,避免服务假运行
2026-05-11 16:19:01 +08:00
2ae2ab0ff5 chore: 移除废弃的打印节点功能及无用设置菜单项
1. 移除左右悬浮菜单中的打印节点按钮及相关点击逻辑
2. 调整悬浮菜单截图按钮的布局间距与logo图标位置,修复XML文件末尾换行问题
3. 删除设置页面的检查更新、赞助、分享应用菜单项及对应代码
2026-05-11 16:08:15 +08:00
ccc0c3c4ae fix(accessibility, floatwindow): 同步无障碍服务状态并优化悬浮窗控制逻辑
- 新增无障碍服务状态广播,实现服务启停状态与UI页面同步
- 重置悬浮窗暂停状态,避免服务重启后卡在上次暂停状态
- 为悬浮窗菜单添加播放/暂停和停止功能
- 修复ListenActivity中无障碍开关切换的无限循环问题
2026-05-11 15:53:42 +08:00
9a9ffb79d5 fix bug 2026-05-11 15:41:50 +08:00
1d9a77a112 bug 2026-05-11 14:59:40 +08:00
8efa4889be websocket 2026-05-11 14:58:31 +08:00
81615a27d9 reconnect 2026-05-11 14:52:26 +08:00
473e2c2d89 change 2026-05-11 14:32:27 +08:00
c6fbcec2d6 fix(WeworkTextUtil): 改进文件大小格式检测以支持更多常见格式
改进正则表达式以兼容更多常见文件大小格式,如"12M"、"12MB"、"12.5 MB"、"12 kb"等。同时添加空值检查,避免空字符串或null值导致异常。
2026-05-11 11:41:45 +08:00
22e6aff8c6 docs: 添加项目交接文档
添加 WorkTool 项目的详细交接文档,涵盖项目概述、技术栈、工程结构、运行流程、配置项、构建流程、调试方法及后续维护建议。该文档旨在帮助后续开发人员快速理解项目整体架构、关键代码位置和运行机制,便于后续维护与二次开发。
2026-05-08 17:26:43 +08:00
6a70f7ef5d feat(设置): 添加服务器地址配置功能
添加服务器地址配置界面,允许用户自定义WebSocket服务器地址。包含输入验证,确保地址格式正确(ws://或wss://开头)。
2026-03-31 17:28:52 +08:00
518a7d813e chore: 更新默认服务器地址和机器人ID配置
将默认WebSocket服务器地址从本地IP更改为远程服务器地址
更新ListenActivity中的机器人ID为新的标识符
2026-03-30 15:43:54 +08:00
701c5a815f fix: 修复添加好友成功后未发送好友信息事件的问题
在添加好友成功后,需要向 WebSocket 发送包含好友信息的 GET_FRIEND_INFO 事件,以便其他模块能及时获取并处理新好友数据。
2026-03-27 18:03:32 +08:00
e2160aa59f refactor: 移除添加好友后获取详细信息的冗余逻辑
移除 `getFriendDetailInfo` 方法及相关调用,因为添加好友成功后不再需要立即进入详情页抓取信息。这简化了添加好友的流程,避免了不必要的页面跳转和潜在的稳定性问题。
2026-03-27 17:38:26 +08:00
f195ef614d fix: 返回主页后增加延迟以避免界面不稳定
在机器人主循环中,返回主页后立即执行后续操作可能导致界面状态不稳定。增加一个与弹窗检测间隔相同的延迟,确保界面完全稳定后再进行后续检测,避免因快速重复检测引发的问题。
2026-03-27 16:04:28 +08:00
287a1ece7f refactor: 移除会话存在性校验并添加发送消息异常保护
移除 WeworkOperationImpl 中发送消息前的会话存在性校验,简化逻辑
在 WeworkController 的 sendMessage 和 replyMessage 方法中添加 try-finally 块
确保 waitingForReply 状态在发送消息后无论成功失败都能正确重置
2026-03-27 15:54:32 +08:00
79ed03c0fd refactor(WeworkLoopImpl): comment out debug log statements for cleaner output
Commented out various LogUtils debug statements throughout the WeworkLoopImpl class to reduce log clutter during execution. This change aims to improve readability and maintainability of the code without affecting functionality.
2026-03-27 15:42:13 +08:00
1a012937cf fix: 在发送消息时设置等待标志防止重复进入聊天页
在 WeworkController 的 sendMessage 和 replyMessage 方法开始时设置 waitingForReply = true,避免主循环在消息发送过程中重复检测并进入同一聊天窗口。同时移除 WeworkLoopImpl 中已不需要的 waitForServerReply() 调用及相关注释。
2026-03-27 15:27:15 +08:00
21 changed files with 820 additions and 528 deletions

View File

@@ -14,7 +14,7 @@ object Constant {
var LONG_INTERVAL = BASE_LONG_INTERVAL
var CHANGE_PAGE_INTERVAL = BASE_CHANGE_PAGE_INTERVAL
var POP_WINDOW_INTERVAL = BASE_POP_WINDOW_INTERVAL
private const val DEFAULT_HOST = "ws://192.168.6.50:8080"
private const val DEFAULT_HOST = "ws://8.166.130.74:18680"
var version = Int.MAX_VALUE
var myName = ""
@@ -58,7 +58,7 @@ object Constant {
SPUtils.getInstance().put(weworkCorpName + "weworkMP", value)
}
var encryptType: Int = SPUtils.getInstance().getInt("encryptType", 0)
var autoReply: Int = SPUtils.getInstance().getInt("autoReply", 1)
var autoReply: Int = SPUtils.getInstance().getInt("autoReply", 0)
var groupStrict: Boolean
get() = SPUtils.getInstance().getBoolean("groupStrict", false)
set(value) = SPUtils.getInstance().put("groupStrict", value)

View File

@@ -1,56 +0,0 @@
package org.yameida.worktool
import com.blankj.utilcode.util.TimeUtils
import org.yameida.worktool.model.WeworkMessageBean
import org.yameida.worktool.service.MyLooper
import org.yameida.worktool.service.WeworkController
import org.yameida.worktool.service.WeworkLoopImpl
import org.yameida.worktool.service.getRoot
import org.yameida.worktool.utils.AccessibilityUtil
import java.util.*
/**
* 示例
*/
object Demo {
fun test(flag: Boolean) {
if (!flag) return
MyLooper.getInstance().removeCallbacksAndMessages(null)
//打印当前视图树
// AccessibilityUtil.printNodeClazzTree(getRoot())
}
fun test2(name: String) {
val time = TimeUtils.date2String(Date(), "MMddHHmm")
val groupName = "测试群$time"
val json = """
{
"socketType":2,
"list":[
{
"type":203,
"titleList":[
"$name"
],
"receivedContent":"你好~我是机器人,你可以@我和我聊天你也可以通过API文档来让我发送消息或完成建群等任务。接口文档https://www.apifox.cn/apidoc/project-1035094/api-23520034"
},
{
"type": 206,
"groupName": "$groupName",
"selectList": [
"$name",
"甲仑"
],
"groupAnnouncement": "(自动拉群+自动群公告) WorkTool欢迎大家~WorkTool管家是机器人有问题可以在QQ群反馈~@我可以聊天~"
}
]
}
""".trimIndent()
MyLooper.onMessage(null, json)
}
}

View File

@@ -22,11 +22,13 @@ import org.yameida.worktool.utils.*
import org.yameida.worktool.utils.capture.MediaProjectionHolder
import org.yameida.worktool.utils.envcheck.CheckHook
import org.yameida.worktool.utils.envcheck.CheckRoot
import kotlin.random.Random
class ListenActivity : AppCompatActivity() {
var riskRetry: Int = 0
private var updatingAccessibilitySwitch = false
companion object {
/**
@@ -59,12 +61,13 @@ class ListenActivity : AppCompatActivity() {
override fun onDestroy() {
super.onDestroy()
unregisterReceiver(openWsReceiver)
FloatWindowHelper.hideWindow()
}
override fun onResume() {
super.onResume()
sw_overlay.isChecked = Settings.canDrawOverlays(Utils.getApp()) && FlowPermissionHelper.canBackgroundStart(Utils.getApp())
sw_accessibility.isChecked = PermissionHelper.isAccessibilitySettingOn()
refreshAccessibilitySwitch()
if (needToWork) {
needToWork = false
goToWork()
@@ -75,16 +78,11 @@ class ListenActivity : AppCompatActivity() {
iv_settings.setOnClickListener {
SettingsActivity.enterActivity(this)
}
et_channel.setText(Constant.robotId)
bt_save.setOnClickListener {
val channel = et_channel.text.toString().trim()
Constant.robotId = channel
ToastUtils.showLong("保存成功")
sendBroadcast(Intent(Constant.WEWORK_NOTIFY).apply {
putExtra("type", "modify_channel")
})
HttpUtil.getMyConfig(toast = false)
MobclickAgent.onProfileSignIn(channel)
tv_channel.text = Constant.robotId.ifBlank { "未生成" }
bt_reset_channel.setOnClickListener {
val channel = generateDefaultRobotId()
tv_channel.text = channel
saveChannel(channel, toast = "重置成功")
}
tv_host.text = Constant.host
tv_host.setOnClickListener {
@@ -131,6 +129,9 @@ class ListenActivity : AppCompatActivity() {
private fun initAccessibility() {
sw_accessibility.setOnCheckedChangeListener(CompoundButton.OnCheckedChangeListener { buttonView, isChecked ->
if (updatingAccessibilitySwitch) {
return@OnCheckedChangeListener
}
LogUtils.i("sw_accessibility onCheckedChanged: $isChecked")
if (isChecked) {
if (Constant.robotId.isBlank()) {
@@ -185,13 +186,45 @@ class ListenActivity : AppCompatActivity() {
}
private fun initData() {
Constant.robotId = "111"
Constant.host = "ws://192.168.6.50:8080"
// 链接号为空时自动生成一次并持久化,避免每次启动覆盖用户手动保存的链接号
if (Constant.robotId.isBlank()) {
Constant.robotId = generateDefaultRobotId()
tv_channel.text = Constant.robotId
}
// HttpUtil.checkUpdate()
HttpUtil.getMyConfig(toast = false)
CacheUtil.autoDelete()
}
private fun generateDefaultRobotId(): String {
val suffix = Random.nextInt(1000, 9999)
return "${System.currentTimeMillis()}$suffix"
}
private fun saveChannel(channel: String, toast: String) {
if (channel.isBlank()) {
ToastUtils.showLong("链接号不能为空")
return
}
Constant.robotId = channel
ToastUtils.showLong(toast)
sendBroadcast(Intent(Constant.WEWORK_NOTIFY).apply {
putExtra("type", "modify_channel")
})
if (WeworkController.isServiceReady()) {
runCatching {
WeworkController.weworkService.reconnectWebSocket("modify_channel_direct")
}.onFailure {
LogUtils.w("重置后直连重连失败,等待广播触发: ${it.message}")
}
} else {
ToastUtils.showLong("无障碍服务未运行,请先开启后再重置")
LogUtils.w("重置时服务未就绪,无法触发直连重连")
}
HttpUtil.getMyConfig(toast = false)
MobclickAgent.onProfileSignIn(channel)
}
private fun initNotification() {
if (!Constant.enableMediaProject) {
return
@@ -206,7 +239,7 @@ class ListenActivity : AppCompatActivity() {
}, Context.BIND_AUTO_CREATE)
//开启屏幕录制权限
if (MediaProjectionHolder.mMediaProjection == null) {
bt_save.postDelayed({
window.decorView.postDelayed({
fastStartActivity(this, GetScreenShotActivity::class.java)
}, 1000)
}
@@ -282,8 +315,8 @@ class ListenActivity : AppCompatActivity() {
.setNegativeButton("", null)
.setPositiveButton("", null)
val show = positiveButton.show()
bt_save.postDelayed({ show.dismiss() }, 5000)
bt_save.postDelayed({
window.decorView.postDelayed({ show.dismiss() }, 5000)
window.decorView.postDelayed({
packageManager.getLaunchIntentForPackage(Constant.PACKAGE_NAMES)?.apply {
this.flags = Intent.FLAG_ACTIVITY_NEW_TASK
startActivity(this)
@@ -293,10 +326,21 @@ class ListenActivity : AppCompatActivity() {
private val openWsReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.getStringExtra("type") == "openWs") {
needToWork = intent.getBooleanExtra("switch", false)
when (intent.getStringExtra("type")) {
"openWs" -> {
needToWork = intent.getBooleanExtra("switch", false)
}
"accessibility_state" -> {
refreshAccessibilitySwitch(intent.getBooleanExtra("enabled", PermissionHelper.isAccessibilitySettingOn()))
}
}
}
}
private fun refreshAccessibilitySwitch(checked: Boolean = PermissionHelper.isAccessibilitySettingOn()) {
updatingAccessibilitySwitch = true
sw_accessibility.isChecked = checked
updatingAccessibilitySwitch = false
}
}

View File

@@ -69,9 +69,6 @@ class SettingsActivity : AppCompatActivity() {
})
rl_reply_strategy.setOnClickListener { showReplyStrategyDialog() }
rl_log.setOnClickListener { showLogDialog() }
rl_update.setOnClickListener { showUpdateDialog() }
rl_donate.setOnClickListener { showDonateDialog() }
rl_share.setOnClickListener { showShareDialog() }
rl_advance.setOnClickListener { SettingsAdvanceActivity.enterActivity(this) }
freshOpenFlow()
bt_open_flow.setOnClickListener {
@@ -148,37 +145,6 @@ class SettingsActivity : AppCompatActivity() {
}
}
private fun showUpdateDialog() {
if (Constant.getMasterCheckUpdateUrl() == Constant.getCheckUpdateUrl()) {
HttpUtil.checkUpdate()
} else {
QMUIDialog.CheckableDialogBuilder(this)
.setTitle("检查新版本")
.addItems(arrayOf("检查当前Host新版本", "检查${getString(R.string.app_name)}官方新版本")) { dialog, which ->
dialog.dismiss()
if (which == 0) {
HttpUtil.checkUpdate()
} else {
HttpUtil.checkUpdate(Constant.getMasterCheckUpdateUrl())
}
}
.create(R.style.QMUI_Dialog)
.show()
}
}
private fun showDonateDialog() {
DonateUtil.zfbDonate(this)
}
private fun showShareDialog() {
startActivity(Intent.createChooser(Intent().apply {
action = Intent.ACTION_SEND
type = ShareUtil.TEXT
putExtra(Intent.EXTRA_TEXT, "我发现一个非常好用的企业微信机器人程序,文档地址: https://worktool.apifox.cn/ APP下载地址是: https://cdn.asrtts.cn/uploads/worktool/apk/worktool-latest.apk")
}, "分享"))
}
private fun freshOpenFlow() {
if (Settings.canDrawOverlays(Utils.getApp())) {
if (FlowPermissionHelper.canBackgroundStart(Utils.getApp())) {

View File

@@ -78,6 +78,8 @@ class SettingsAdvanceActivity : AppCompatActivity() {
ll_corp_param.visibility = if (Constant.customLink) View.VISIBLE else View.GONE
rl_username.visibility = if (Constant.customMP) View.VISIBLE else View.GONE
rl_qa_url.setOnClickListener { showQaUrlDialog() }
rl_host.setOnClickListener { showHostDialog() }
tv_select_host.text = Constant.host
rl_corp_name.setOnClickListener { showCorpNameDialog() }
rl_corp.setOnClickListener { showCorpIdDialog() }
rl_agent.setOnClickListener { showAgentIdDialog() }
@@ -117,6 +119,32 @@ class SettingsAdvanceActivity : AppCompatActivity() {
.create(R.style.QMUI_Dialog).show()
}
private fun showHostDialog() {
val builder = QMUIDialog.EditTextDialogBuilder(this)
builder.setTitle("服务器地址")
.setPlaceholder("请输入WebSocket服务器地址")
.setDefaultText(Constant.host)
.setInputType(InputType.TYPE_CLASS_TEXT)
.addAction(getString(R.string.cancel)) { dialog, index -> dialog.dismiss() }
.addAction(getString(R.string.add)) { dialog, index ->
val text = builder.editText.text
if (text != null && text.isNotEmpty()) {
val host = text.toString().trim()
if (host.matches("wss?://[^:]+:\\d+".toRegex())) {
dialog.dismiss()
Constant.host = host
tv_select_host.text = host
ToastUtils.showLong("服务器地址已更新")
} else {
ToastUtils.showLong("格式异常!示例: ws://192.168.1.1:18680")
}
} else {
ToastUtils.showLong("请勿为空!")
}
}
.create(R.style.QMUI_Dialog).show()
}
private fun updateRobotQaUrl(callbackUrl: String) {
try {
val json = hashMapOf<String, Any>()

View File

@@ -1,7 +1,6 @@
package org.yameida.worktool.service
import com.blankj.utilcode.util.*
import org.yameida.worktool.Demo
import org.yameida.worktool.annotation.RequestMapping
import org.yameida.worktool.model.ExecCallbackBean
import org.yameida.worktool.model.WeworkMessageBean
@@ -18,6 +17,8 @@ object WeworkController {
/** 是否正在等待回复 - 等待时会暂停主循环扫描新消息 */
var waitingForReply = false
fun isServiceReady(): Boolean = ::weworkService.isInitialized
/**
* 交互通知
* @see WeworkMessageBean.TYPE_CONSOLE_TOAST
@@ -63,10 +64,15 @@ object WeworkController {
@RequestMapping
fun sendMessage(message: WeworkMessageBean): Boolean {
LogUtils.d("REQUEST sendMessage(): ${message.messageId} ${message.titleList} ${message.receivedContent} ${message.at} ${message.atList?.joinToString()}")
val result = WeworkOperationImpl.sendMessage(message, message.titleList, message.receivedContent, message.at, message.atList)
// 发送完成,通知等待结束
waitingForReply = false
return result
// 进入发送消息流程,暂停主循环检测新消息,避免重复进入同一聊天页
waitingForReply = true
try {
val result = WeworkOperationImpl.sendMessage(message, message.titleList, message.receivedContent, message.at, message.atList)
return result
} finally {
// 无论成功还是失败,都要恢复等待状态
waitingForReply = false
}
}
/**
@@ -82,17 +88,22 @@ object WeworkController {
@RequestMapping
fun replyMessage(message: WeworkMessageBean): Boolean {
LogUtils.d("REQUEST replyMessage(): ${message.messageId} ${message.receivedName} ${message.originalContent} ${message.textType} ${message.receivedContent}")
val result = WeworkOperationImpl.replyMessage(
message,
message.titleList,
message.receivedName,
message.originalContent,
message.textType,
message.receivedContent
)
// 发送完成,通知等待结束
waitingForReply = false
return result
// 进入发送消息流程,暂停主循环检测新消息,避免重复进入同一聊天页
waitingForReply = true
try {
val result = WeworkOperationImpl.replyMessage(
message,
message.titleList,
message.receivedName,
message.originalContent,
message.textType,
message.receivedContent
)
return result
} finally {
// 无论成功还是失败,都要恢复等待状态
waitingForReply = false
}
}
/**
* 在房间内转发消息
@@ -149,7 +160,6 @@ object WeworkController {
@RequestMapping
fun test(message: WeworkMessageBean? = null) {
LogUtils.d(message)
Demo.test(true)
}
/**

View File

@@ -7,7 +7,6 @@ import androidx.core.text.isDigitsOnly
import com.blankj.utilcode.util.*
import com.hjq.toast.ToastUtils
import org.yameida.worktool.Constant
import org.yameida.worktool.Demo
import org.yameida.worktool.MyApplication
import org.yameida.worktool.activity.GetScreenShotActivity
import org.yameida.worktool.model.WeworkMessageBean
@@ -38,7 +37,7 @@ object WeworkLoopImpl {
while (mainLoopRunning) {
// 如果正在等待回复,跳过新消息检测,避免多消息处理混乱
if (WeworkController.waitingForReply) {
LogUtils.d("等待回复中,暂停检测新消息...")
// LogUtils.d("等待回复中,暂停检测新消息...")
sleep(Constant.POP_WINDOW_INTERVAL)
continue
}
@@ -46,11 +45,10 @@ object WeworkLoopImpl {
LogUtils.d("当前在房间: ")
getChatMessageList()
if (mainLoopRunning) {
// 如果不是等待回复状态,则返回主页
if (!WeworkController.waitingForReply) {
goHome()
} else {
LogUtils.d("等待回复中,保持当前页面...")
// LogUtils.d("等待回复中,保持当前页面...")
}
}
continue
@@ -66,11 +64,13 @@ object WeworkLoopImpl {
}
// 获取消息后等待服务端回复指令最多30秒
if (mainLoopRunning) {
waitForServerReply()
// waitForServerReply()
}
// 等待完成后返回主页
if (mainLoopRunning) {
goHome()
// 返回主页后延迟一下,让界面稳定,避免快速重复检测
sleep(Constant.POP_WINDOW_INTERVAL)
}
}
if (!mainLoopRunning) break
@@ -143,7 +143,7 @@ object WeworkLoopImpl {
val childCount = item.parent?.parent?.parent?.childCount
if (childCount == 4 || childCount == 5) {
if (item.parent != null && item.parent.childCount > 1) {
LogUtils.d("通讯录有红点")
// LogUtils.d("通讯录有红点")
AccessibilityUtil.performClick(item)
val hasRecommendFriend = AccessibilityUtil.findOneByText(getRoot(), "可能的同事", exact = true, timeout = Constant.POP_WINDOW_INTERVAL)
if (hasRecommendFriend != null) {
@@ -175,8 +175,7 @@ object WeworkLoopImpl {
val nameList = passFriendRequest()
if (nameList.isEmpty())
break
//todo 可自定义执行任务
// Demo.test2(nameList[0])
// todo 可自定义执行任务
}
}
return true
@@ -184,7 +183,7 @@ object WeworkLoopImpl {
LogUtils.d("未发现待添加客户")
}
} else {
LogUtils.v("通讯录无红点")
// LogUtils.v("通讯录无红点")
}
}
}
@@ -327,7 +326,6 @@ object WeworkLoopImpl {
null
)
WeworkController.weworkService.webSocketManager.send(messageBean)
// 方案A发送消息后立即返回主页继续检测下一条消息不等待
if (needInfer) {
val lastMessage = messageList.lastOrNull()
if (lastMessage != null && lastMessage.sender == 0) {
@@ -344,13 +342,10 @@ object WeworkLoopImpl {
|| tempContent.isNotBlank()
) {
LogUtils.v("推测需要回复: $tempContent")
// 方案A不清空 waitingForReply直接返回主页
// 这样主循环会继续检测下一条消息
return true
}
}
2 -> {
// 方案A不清空 waitingForReply直接返回主页
return true
}
else -> return true
@@ -458,11 +453,13 @@ object WeworkLoopImpl {
if (textNode?.text?.toString() == "添加请求已过期,添加失败") {
LogUtils.d("添加好友失败")
} else {
// 获取完整的好友信息
val friendInfo = getFriendDetailInfo(friendName)
val friend = WeworkMessageBean.Friend().apply {
name = friendName
newFriend = true
}
val weworkMessageBean = WeworkMessageBean()
weworkMessageBean.type = WeworkMessageBean.GET_FRIEND_INFO
weworkMessageBean.friend = friendInfo
weworkMessageBean.friend = friend
WeworkController.weworkService.webSocketManager.send(weworkMessageBean)
nameList.add(friendName)
}
@@ -479,66 +476,6 @@ object WeworkLoopImpl {
return nameList
}
/**
* 获取好友详细信息
* 通过好友后进入详情页抓取完整信息
*/
private fun getFriendDetailInfo(friendName: String): WeworkMessageBean.Friend {
val friend = WeworkMessageBean.Friend().apply {
name = friendName
newFriend = true
}
try {
// 点击发消息进入聊天页面
val sendMsgNode = AccessibilityUtil.findOneByText(getRoot(), "发消息", exact = true)
if (sendMsgNode != null) {
AccessibilityUtil.performClick(sendMsgNode)
sleep(Constant.CHANGE_PAGE_INTERVAL)
}
// 进入好友详情页
if (WeworkRoomUtil.intoFriendDetail()) {
sleep(Constant.CHANGE_PAGE_INTERVAL)
// 解析详情页信息
val listView = AccessibilityUtil.findOneByClazz(getRoot(), Views.ListView)
if (listView != null) {
// 遍历列表获取各字段信息
for (i in 0 until listView.childCount) {
val item = listView.getChild(i) ?: continue
val textViews = AccessibilityUtil.findAllOnceByClazz(item, Views.TextView)
if (textViews.size >= 2) {
val label = textViews[0].text?.toString() ?: ""
val value = textViews[1].text?.toString() ?: ""
when {
label.contains("微信号") || label.contains("微信ID") -> friend.wechatId = value
label.contains("手机") -> friend.phone = value
label.contains("企业") -> friend.corpName = value
label.contains("部门") -> friend.department = value
label.contains("职位") || label.contains("职务") -> friend.position = value
label.contains("邮箱") -> friend.email = value
label.contains("备注") -> friend.markName = value
}
LogUtils.d("好友详情 - $label: $value")
}
}
}
// 尝试获取头像(如果有)
val avatarView = AccessibilityUtil.findOneByClazz(getRoot(), Views.ImageView)
if (avatarView != null) {
// 头像通常没有直接URL这里留空或可通过其他方式获取
// friend.avatarUrl = ""
}
// 返回聊天页面
backPress()
sleep(Constant.POP_WINDOW_INTERVAL)
}
} catch (e: Exception) {
LogUtils.e("获取好友详情失败: ${e.message}")
}
LogUtils.d("好友信息: $friend")
return friend
}
/**
* 获取单聊联系人详细信息
*/
@@ -588,7 +525,7 @@ object WeworkLoopImpl {
if (!isAtHome()) { goHome() }
if (logIndex++ % 30 == 0) {
LogUtils.d("读取首页聊天列表")
// LogUtils.d("读取首页聊天列表")
if (logIndex % 120 == 0) log("读取首页聊天列表")
}
@@ -630,7 +567,7 @@ object WeworkLoopImpl {
//消息不一致
return true
} else {
LogUtils.v("未发现新消息或无提示消息")
// LogUtils.v("未发现新消息或无提示消息")
}
}
} else {
@@ -641,7 +578,7 @@ object WeworkLoopImpl {
//让企微切换页面使APP保持活跃
goHomeTab("通讯录")
goHomeTab("消息")
LogUtils.d("检查最近列表")
//LogUtils.d("检查最近列表")
log("检查最近列表")
if (hasNewMessage()) {
return false
@@ -699,7 +636,7 @@ object WeworkLoopImpl {
* 检查首页-聊天列表是否有未读红点并点击进入
*/
private fun checkUnreadChatRoom(list: AccessibilityNodeInfo): Boolean {
val spotNodeList = arrayListOf<AccessibilityNodeInfo>()
val unreadRowNodeList = arrayListOf<AccessibilityNodeInfo>()
for (i in 0 until list.childCount) {
val item = list.getChild(i)
if (item != null && Views.RelativeLayout.equals(item.className)) {
@@ -710,20 +647,22 @@ object WeworkLoopImpl {
&& spotNode.text != null
&& spotNode.text.toString().replace("+", "").isDigitsOnly()
) {
spotNodeList.add(spotNode)
unreadRowNodeList.add(item)
}
}
}
}
if (spotNodeList.size > 0) {
LogUtils.i("发现未读消息: " + spotNodeList.size + "")
log("发现未读消息: " + spotNodeList.size + "")
if (AccessibilityUtil.performClick(spotNodeList.firstOrNull())) {
//进入聊天页 下一步 getChatMessageList
} else {
AccessibilityUtil.clickByNode(WeworkController.weworkService, spotNodeList.firstOrNull()?.parent)
if (unreadRowNodeList.isNotEmpty()) {
LogUtils.i("发现未读消息: " + unreadRowNodeList.size + "")
log("发现未读消息: " + unreadRowNodeList.size + "")
val firstUnreadRow = unreadRowNodeList.firstOrNull()
val clicked = AccessibilityUtil.performClick(firstUnreadRow)
|| AccessibilityUtil.clickByNode(WeworkController.weworkService, firstUnreadRow)
if (!clicked) {
return false
}
return true
// 避免“点击红点但未实际进会话”的假成功
return AccessibilityUtil.waitForPageMissing("WwMainActivity", "GlobalSearchActivity", timeout = 2000)
} else {
return false
}
@@ -747,7 +686,7 @@ object WeworkLoopImpl {
if (tvList[2].contains("(退出了外部群)|(移出了群聊)|(邀请你加入了)|(修改群名为)|(此群为外部群)|(加入了外部群)".toRegex())) {
val interval = System.currentTimeMillis() / 1000 - SPUtils.getInstance("noTipMessage").getLong(tvList[0], 0)
if (interval > 3600) {
LogUtils.i("发现无提示消息: $tvList")
// LogUtils.i("发现无提示消息: $tvList")
log("发现无提示消息: $tvList")
if (AccessibilityUtil.performClick(item)) {
//进入聊天页 下一步 getChatMessageList
@@ -757,11 +696,11 @@ object WeworkLoopImpl {
SPUtils.getInstance("noTipMessage").put(tvList[0], System.currentTimeMillis() / 1000)
return 1
} else {
LogUtils.v("发现无提示消息: $tvList 消息在 $interval 秒前已被查看")
// LogUtils.v("发现无提示消息: $tvList 消息在 $interval 秒前已被查看")
}
}
} else {
LogUtils.v("未发现无提示消息: ${tvList[1]}")
// LogUtils.v("未发现无提示消息: ${tvList[1]}")
return -1
}
}
@@ -795,7 +734,7 @@ object WeworkLoopImpl {
continue
}
if (SPUtils.getInstance("noSyncMessage").getString(title) != lastSyncMessage) {
LogUtils.e("发现不一致消息: $tvList $lastSyncMessage")
// LogUtils.e("发现不一致消息: $tvList $lastSyncMessage")
error("发现不一致消息: $tvList $lastSyncMessage")
SPUtils.getInstance("noSyncMessage").put(title, lastSyncMessage)
if (AccessibilityUtil.performClick(item)) {
@@ -805,10 +744,10 @@ object WeworkLoopImpl {
}
return 1
} else {
LogUtils.v("消息多次不一致: $tvList")
// LogUtils.v("消息多次不一致: $tvList")
}
} else {
LogUtils.v("未发现不一致消息: ${tvList[1]}")
// LogUtils.v("未发现不一致消息: ${tvList[1]}")
return -1
}
}

View File

@@ -45,22 +45,6 @@ object WeworkOperationImpl {
return false
}
// 验证titleList是否在当前会话列表中存在
val currentRoomList = WeworkRoomUtil.getRoomTitle(print = false)
if (currentRoomList.isEmpty()) {
LogUtils.d("无法获取当前会话列表")
uploadCommandResult(message, ExecCallbackBean.ERROR_SEND_MESSAGE, "无法获取当前会话列表,请确保在微信首页", startTime, listOf(), titleList)
return false
}
val notFoundList = titleList.filter { title ->
currentRoomList.none { it == title || it.replace(Constant.digitalRegex, "") == title || title.replace(Constant.digitalRegex, "") == it }
}
if (notFoundList.isNotEmpty()) {
LogUtils.d("以下会话不存在: ${notFoundList.joinToString()}")
uploadCommandResult(message, ExecCallbackBean.ERROR_SEND_MESSAGE, "会话不存在: ${notFoundList.joinToString()}", startTime, listOf(), titleList)
return false
}
val successList = arrayListOf<String>()
val failList = arrayListOf<String>()
for (title in LinkedHashSet(titleList)) {

View File

@@ -6,6 +6,7 @@ import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.FileObserver
import android.os.Message
import android.util.Log
import android.view.accessibility.AccessibilityEvent
import com.blankj.utilcode.util.*
@@ -13,11 +14,10 @@ import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import org.yameida.worktool.Constant
import org.yameida.worktool.Demo
import org.yameida.worktool.model.WeworkMessageBean
import org.yameida.worktool.observer.MultiFileObserver
import org.yameida.worktool.utils.*
import java.lang.Exception
import kotlin.concurrent.thread
/**
* 企业微信辅助服务
@@ -39,24 +39,23 @@ class WeworkService : AccessibilityService() {
LogUtils.i("初始化成功")
//隐藏软键盘模式
softKeyboardController.showMode = SHOW_MODE_HIDDEN
// 服务重启后恢复主功能默认运行态,避免被上一次暂停状态卡住
FloatWindowHelper.isPause = false
WeworkController.weworkService = this
WeworkController.enableLoopRunning = true
notifyAccessibilityState(true)
//初始化长连接
initWebSocket()
//初始化消息处理器
MyLooper.init()
//初始化图片接收
initObserver()
//开发者可以在这里添加测试代码 启动时调用一次
thread { Demo.test(AppUtils.isAppDebug()) }
//监听是否修改链接号并重新长连接
registerReceiver(object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.getStringExtra("type") == "modify_channel") {
LogUtils.e("更新channel")
webSocketManager.close(1000, "modify_channel")
initWebSocket()
reconnectWebSocket("modify_channel")
}
}
}, IntentFilter(Constant.WEWORK_NOTIFY))
@@ -69,6 +68,14 @@ class WeworkService : AccessibilityService() {
webSocketManager = WebSocketManager(url, listener)
}
fun reconnectWebSocket(reason: String) {
LogUtils.i("reconnectWebSocket: $reason")
if (::webSocketManager.isInitialized) {
webSocketManager.close(1000, reason)
}
initWebSocket()
}
private fun initObserver() {
if (!Constant.pushImage) return
try {
@@ -113,16 +120,24 @@ class WeworkService : AccessibilityService() {
LogUtils.i("onDestroy")
//关闭自动回复
WeworkController.enableLoopRunning = false
// 关闭主功能时清理暂停态,确保下次开启可立即进入检测
FloatWindowHelper.isPause = false
notifyAccessibilityState(false)
//隐藏软键盘模式
softKeyboardController.showMode = SHOW_MODE_AUTO
webSocketManager.close(1000, "service Destroy")
}
private fun notifyAccessibilityState(enabled: Boolean) {
sendBroadcast(Intent(Constant.WEWORK_NOTIFY).apply {
putExtra("type", "accessibility_state")
putExtra("enabled", enabled)
})
}
inner class EchoWebSocketListener : WebSocketListener() {
private val TAG = "WeworkService.EchoWebSocketListener"
private lateinit var socket: WebSocket
override fun onOpen(webSocket: WebSocket, response: Response) {
socket = webSocket
Log.e(TAG, "连接建立")
val robotId = Constant.robotId
val appVersion = SPUtils.getInstance().getString("appVersion", "")
@@ -131,6 +146,13 @@ class WeworkService : AccessibilityService() {
val hook = SPUtils.getInstance().getBoolean("hook", false)
LogUtils.i("连接建立: $robotId appVersion: $appVersion workVersion: $workVersion deviceRooted: $deviceRooted hook: $hook")
log("连接建立: $robotId appVersion: $appVersion workVersion: $workVersion deviceRooted: $deviceRooted hook: $hook")
// 断线后会被置为false重连成功时主动恢复主循环避免出现“提示运行中但未检测消息”
WeworkController.enableLoopRunning = true
MyLooper.getInstance().removeMessages(WeworkMessageBean.LOOP_RECEIVE_NEW_MESSAGE)
MyLooper.getInstance().sendMessage(Message.obtain().apply {
what = WeworkMessageBean.LOOP_RECEIVE_NEW_MESSAGE
obj = WeworkMessageBean().apply { type = WeworkMessageBean.LOOP_RECEIVE_NEW_MESSAGE }
})
LogUtils.i("设置自动跳转企业微信")
sendBroadcast(true)
}
@@ -154,7 +176,7 @@ class WeworkService : AccessibilityService() {
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
super.onClosing(webSocket, code, reason)
socket.close(code, reason)
webSocket.close(code, reason)
Log.e(TAG, "服务端关闭连接 $code: $reason")
sendBroadcast(false)
}

View File

@@ -31,6 +31,7 @@ import kotlin.concurrent.thread
object FloatWindowHelper {
var isPause = false
private var bound = false
fun showWindow() {
LogUtils.d("FloatWindowHelper.showWindow()")
@@ -39,7 +40,23 @@ object FloatWindowHelper {
val app = Utils.getApp()
val intent = Intent(app, DefaultFloatService::class.java)
app.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
if (!bound) {
bound = app.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
}
}
fun hideWindow() {
LogUtils.d("FloatWindowHelper.hideWindow()")
val app = Utils.getApp()
FloatWindowManager.hide(DefaultFloatService::class.java)
if (bound) {
try {
app.unbindService(serviceConnection)
} catch (ignore: Exception) {
}
bound = false
}
app.stopService(Intent(app, DefaultFloatService::class.java))
}
/**
@@ -85,35 +102,6 @@ object FloatWindowHelper {
service.onClickListener = object : OnClickListener {
override fun onClick(v: View, event: Int) {
when (event) {
1 -> {
if (PermissionHelper.isAccessibilitySettingOn()) {
if (!isPause) {
ToastUtils.showShort("请先暂停Awin WorkTool主功能~")
return
}
thread {
val printNodeClazzTree =
AccessibilityUtil.printNodeClazzTree(getRoot(true))
val df = SimpleDateFormat("MMdd_HHmmss")
val filePath = "${
Utils.getApp().getExternalFilesDir("share")
}/${df.format(Date())}/${df.format(Date())}_printNode.txt"
val newFile = File(filePath)
val create = FileUtils.createFileByDeleteOldFile(newFile)
if (create && newFile.canWrite()) {
printNodeClazzTree.append("\n")
.append(WeworkController.weworkService.currentPackage)
.append("\n")
.append(WeworkController.weworkService.currentClass)
newFile.writeBytes(printNodeClazzTree.toString().toByteArray())
LogUtils.i("打印节点文件存储本地成功 $filePath", "当前页面: ${WeworkController.weworkService.currentClass}")
}
ShareUtil.share("*/*", newFile)
}
} else {
ToastUtils.showShort("请先打开Awin WorkTool主功能~")
}
}
2 -> {
if (PermissionHelper.isAccessibilitySettingOn()) {
if (isPause) {
@@ -127,6 +115,28 @@ object FloatWindowHelper {
ToastUtils.showShort("请先打开Awin WorkTool主功能~")
}
}
5 -> {
if (PermissionHelper.isAccessibilitySettingOn()) {
if (isPause) {
Glide.with(Utils.getApp()).load(R.drawable.float_icon_pause).into(v as ImageView)
accessibilityServiceResume()
} else {
Glide.with(Utils.getApp()).load(R.drawable.float_icon_play).into(v as ImageView)
accessibilityServicePause()
}
} else {
ToastUtils.showShort("请先打开Awin WorkTool主功能~")
}
}
6 -> {
if (PermissionHelper.isAccessibilitySettingOn()) {
// 停止主功能时复位暂停状态,避免下次开启后主循环被卡住
isPause = false
WeworkController.weworkService.disableSelf()
} else {
ToastUtils.showShort("主功能已关闭~")
}
}
3 -> {
Utils.getApp().packageManager.getLaunchIntentForPackage(Constant.PACKAGE_NAMES)?.apply {
this.flags = Intent.FLAG_ACTIVITY_NEW_TASK
@@ -143,6 +153,7 @@ object FloatWindowHelper {
override fun onServiceDisconnected(name: ComponentName?) {
LogUtils.i("DefaultFloatService 服务断开")
bound = false
}
}

View File

@@ -112,8 +112,10 @@ object HttpUtil {
}
override fun onError(response: Response<String>) {
ToastUtils.showLong("获取配置失败 请检查机器人ID")
LogUtils.e("获取配置失败 请检查机器人ID")
if (toast) {
ToastUtils.showLong("获取配置失败请检查网络/Host/机器人ID")
}
LogUtils.e("获取配置失败: ${response.code()} ${response.exception?.message}")
}
})
}

View File

@@ -22,32 +22,37 @@ import java.util.concurrent.TimeUnit;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.WebSocket;
import okhttp3.WebSocketListener;
import okio.ByteString;
public class WebSocketManager {
public static final String HEARTBEAT = "{\"type\":" + WeworkMessageBean.HEART_BEAT + "}";
private static final ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor();
private static final OkHttpClient client = new OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.pingInterval(25, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.build();
public static Map<String, WebSocketManager> webSocketManager = new ConcurrentHashMap<>();
private static final int reconnectInt = 5000; //毫秒
private static final long heartBeatRate = 5; //秒
private Map<String, Long> messageIdMap = new ConcurrentHashMap<>();
private ScheduledFuture task;
private WebSocket socket;
private String url;
private WebSocketListener listener;
private boolean connecting = false;
private volatile boolean connecting = false;
private volatile boolean manuallyClosed = false;
private volatile boolean opened = false;
private long lastConnectedTime = 0L;
public WebSocketManager(String url, WebSocketListener listener) {
Log.e(url, "新建链接");
this.url = url;
this.listener = listener;
OkHttpClient client = new OkHttpClient();
Request request = new Request.Builder().url(url).build();
this.socket = client.newWebSocket(request, listener);
socket.send("{\"td\":" + System.currentTimeMillis() + "}");
this.socket = client.newWebSocket(new Request.Builder().url(url).build(), innerListener);
webSocketManager.put(url, this);
task = heartCheckStart();
}
@@ -83,7 +88,7 @@ public class WebSocketManager {
public void send(WeworkMessageListBean msg, boolean log) {
String json = GsonUtils.toJson(msg);
boolean success = socket.send(json);
boolean success = socket != null && socket.send(json);
if (log && success)
LogUtils.d(url, json, "通讯消息发送成功!");
if (!success)
@@ -91,14 +96,19 @@ public class WebSocketManager {
}
public void send(String msg) {
boolean success = socket.send(msg);
boolean success = socket != null && socket.send(msg);
LogUtils.e(url, msg, (success ? "通讯消息发送成功!" : "通讯消息发送失败!"));
}
public void close(int code, String reason) {
task.cancel(true);
manuallyClosed = true;
if (task != null) {
task.cancel(true);
}
Log.e("url", "task 取消");
this.socket.close(code, reason);
if (this.socket != null) {
this.socket.close(code, reason);
}
Log.e(url, "链接关闭");
}
@@ -112,44 +122,25 @@ public class WebSocketManager {
}
public void reConnect() {
if (manuallyClosed || connecting) {
return;
}
connecting = true;
opened = false;
Log.e(url, "重连");
boolean isConnect = false;
int interval = reconnectInt;
while (true) {
try {
isConnect = connect();
if (isConnect) {
connecting = false;
break;
}
} catch (Exception e) {
e.printStackTrace();
}
try {
Thread.sleep(interval);
if (interval < 600000) {
interval *= 2;
}
} catch (InterruptedException e) {
e.printStackTrace();
try {
if (socket != null) {
socket.cancel();
}
} catch (Exception ignore) {
}
}
private boolean connect() {
WebSocket s = new OkHttpClient().newWebSocket(new Request.Builder().url(url).build(), listener);
if (s.send(WebSocketManager.HEARTBEAT)) {
this.socket = s;
s.send("{\"td\":" + System.currentTimeMillis() + "}");
return true;
}
return false;
socket = client.newWebSocket(new Request.Builder().url(url).build(), innerListener);
}
private ScheduledFuture heartCheckStart() {
lastConnectedTime = System.currentTimeMillis();
Runnable r = () -> {
if (manuallyClosed) return;
SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");//设置日期格式
Log.d(url, "心跳检测" + df.format(new Date()));// new Date()为获取当前系统时间
if (!connecting && (socket == null || !socket.send(HEARTBEAT))) {
@@ -159,7 +150,7 @@ public class WebSocketManager {
reConnect();
//重连后刷新连接时间
lastConnectedTime = System.currentTimeMillis();
} else if (System.currentTimeMillis() % 1000 == 0) {
} else if (opened && System.currentTimeMillis() % 1000 == 0) {
socket.send("{\"td\":" + System.currentTimeMillis() + "}");
}
if (!Constant.INSTANCE.getEnableMediaProject()) {
@@ -176,4 +167,46 @@ public class WebSocketManager {
public static WebSocketManager getWebSocketManager(String id) {
return webSocketManager.get(id);
}
private final WebSocketListener innerListener = new WebSocketListener() {
@Override
public void onOpen(WebSocket webSocket, Response response) {
opened = true;
connecting = false;
lastConnectedTime = System.currentTimeMillis();
listener.onOpen(webSocket, response);
}
@Override
public void onMessage(WebSocket webSocket, String text) {
listener.onMessage(webSocket, text);
}
@Override
public void onMessage(WebSocket webSocket, ByteString bytes) {
listener.onMessage(webSocket, bytes);
}
@Override
public void onClosing(WebSocket webSocket, int code, String reason) {
opened = false;
connecting = false;
listener.onClosing(webSocket, code, reason);
}
@Override
public void onClosed(WebSocket webSocket, int code, String reason) {
opened = false;
connecting = false;
listener.onClosed(webSocket, code, reason);
}
@Override
public void onFailure(WebSocket webSocket, Throwable t, Response response) {
opened = false;
connecting = false;
WeworkController.INSTANCE.setEnableLoopRunning(false);
listener.onFailure(webSocket, t, response);
}
};
}

View File

@@ -329,7 +329,12 @@ object WeworkTextUtil {
* 是否为文件上方时间
*/
fun isFileSize(size: String?): Boolean {
return size?.matches("[0-9\\.]+[BKMG]".toRegex()) ?: false
if (size.isNullOrBlank()) return false
// 兼容常见文件大小格式: 12M / 12MB / 12.5 MB / 12 kb
val normalized = size.trim()
return normalized.matches(
"^[0-9]+(?:\\.[0-9]+)?[\\s\\u00A0]?(?:B|KB|MB|GB|TB|K|M|G)$".toRegex(RegexOption.IGNORE_CASE)
)
}
/**

View File

@@ -326,11 +326,14 @@
android:textColor="@color/color_333333"
android:textSize="@dimen/setting_start_font_size" />
<EditText
android:id="@+id/et_channel"
<TextView
android:id="@+id/tv_channel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="请输入申请的机器人ID"
android:paddingTop="4dp"
android:paddingBottom="4dp"
android:text="未生成"
android:textIsSelectable="true"
android:textColor="@color/color_999999"
android:textSize="@dimen/setting_end_font_size" />
@@ -344,17 +347,17 @@
android:orientation="vertical">
<Button
android:id="@+id/bt_save"
android:id="@+id/bt_reset_channel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:background="@drawable/comment_red_btn"
android:paddingStart="50dp"
android:paddingEnd="50dp"
android:textSize="18sp"
android:textStyle="bold"
android:paddingStart="42dp"
android:paddingEnd="42dp"
android:text="重置"
android:textColor="@color/white"
android:text="保存" />
android:textSize="18sp"
android:textStyle="bold" />
</LinearLayout>

View File

@@ -473,172 +473,6 @@
</RelativeLayout>
<RelativeLayout
android:id="@+id/rl_update"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="@dimen/setting_start_padding"
android:paddingTop="@dimen/setting_vertical_padding"
android:paddingEnd="@dimen/setting_end_padding"
android:paddingBottom="@dimen/setting_vertical_padding">
<ImageView
android:id="@+id/iv_rec_update_"
android:layout_width="@dimen/setting_start_image_width"
android:layout_height="@dimen/setting_start_image_width"
android:layout_centerVertical="true"
android:scaleX="1.1"
android:scaleY="1.1"
android:src="@drawable/settings_directory" />
<TextView
android:id="@+id/tv_select_update"
android:layout_width="@dimen/setting_end_font_width"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
android:layout_marginStart="@dimen/setting_end_start_padding"
android:textColor="@color/float_time_color"
android:textSize="@dimen/setting_end_font_size"
android:textStyle="bold" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_centerVertical="true"
android:layout_marginStart="@dimen/setting_start_padding"
android:layout_toStartOf="@id/tv_select_update"
android:layout_toEndOf="@id/iv_rec_update_"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="检查新版本"
android:textColor="@color/color_333333"
android:textSize="@dimen/setting_start_font_size" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="版本更新检查"
android:textColor="@color/color_999999"
android:textSize="@dimen/setting_end_font_size" />
</LinearLayout>
</RelativeLayout>
<RelativeLayout
android:id="@+id/rl_donate"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="@dimen/setting_start_padding"
android:paddingTop="@dimen/setting_vertical_padding"
android:paddingEnd="@dimen/setting_end_padding"
android:paddingBottom="@dimen/setting_vertical_padding">
<ImageView
android:id="@+id/iv_rec_donate_"
android:layout_width="@dimen/setting_start_image_width"
android:layout_height="@dimen/setting_start_image_width"
android:layout_centerVertical="true"
android:src="@drawable/settings_rate_us" />
<TextView
android:id="@+id/tv_select_donate"
android:layout_width="@dimen/setting_end_font_width"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
android:layout_marginStart="@dimen/setting_end_start_padding"
android:textColor="@color/float_time_color"
android:textSize="@dimen/setting_end_font_size"
android:textStyle="bold" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_centerVertical="true"
android:layout_marginStart="@dimen/setting_start_padding"
android:layout_toStartOf="@id/tv_select_donate"
android:layout_toEndOf="@id/iv_rec_donate_"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="赞助我们"
android:textColor="@color/color_333333"
android:textSize="@dimen/setting_start_font_size" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="还有机会成为我们的内测用户体验新功能"
android:textColor="@color/color_999999"
android:textSize="@dimen/setting_end_font_size" />
</LinearLayout>
</RelativeLayout>
<RelativeLayout
android:id="@+id/rl_share"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="@dimen/setting_start_padding"
android:paddingTop="@dimen/setting_vertical_padding"
android:paddingEnd="@dimen/setting_end_padding"
android:paddingBottom="@dimen/setting_vertical_padding">
<ImageView
android:id="@+id/iv_rec_share_"
android:layout_width="@dimen/setting_start_image_width"
android:layout_height="@dimen/setting_start_image_width"
android:layout_centerVertical="true"
android:scaleX="1.1"
android:scaleY="1.1"
android:src="@drawable/settings_share" />
<TextView
android:id="@+id/tv_select_share"
android:layout_width="@dimen/setting_end_font_width"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
android:layout_marginStart="@dimen/setting_end_start_padding"
android:textColor="@color/float_time_color"
android:textSize="@dimen/setting_end_font_size"
android:textStyle="bold" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_centerVertical="true"
android:layout_marginStart="@dimen/setting_start_padding"
android:layout_toStartOf="@id/tv_select_share"
android:layout_toEndOf="@id/iv_rec_share_"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="分享应用"
android:textColor="@color/color_333333"
android:textSize="@dimen/setting_start_font_size" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="把本应用分享给其他人"
android:textColor="@color/color_999999"
android:textSize="@dimen/setting_end_font_size" />
</LinearLayout>
</RelativeLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"

View File

@@ -317,6 +317,53 @@
</LinearLayout>
</RelativeLayout>
<RelativeLayout
android:id="@+id/rl_host"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="@dimen/setting_start_padding"
android:paddingTop="@dimen/setting_vertical_padding"
android:paddingEnd="@dimen/setting_end_padding"
android:paddingBottom="@dimen/setting_vertical_padding">
<TextView
android:id="@+id/tv_select_host"
android:layout_width="@dimen/setting_end_font_width"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
android:layout_marginStart="@dimen/setting_end_start_padding"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/float_time_color"
android:textSize="@dimen/setting_end_font_size"
android:textStyle="bold" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_centerVertical="true"
android:layout_marginStart="@dimen/setting_start_padding"
android:layout_toStartOf="@id/tv_select_host"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="服务器地址"
android:textColor="@color/color_333333"
android:textSize="@dimen/setting_start_font_size" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="WebSocket服务器地址(ws://或wss://开头)"
android:textColor="@color/color_999999"
android:textSize="@dimen/setting_end_font_size" />
</LinearLayout>
</RelativeLayout>
<RelativeLayout
android:id="@+id/rl_rec_orientation"
android:layout_width="match_parent"

View File

@@ -56,7 +56,14 @@ class DefaultFloatService : BaseFloatWindow(), View.OnClickListener {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
LogUtils.d(TAG, "onStartCommand: ${intent?.data}")
show()
when (intent?.getStringExtra(FloatWindowManager.EXTRA_ACTION)) {
FloatWindowManager.ACTION_HIDE -> {
hide()
stopForeground(true)
stopSelf()
}
else -> show()
}
return super.onStartCommand(intent, flags, startId)
}
@@ -81,9 +88,6 @@ class DefaultFloatService : BaseFloatWindow(), View.OnClickListener {
if (v == leftView.iv_logo_left || v == rightView.iv_logo_right || v == leftView.iv_logo_left2 || v == rightView.iv_logo_right2) {
onClickListener?.onClick(v, 0)
}
if (v == leftView.iv_start_left || v == rightView.iv_start_right) {
onClickListener?.onClick(v,1)
}
if (v == leftView.iv_shot_left || v == rightView.iv_shot_right) {
onClickListener?.onClick(v,2)
}
@@ -105,7 +109,6 @@ class DefaultFloatService : BaseFloatWindow(), View.OnClickListener {
leftView = inflater.inflate(R.layout.layout_menu_left, null)
leftView.iv_logo_left.setOnClickListener(this)
leftView.iv_logo_left2.setOnClickListener(this)
leftView.iv_start_left.setOnClickListener(this)
leftView.iv_shot_left.setOnClickListener(this)
leftView.iv_back_left.setOnClickListener(this)
leftView.iv_resume_pause_left.setOnClickListener(this)
@@ -119,7 +122,6 @@ class DefaultFloatService : BaseFloatWindow(), View.OnClickListener {
rightView = inflater.inflate(R.layout.layout_menu_right, null)
rightView.iv_logo_right.setOnClickListener(this)
rightView.iv_logo_right2.setOnClickListener(this)
rightView.iv_start_right.setOnClickListener(this)
rightView.iv_shot_right.setOnClickListener(this)
rightView.iv_back_right.setOnClickListener(this)
rightView.iv_resume_pause_right.setOnClickListener(this)
@@ -197,4 +199,10 @@ class DefaultFloatService : BaseFloatWindow(), View.OnClickListener {
}
override fun onDestroyed() {}
override fun onDestroy() {
hide()
stopForeground(true)
super.onDestroy()
}
}

View File

@@ -11,9 +11,13 @@ object FloatWindowManager {
private val TAG = FloatWindowManager::class.java.simpleName
private var context = Utils.getApp()
const val EXTRA_ACTION = "float_action"
const val ACTION_SHOW = "show"
const val ACTION_HIDE = "hide"
fun show(service: Class<out BaseFloatWindow>, intent: Intent? = null) {
startServiceSafe(Intent(context, service).apply {
putExtra(EXTRA_ACTION, ACTION_SHOW)
if (intent != null) {
this.putExtras(intent)
}
@@ -22,6 +26,7 @@ object FloatWindowManager {
fun hide(service: Class<out BaseFloatWindow>, intent: Intent? = null) {
startServiceSafe(Intent(context, service).apply {
putExtra(EXTRA_ACTION, ACTION_HIDE)
if (intent != null) {
this.putExtras(intent)
}

View File

@@ -16,19 +16,12 @@
android:visibility="visible">
<ImageView
android:id="@+id/iv_start_left"
android:layout_width="@dimen/float_size"
android:layout_height="@dimen/float_size"
android:layout_marginEnd="@dimen/float_margin_start"
android:src="@drawable/float_icon_record" />
<ImageView
android:id="@+id/iv_shot_left"
android:layout_width="@dimen/float_size"
android:layout_height="@dimen/float_size"
android:layout_marginStart="@dimen/float_margin_start"
android:layout_marginTop="29dp"
android:layout_marginTop="0dp"
android:src="@drawable/float_icon_pause" />
<ImageView
@@ -44,7 +37,7 @@
android:id="@+id/iv_logo_left"
android:layout_width="50dp"
android:layout_height="50dp"
android:layout_below="@id/iv_start_left"
android:layout_below="@id/iv_shot_left"
android:layout_marginStart="5dp"
android:layout_marginTop="10dp"
android:layout_marginEnd="55dp"

View File

@@ -15,18 +15,11 @@
android:layout_height="190dp"
android:visibility="visible">
<ImageView
android:id="@+id/iv_start_right"
android:layout_width="@dimen/float_size"
android:layout_height="@dimen/float_size"
android:layout_marginStart="@dimen/float_margin_start"
android:src="@drawable/float_icon_record" />
<ImageView
android:id="@+id/iv_shot_right"
android:layout_width="@dimen/float_size"
android:layout_height="@dimen/float_size"
android:layout_marginTop="29dp"
android:layout_marginTop="0dp"
android:src="@drawable/float_icon_pause" />
<ImageView
@@ -41,7 +34,7 @@
android:id="@+id/iv_logo_right"
android:layout_width="50dp"
android:layout_height="50dp"
android:layout_below="@id/iv_start_right"
android:layout_below="@id/iv_shot_right"
android:layout_marginStart="55dp"
android:layout_marginTop="10dp"
android:src="@mipmap/ic_launcher_round" />

421
交接文档.md Normal file
View File

@@ -0,0 +1,421 @@
# WorkTool 项目交接文档
本文档面向后续接手的开发人员,帮助快速理解 WorkTool 的整体架构、运行机制以及关键代码位置,便于后续维护与二次开发。
---
## 1. 项目概述
- 项目名称WorkTool
- 主要功能:依附于企业微信 / 微信运行的无人值守群管理机器人,通过无障碍服务和企业微信官方 SDK 实现自动收发消息、建群、拉人、踢人等能力。
- 客户端形态Android 原生 APP需运行在一台专用手机上通过 WebSocket 与后端任务调度平台长连接通信。
- 法规与安全:基于 Android 官方无障碍服务和企业微信官方 SDK无 Hook / 无 Root / 无内存修改,注意遵守腾讯运营规范与相关法律法规。
更多面向使用方的说明及 API 文档,可参考:
- 项目 README[README.md](file:///Users/tan/Documents/project/lzwcai-terminal-worktool/README.md)
- 后端协议文档:[BACKEND_PROTOCOL.md](file:///Users/tan/Documents/project/lzwcai-terminal-worktool/BACKEND_PROTOCOL.md)
---
## 2. 技术栈与运行环境
- 平台Android
- 语言Kotlin + Java 混合
- 构建工具Gradle使用仓库自带 `gradlew`
- 最低支持版本:`minSdkVersion 24`
- 目标版本:`targetSdkVersion 30`
- 编译版本:`compileSdkVersion 30`
- Java 版本:`sourceCompatibility 1.8` / `targetCompatibility 1.8`
- IDE 建议Android StudioArctic Fox 及以上版本均可正常打开)
涉及的关键第三方依赖(不完整列举):
- 工具库:`com.blankj:utilcodex`(通过 `com.blankj.utilcode.util.*` 等类使用)
- 日志:`LogUtils`(见 [LogUtilsInit.kt](file:///Users/tan/Documents/project/lzwcai-terminal-worktool/app/src/main/java/org/yameida/worktool/utils/LogUtilsInit.kt)
- Toast`com.hjq:toast``ToastUtils`
- UI 组件:`Material Components``MaterialAlertDialogBuilder`)、`QMUI` 对话框
- 网络通信:`OkHttp` WebSocket见 [WebSocketManager.java](file:///Users/tan/Documents/project/lzwcai-terminal-worktool/app/src/main/java/org/yameida/worktool/utils/WebSocketManager.java)
- 埋点统计:友盟 (`UMConfigure`)TalkingData (`TalkingDataSDK`)
- 企业微信 SDK`lib_wwapi-*.aar`(位于 `app/libs/`
---
## 3. 工程结构总览
根目录主要文件与模块:
- `app/`:主 APP 工程业务逻辑、UI、无障碍自动化、与后端通信等全部在此
- `baselibrary/`:基础 UI / Adapter 等通用组件
- `floatwindow/`:悬浮窗相关实现
- `BACKEND_PROTOCOL.md`:客户端与后台 WebSocket 协议说明
- `README.md`:面向使用方的简要说明与版本更新记录
多模块声明见:
- [settings.gradle](file:///Users/tan/Documents/project/lzwcai-terminal-worktool/settings.gradle)
- [app/build.gradle](file:///Users/tan/Documents/project/lzwcai-terminal-worktool/app/build.gradle)
### 3.1 app 模块结构
路径:`app/src/main/java/org/yameida/worktool/`
主要包与职责:
- `activity/`
- `ListenActivity`主控制面板配置链接号robotId、后端 host、开关无障碍与悬浮窗是日常运维主要入口。
- `LoginActivity`:登录相关界面。
- `SettingsActivity` / `SettingsAdvanceActivity`:基础及高级设置。
- `AccessibilityGuideActivity` / `FloatViewGuideActivity`:权限引导页面。
- `BrowserActivity``GetScreenShotActivity`:辅助操作页面。
- `service/`
- `WeworkService`:无障碍服务实现,负责监控和操作企业微信界面,是自动化的核心。
- `WeworkController`:统一调度控制,无障碍操作入口,管理循环任务状态。
- `WeworkLoopImpl` / `WeworkOperationImpl` / `WeworkInteractionImpl` / `WeworkGetImpl`:对企业微信不同操作的具体实现(循环拉取任务、执行发送消息、拉群、获取信息等)。
- `PlayNotifyService` / `PlayNotifyManager`:前台服务与通知控制,保障机器人在前台或长时间运行。
- `GlobalMethod.kt` / `MyLooper.kt`:封装通用操作和循环执行器。
- `utils/`
- 网络相关:`HttpUtil.kt`REST 接口访问)、`OkHttpUtil.kt``WebSocketManager.java`
- 无障碍与权限:`AccessibilityUtil.kt``AccessibilityExtraUtil.kt``PermissionHelper.kt``FlowPermissionHelper.kt``PermissionPageManagement.java`
- 悬浮窗:`FloatWindowHelper.kt`,配合 `floatwindow` 模块实现全局悬浮按钮。
- 屏幕与截图:`capture/` 包下的 `ScreenCaptureUtil``ScreenCaptureUtilByMediaPro``MediaProjectionHolder.kt` 等。
- 配置与缓存:`CacheUtil.kt``PropUtil.kt``HostTestHelper.kt`
- 其他工具:`RegexHelper.kt``RuntimeUtil.kt``WeworkRoomUtil.kt``WeworkTextUtil.kt` 等。
- `model/`
- `network/`:与后端通信的返回体,例如 `CheckUpdateResult.java``GetMyConfigResult.java`
- `operation/`:执行结果封装,如 `SelectResult.kt`
- `WeworkMessageBean.java` / `WeworkMessageListBean.kt`WebSocket 消息封装,与 `BACKEND_PROTOCOL.md` 一一对应。
- `AppUpdate.java``MyConfigBean.kt` 等:应用更新 / 机器人配置相关模型。
- `notification/`:通知栏相关封装(`PlayNotifyManager.kt`)。
- `config/`:全局异常处理(`GlobalException.java`)。
- `observer/`:文件监听等辅助(`MultiFileObserver.java`)。
- 顶层文件:
- `MyApplication.kt`:全局 Application完成 SDK 初始化、前台服务启动等。
- `Constant.kt`:全局常量与配置(可持久化到 `SPUtils`)。
- `Demo.kt`:示例或测试代码(如有使用可再确认)。
### 3.2 baselibrary 模块
路径:`baselibrary/`
- 主要保存通用 UI 组件与 Adapter例如 `RvSimpleAdapter``RvViewHolder`
- 资源文件中包含通用 `styles``strings` 等。
- 项目中作为 `app` 的依赖模块,无业务逻辑。
### 3.3 floatwindow 模块
路径:`floatwindow/`
- 浮窗管理:`FloatWindowManager.kt``BaseFloatWindow.java``DefaultFloatService.kt`
- 监听器:`listener/` 包下 `FloatWindowListener.kt``OnClickListener.kt` 等。
- 视图:`view/HiderView.java` 与相关布局、图片资源。
-`app` 提供悬浮窗入口、暂停 / 启动机器人等操作。
---
## 4. 运行流程与关键逻辑
### 4.1 App 启动流程
入口类:
- `AndroidManifest.xml` 中配置 `MyApplication` 作为 `application`,主 Activity 为登录或 Listen 页(视具体配置而定)。
- [MyApplication.kt](file:///Users/tan/Documents/project/lzwcai-terminal-worktool/app/src/main/java/org/yameida/worktool/MyApplication.kt) 负责:
- 初始化 `Utils`、日志配置(`LogUtilsInit`)、`Gson` 代理。
- 初始化 `ToastUtils`
- 初始化友盟埋点(`UMConfigure`),通过 `SPUtils``uminit` 标记隐私协议同意状态。
- 初始化 TalkingData`TalkingDataSDK.init(...)``Constant.robotId` 作为渠道维度之一。
- 初始化企业微信 SDK`IWWAPIUtil.init(this)`)。
- 初始化应用更新框架(`UpdateAppUtils.init(this)`)。
- 启动前台通知(`PlayNotifyManager.show()`)。
- 设置全局异常捕获(`GlobalException`),异常时自动重启。
### 4.2 ListenActivity 配置流程
核心界面:[ListenActivity.kt](file:///Users/tan/Documents/project/lzwcai-terminal-worktool/app/src/main/java/org/yameida/worktool/activity/ListenActivity.kt)。
主要职责:
- 展示并编辑链接号(`robotId`
- 文本框 `et_channel` 显示当前 `Constant.robotId`
- 点击“保存”按钮:
- 将输入写入 `Constant.robotId`(内部通过 `SPUtils` 持久化)。
- 发送广播通知 `Constant.WEWORK_NOTIFY`,类型为 `modify_channel`
- 调用 `HttpUtil.getMyConfig(toast = false)` 拉取机器人配置。
- 将链接号上报给友盟 `MobclickAgent.onProfileSignIn(channel)`
- 配置 WebSocket host
- 文本 `tv_host` 显示 `Constant.host`
- 点击:弹出历史 host 列表(来自 `SPUtils``host_list`),支持一键切换并调用 `HostTestHelper.testWs()` 进行连通性检测。
- 长按:弹出编辑对话框,支持新增 / 删除 host新增时校验是否符合 `ws://``wss://` 开头格式。
- 权限开关:
- 无障碍开关 `sw_accessibility`
- 校验是否填写链接号。
- 校验是否已开启系统无障碍;如未开启,跳转到引导页 `AccessibilityGuideActivity` 或系统设置。
- 如检测到 Hook / Root 环境,进行风险提示并控制运行。
- 关闭时调用 `WeworkController.weworkService.disableSelf()`(在部分 ROM 上会转跳到系统设置)。
- 悬浮窗开关 `sw_overlay`
- 开启时跳转 `FloatViewGuideActivity` 引导授权。
- 已授权时显示悬浮窗(`FloatWindowHelper.showWindow()`)或引导前往权限设置页。
- 环境检测:
- 设备信息、Root 状态、Hook 状态写入 `SPUtils`,用于后续分析。
- 检查企业微信当前版本是否在 `Constant.AVAILABLE_VERSION` 列表内,给出“已适配 / 可能存在兼容性问题”等提示。
- 初始化数据:
-`initData` 中设置默认 `Constant.robotId``Constant.host`(可以视需要调整或移除默认值)。
- 调用 `HttpUtil.getMyConfig(toast = false)` 拉取机器人配置。
- 调用 `CacheUtil.autoDelete()` 清理旧缓存。
### 4.3 与后端的通信
WebSocket 链接:
- WebSocket 地址统一由 `Constant.getWsUrl()` 生成,格式为:`<host>/webserver/wework/<robotId>`
- 默认 host 在 `Constant.DEFAULT_HOST` 中配置:`ws://8.166.130.74:18680`
- host 与 robotId 最终都持久化在 `SPUtils` 中,方便修改。
- 具体 WebSocket 管理由 [WebSocketManager.java](file:///Users/tan/Documents/project/lzwcai-terminal-worktool/app/src/main/java/org/yameida/worktool/utils/WebSocketManager.java) 实现:
- 维护 WebSocket 连接、心跳(每 5 秒一次)、自动重连。
- 发送和确认消息(`WeworkMessageListBean` 封装),写入日志。
- 断线时关闭新消息接收(`WeworkController.setEnableLoopRunning(false)`),重连成功后再恢复。
- 当长时间连接正常且未开启截屏模式时,如果检测到长时间未重连且仍在运行,会通过 Toast 提示“机器人运行中 请勿人工操作手机~”。
协议格式:
- 完整协议说明见 [BACKEND_PROTOCOL.md](file:///Users/tan/Documents/project/lzwcai-terminal-worktool/BACKEND_PROTOCOL.md)
- 连接 URL 格式、心跳机制。
- `WeworkMessageListBean``WeworkMessageBean` 的字段含义。
- 常用指令类型(如 `203` 发送消息、`218` 推送文件、`505` 获取最近聊天列表、`101` 接收消息列表等)。
- 执行结果回调、错误码定义。
- 代码中 `type``socketType` 等字段与文档一一对应,建议修改协议前同时修改此文档与对应模型类。
### 4.4 自动化执行流程(企业微信侧)
高层流程(简化):
1. 后端下发任务(通过 WebSocket
2. 客户端解析为 `WeworkMessageBean` / `WeworkMessageListBean`
3. `WeworkController` 调度对应的 `Wework*Impl` 实现执行操作:
- 打开企业微信指定页面。
- 定位目标群 / 联系人。
- 根据控件树执行点击、输入文本、长按、转发等操作。
4. 执行完成后,将结果封装回 WebSocket 消息上报服务端,包含成功 / 失败列表、错误原因等。
关键依赖:
- 无障碍服务:`WeworkService` + `AccessibilityUtil` / `AccessibilityExtraUtil`
- 文本匹配与房间识别:`RegexHelper``WeworkRoomUtil``WeworkTextUtil`
- 循环任务:`MyLooper``WeworkLoopImpl`
---
## 5. 配置项与敏感信息
### 5.1 host 与 robotId
相关代码:
- [Constant.kt](file:///Users/tan/Documents/project/lzwcai-terminal-worktool/app/src/main/java/org/yameida/worktool/Constant.kt)
- [ListenActivity.kt](file:///Users/tan/Documents/project/lzwcai-terminal-worktool/app/src/main/java/org/yameida/worktool/activity/ListenActivity.kt)
说明:
- `Constant.host`
- 默认值:`DEFAULT_HOST = "ws://8.166.130.74:18680"`
- 通过 `SPUtils` 持久化,键为 `"host"`
- 可在 ListenActivity 中通过 host 列表 / 编辑弹窗进行修改。
- 业务上可视为“后端 WebSocket 网关地址”,如需要更换环境(测试 / 生产)时重点关注。
- `Constant.robotId`
- 通过 ListenActivity 的输入框配置。
- 底层持久化键为 `"robotId"`,同时兼容历史 `"LISTEN_CHANNEL_ID"`
- 后端一般将其视为“链接号 / 机器人唯一 ID”用于区分不同设备 / 账号。
注意事项:
- 在更改默认 host 或引导用户填写 robotId 时,需要与后端保持一致(确保后台任务调度平台识别该 robotId
- 如需要支持多环境(测试 / 预发 / 生产),可以在现有 host 列表机制上扩展 UI 与配置管理。
### 5.2 企业微信兼容版本
相关代码:
- [Constant.kt](file:///Users/tan/Documents/project/lzwcai-terminal-worktool/app/src/main/java/org/yameida/worktool/Constant.kt) 中 `AVAILABLE_VERSION``AVAILABLE_VERSION_MAP`
- ListenActivity 中通过 `AppUtils.getAppInfo(Constant.PACKAGE_NAMES)?.versionName` 获取企业微信版本。
说明:
- `AVAILABLE_VERSION` 列出了当前已适配的企业微信版本号(例如 `4.1.8``4.1.9``4.1.10` 等)。
- `AVAILABLE_VERSION_MAP` 用于将版本号映射为内部的整数版本,用于兼容性判断。
- 当检测到企业微信版本不在列表内时,会提示“可能存在部分兼容性问题”,但仍允许使用。
后续维护建议:
- 当企业微信版本升级后,需要:
- 手动测试各项自动化功能。
- 若确认兼容,将新版本号加入 `AVAILABLE_VERSION``AVAILABLE_VERSION_MAP`
- 如需要针对特定版本调整逻辑,可结合 `Constant.version` 做分支处理。
### 5.3 统计与第三方 SDK key
相关代码:
- [MyApplication.kt](file:///Users/tan/Documents/project/lzwcai-terminal-worktool/app/src/main/java/org/yameida/worktool/MyApplication.kt)
说明:
- 友盟统计:
- `val key = "6284a3a3d024421570f97c3c"`
- 通过 `UMConfigure.preInit` / `UMConfigure.init` 初始化。
- 隐私协议同意状态通过 `SPUtils``"uminit"` 字段控制。
- TalkingData
- `TalkingDataSDK.init(this, "80E9C84E39904DAFB28562910FF7C86C", getString(R.string.app_name) + "_master", Constant.robotId)`
- 依赖 `Constant.robotId` 作为渠道维度。
接手后如需更换统计账号或关闭统计:
- 调整或移除相应初始化代码。
- 同时评估对现有数据分析的影响,必要时与产品 / 运维同步。
---
## 6. 构建、打包与发布流程
### 6.1 本地开发环境搭建
1. 克隆仓库到本地:
- 建议完整克隆含子模块(当前项目无 Git 子模块,仅多 module
2. 使用 Android Studio 打开仓库根目录。
3. 确保本地已安装:
- JDK 8或 Android Studio 自带 JDK
- Android SDK 30 及对应 Build Tools。
4. 首次打开时,按 IDE 提示同步 Gradle等待依赖下载完成。
### 6.2 常用构建命令
在项目根目录下,可以使用仓库自带 Gradle Wrapper
- 编译 Debug 包:
```bash
./gradlew assembleDebug
```
- 编译 Release 包:
```bash
./gradlew assembleRelease
```
注意:
- `app/build.gradle` 中 `release` 构建类型启用了混淆:
- `minifyEnabled true`
- 混淆配置文件:`proguard-android-optimize.txt`(默认)与 `app/proguard-rules.pro`
- 签名配置可能未在仓库中直接提供(如使用本地 `keystore`),如需正式发布包,请向原维护人员或运维团队索取签名文件与配置。
### 6.3 打包注意事项
- 由于集成了企业微信 SDK`libs/lib_wwapi-*.aar`)及多个三方 SDK混淆配置需要保持当前状态避免将关键类混淆导致运行崩溃。
- 如新增依赖,请同步更新 `proguard-rules.pro`。
- Release 包上线前建议至少在以下维度自测:
- 不同 Android 版本(特别是 API 24~30
- 不同厂商 ROM华为、小米、OPPO、VIVO 等)的无障碍与后台保活行为。
- 至少一种已适配的企业微信版本。
---
## 7. 日常调试与常见问题
### 7.1 日志查看
- 项目统一使用 `LogUtils` 输出日志,初始化在 `LogUtilsInit` 中。
- 日志输出位置、格式可在 [LogUtilsInit.kt](file:///Users/tan/Documents/project/lzwcai-terminal-worktool/app/src/main/java/org/yameida/worktool/utils/LogUtilsInit.kt) 中调整。
- 联调时建议:
- 在关键流程WebSocket 收发、无障碍执行)增加必要的日志。
- 结合后端日志与手机本地日志定位问题。
### 7.2 无障碍与权限问题
常见现象:
- 无法自动跳转 / 点击:
- 检查系统无障碍设置中是否已为 WorkTool 授权。
- 检查是否被 ROM 的“电池优化 / 后台保护”策略限制。
- 悬浮窗不显示:
- 检查“悬浮窗权限”是否开启。
- 检查 `FloatWindowHelper.showWindow()` 是否被调用。
相关工具类:
- `PermissionHelper`:无障碍权限判断与相关操作。
- `FlowPermissionHelper`:后台启动、悬浮窗等权限判断。
- `PermissionPageManagement`:跳转至系统设置页面。
### 7.3 WebSocket 连接异常
排查步骤:
1. 在 ListenActivity 中确认 `host` 与 `robotId` 配置正确。
2. 使用 `HostTestHelper.testWs()`ListenActivity 操作 host 时自动调用)测试连通性。
3. 查看 `WebSocketManager` 日志,确认是否存在频繁断线重连。
4. 确认服务端配置与 `BACKEND_PROTOCOL.md` 版本一致。
进一步建议(针对日志中偶发 `Connection closed` 场景):
- 现象判定:
- 若出现“断开后 1~2 秒内自动重连成功,且同一 clientId 可继续收发消息”,通常是短时网络抖动或网关空闲回收,不一定是严重故障。
- 若出现“周期性断开(如每几分钟一次)”或“重连失败持续告警”,优先排查配置问题。
- 归因方法(客户端 / 服务端 / 网关):
- 客户端Android补充日志在 `WeworkService.EchoWebSocketListener` 的 `onClosing/onClosed/onFailure` 打印 `code`、`reason`、`throwable`。
- 服务端补充日志:在 WebSocket Handler 的关闭与异常回调中打印 `clientId`、`sessionId`、关闭码、异常栈。
- 网关层补充日志Nginx / LB 记录 upstream 连接关闭原因与空闲超时触发记录。
- 通过同一时间窗口对齐三端日志,判断“谁先发起关闭、关闭码是什么、是否有网络异常”。
- 常见根因:
- 手机网络切换Wi-Fi/移动网络)或弱网导致 TCP 断开。
- 网关 / 反向代理空闲超时过短,主动关闭 WebSocket。
- 心跳机制仅业务层保活,未启用 WebSocket ping导致中间设备误回收长连接。
- Android 省电策略导致后台网络能力受限。
- 建议优化项(按优先级):
- 客户端统一复用 `OkHttpClient`,并开启 `pingInterval`(建议 20~30 秒)。
- 网关与服务端 idle timeout 应明显高于心跳周期(建议至少 30~60 秒)。
- 重连策略增加“指数退避 + 抖动”,避免批量设备同时重连。
- 在运维侧统计“每设备每小时断线次数、重连耗时、连续失败次数”,作为健康度指标。
相关代码入口:
- WebSocket 管理:`app/src/main/java/org/yameida/worktool/utils/WebSocketManager.java`
- 监听与回调:`app/src/main/java/org/yameida/worktool/service/WeworkService.kt`
---
## 8. 后续可扩展与注意事项
- 协议变更:
- 修改 WebSocket 协议或新增指令类型时,需同时更新:
- `WeworkMessageBean` / `WeworkMessageListBean` 数据结构。
- `Wework*Impl` 中对新指令的处理逻辑。
- 文档 [BACKEND_PROTOCOL.md](file:///Users/tan/Documents/project/lzwcai-terminal-worktool/BACKEND_PROTOCOL.md)。
- 新企业微信版本适配:
- 新版本上线后,需实机测试高频功能。
- 确认后更新 `Constant.AVAILABLE_VERSION` 及相关逻辑。
- 安全与合规:
- 严禁在代码中硬编码任何生产环境账号密码。
- 涉及数据采集、上传时注意隐私合规(特别是好友信息、聊天内容等)。
---
## 9. 交接建议
建议接手同学优先按以下顺序熟悉项目:
1. 阅读 README 与本交接文档,理解整体定位与功能。
2. 在测试环境跑通完整链路:
- 编译安装 APP。
- 配置测试后端 host 与 robotId。
- 打开无障碍与悬浮窗权限。
- 使用后台调度平台下发几种典型任务(发送文本、发送图片、获取最近聊天列表等)。
3. 深入阅读核心代码:
- `MyApplication`(启动流程)。
- `ListenActivity`(配置入口)。
- `WeworkService` + `WeworkController` + `Wework*Impl`(自动化执行)。
- `WebSocketManager` + 协议模型类(通讯层)。
4. 根据实际需要再进一步梳理业务细节与扩展点。
如后续对具体模块或逻辑有疑问,可在对应类文件中逐步补充更细的内部文档或注释,并与团队同步更新本交接文档。