diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts
index db60fd60..da59ebc3 100644
--- a/androidApp/build.gradle.kts
+++ b/androidApp/build.gradle.kts
@@ -67,8 +67,8 @@ android {
applicationId = "com.smjcco.wxpusher"
minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt()
- versionCode = 10811
- versionName = "1.8.11"
+ versionCode = 10820
+ versionName = "1.8.20"
//指定产物名称
setProperty("archivesBaseName", "wxpusher-app-v$versionName")
diff --git a/androidApp/src/androidMain/AndroidManifest.xml b/androidApp/src/androidMain/AndroidManifest.xml
index 365cf8f6..58dec756 100644
--- a/androidApp/src/androidMain/AndroidManifest.xml
+++ b/androidApp/src/androidMain/AndroidManifest.xml
@@ -121,6 +121,21 @@
android:launchMode="singleTask"
android:screenOrientation="portrait" />
+
+
+
+
+
+
@@ -275,4 +290,4 @@
-
\ No newline at end of file
+
diff --git a/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/app/WxPusherApplication.kt b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/app/WxPusherApplication.kt
index 743f8652..5ef396ac 100644
--- a/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/app/WxPusherApplication.kt
+++ b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/app/WxPusherApplication.kt
@@ -42,14 +42,12 @@ class WxPusherApplication : Application() {
WxpAppDataService.init();
//初始化设备基础信息
WxpBaseInfoService.init(WxpBaseInfoServiceImpl())
+ // 先加载本地降级配置,再决定推送通道;云端刷新后通知协调器热切换。
+ ConfigManager.init(this)
PushManager.init(this)
- //上报一次绑定关系,主要是为了更新设备活跃时间
- WxpAppDataService.updateDeviceInfo()
initTbs()
//注册版本升级市场跳转能力(已适配厂商优先,其他走 TBS)
WxpVersionCheckManager.setNavigator(AppMarketNavigator)
- //拉取一个简单的配置
- ConfigManager.init(this)
// 启动时执行一次 app_fe 版本刷新(内部有 1 小时间隔,失败无影响)
AppFeVersionManager.refreshOnAppLaunch()
//初始化微信SDK
@@ -87,4 +85,4 @@ class WxPusherApplication : Application() {
.build()
UpgradeManager.getInstance().init(this, config)
}
-}
\ No newline at end of file
+}
diff --git a/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/app/WxpBaseInfoServiceImpl.kt b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/app/WxpBaseInfoServiceImpl.kt
index 8cd20051..7f490adc 100644
--- a/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/app/WxpBaseInfoServiceImpl.kt
+++ b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/app/WxpBaseInfoServiceImpl.kt
@@ -1,10 +1,17 @@
package com.smjcco.wxpusher.app
+import com.smjcco.wxpusher.base.biz.bean.WxpPlatformEnum
import com.smjcco.wxpusher.base.common.IWxpBaseInfoServiceListener
-import com.smjcco.wxpusher.utils.DeviceUtils
+import com.smjcco.wxpusher.push.PushPlatformState
class WxpBaseInfoServiceImpl : IWxpBaseInfoServiceListener {
- override fun getPlatform(): String {
- return DeviceUtils.getPlatform().getPlatform()
+ /** 获取 Android 客户端平台,不包含当前推送通道信息。 */
+ override fun getClientPlatform(): String {
+ return WxpPlatformEnum.Android.platform
}
-}
\ No newline at end of file
+
+ /** 获取当前已经生效或首次注册阶段默认使用的后端推送路由平台。 */
+ override fun getEffectivePushPlatform(): String {
+ return PushPlatformState.getEffectivePushPlatform().getPlatform()
+ }
+}
diff --git a/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/bean/DevicePlatform.kt b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/bean/DevicePlatform.kt
index 8e2024dd..01cdce18 100644
--- a/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/bean/DevicePlatform.kt
+++ b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/bean/DevicePlatform.kt
@@ -25,4 +25,10 @@ enum class DevicePlatform(private val platform: String) {
Wecom("Wecom");
fun getPlatform() = platform
-}
\ No newline at end of file
+
+ companion object {
+ /** 根据后端保存的平台字符串恢复枚举,无法识别时返回空。 */
+ fun find(platform: String?): DevicePlatform? =
+ entries.firstOrNull { it.platform == platform }
+ }
+}
diff --git a/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/config/ConfigManager.kt b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/config/ConfigManager.kt
index 53dd6f05..23b9f5c9 100644
--- a/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/config/ConfigManager.kt
+++ b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/config/ConfigManager.kt
@@ -29,6 +29,9 @@ object ConfigManager {
// 应用上下文
private lateinit var appContext: Context
+ // 云端配置发生实际变化时,通知推送通道等运行时组件立即响应。
+ private val listeners = mutableSetOf<(ConfigItem) -> Unit>()
+
/**
* 初始化配置管理器
* @param context 应用上下文
@@ -122,6 +125,23 @@ object ConfigManager {
return currentConfig
}
+ /** 注册配置变化监听器。 */
+ fun addListener(listener: (ConfigItem) -> Unit) {
+ listeners.add(listener)
+ }
+
+ /** 移除配置变化监听器。 */
+ fun removeListener(listener: (ConfigItem) -> Unit) {
+ listeners.remove(listener)
+ }
+
+ private suspend fun notifyConfigChanged(config: ConfigItem) {
+ // 监听方可能更新页面或服务状态,因此统一在主线程回调。
+ withContext(Dispatchers.Main) {
+ listeners.toList().forEach { it(config) }
+ }
+ }
+
/**
* 强制从服务器刷新配置
* @param callback 刷新结果回调
@@ -138,7 +158,12 @@ object ConfigManager {
// 更新当前配置
val compatibleConfig = configResponse?.configs?.let { findCompatibleConfig(it) }
if (compatibleConfig != null) {
+ // 只有内容真正变化才触发热切换,避免每次刷新重复初始化通道。
+ val changed = compatibleConfig != currentConfig
currentConfig = compatibleConfig
+ if (changed) {
+ notifyConfigChanged(compatibleConfig)
+ }
} else {
WxpLogUtils.i(TAG, "没有可用配置")
}
@@ -158,4 +183,4 @@ object ConfigManager {
}
}
}
-}
\ No newline at end of file
+}
diff --git a/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/page/main/WxpMainActivity.kt b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/page/main/WxpMainActivity.kt
index 239fe33d..811b0184 100644
--- a/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/page/main/WxpMainActivity.kt
+++ b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/page/main/WxpMainActivity.kt
@@ -29,6 +29,7 @@ import com.smjcco.wxpusher.page.main.fragment.ProfileFragment
import com.smjcco.wxpusher.page.main.fragment.WxpExtFuncFragment
import com.smjcco.wxpusher.page.main.fragment.WxpProviderListFragment
import com.smjcco.wxpusher.push.PushManager
+import com.smjcco.wxpusher.push.ws.alert.WsAlertPlayer
import com.smjcco.wxpusher.push.ws.keepalive.KeepWsAliveServiceStarter
import com.smjcco.wxpusher.utils.PermissionRequester
import com.smjcco.wxpusher.utils.PermissionUtils
@@ -303,6 +304,8 @@ class WxpMainActivity : WxpBaseActivity(), CurrentTabProvider {
override fun onResume() {
super.onResume()
+ //用户已经看到消息了,WS 的持续提醒(最长 60 秒)就该停下来
+ WsAlertPlayer.stopAll()
PushManager.showOpenNoteRemindSettingDialog(this)
//显示首页的时候,尝试启动一次保活服务
KeepWsAliveServiceStarter.start(this)
diff --git a/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/page/main/fragment/MessageListFragment.kt b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/page/main/fragment/MessageListFragment.kt
index d3a255e3..2110efc7 100644
--- a/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/page/main/fragment/MessageListFragment.kt
+++ b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/page/main/fragment/MessageListFragment.kt
@@ -45,7 +45,9 @@ import com.smjcco.wxpusher.page.messagelist.WxpMessageListPresenter
import com.smjcco.wxpusher.page.messagelist.WxpMessageListReq
import com.smjcco.wxpusher.page.web.WxpWebViewActivity
import com.smjcco.wxpusher.push.IPushTokenChangedListener
+import com.smjcco.wxpusher.push.PushChannel
import com.smjcco.wxpusher.push.PushManager
+import com.smjcco.wxpusher.push.PushPlatformState
import com.smjcco.wxpusher.utils.DeviceUtils
import com.smjcco.wxpusher.utils.PermissionUtils
import com.smjcco.wxpusher.utils.WxpJumpPageUtils
@@ -224,7 +226,7 @@ class MessageListFragment : WxpBaseMvpFragment(), IWxp
*/
private fun refreshBanner() {
//如果是非厂商通道,并且没有忽略电池优化,就提醒用户关闭电池优化
- if (DeviceUtils.getPlatform() == DevicePlatform.Android) {
+ if (PushPlatformState.getEffectivePushChannel() == PushChannel.WEBSOCKET) {
if (!DeviceUtils.isIgnoringBatteryOptimizations()) {
batteryBanner.visibility = View.VISIBLE
bannerBtn.setOnClickListener {
@@ -389,10 +391,18 @@ class MessageListFragment : WxpBaseMvpFragment(), IWxp
notePermissionCloseImg.setImageDrawable(drawable)
}
notePermissionBanner.setOnClickListener {
- WxpJumpPageUtils.jumpToWebUrl(
- url = WxpConfig.appFeUrl + "/app/?code=${data.code}#/no-message",
- activity = activity
- )
+ // 厂商通道异常时直接引导用户手动切换,其他异常继续使用原排查页面。
+ if (data.code == 20002
+ && PushPlatformState.getEffectivePushChannel() == PushChannel.VENDOR
+ ) {
+ WxpToastUtils.showToast("当前推送通道异常,请尝试切换推送通道")
+ WxpJumpPageUtils.jumpToPushChannelSetting(activity)
+ } else {
+ WxpJumpPageUtils.jumpToWebUrl(
+ url = WxpConfig.appFeUrl + "/app/?code=${data.code}#/no-message",
+ activity = activity
+ )
+ }
}
}
diff --git a/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/page/main/fragment/ProfileFragment.kt b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/page/main/fragment/ProfileFragment.kt
index 7d7ba4f4..c82e68ee 100644
--- a/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/page/main/fragment/ProfileFragment.kt
+++ b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/page/main/fragment/ProfileFragment.kt
@@ -20,9 +20,9 @@ import com.smjcco.wxpusher.base.common.WxpBaseInfoService
import com.smjcco.wxpusher.base.common.WxpDialogParams
import com.smjcco.wxpusher.base.common.WxpDialogUtils
import com.smjcco.wxpusher.base.common.WxpToastUtils
-import com.smjcco.wxpusher.base.common.runAtMainSuspend
import com.smjcco.wxpusher.biz.version.WxpVersionCheckManager
import com.smjcco.wxpusher.common.WxpConstants
+import com.smjcco.wxpusher.push.PushChannelCoordinator
import com.smjcco.wxpusher.utils.PermissionUtils
import com.smjcco.wxpusher.utils.WxpJumpPageUtils
@@ -50,6 +50,14 @@ class ProfileFragment : WxpBaseFragment() {
setupData()
}
+ override fun onResume() {
+ super.onResume()
+ // 从通道设置页返回后刷新当前设备实际使用的通道名称。
+ if (::adapter.isInitialized) {
+ setupData()
+ }
+ }
+
private fun setupUI(view: View) {
recyclerView = view.findViewById(R.id.recycler_view)
recyclerView.layoutManager = LinearLayoutManager(requireContext())
@@ -65,7 +73,6 @@ class ProfileFragment : WxpBaseFragment() {
val loginInfo = WxpAppDataService.getLoginInfo()
val uid = loginInfo?.uid ?: ""
val spt = loginInfo?.spt ?: ""
- val deviceId = loginInfo?.deviceId ?: ""
if (!BuildConfig.online) {
sectionData.add(
@@ -112,13 +119,6 @@ class ProfileFragment : WxpBaseFragment() {
) {
copyToClipboard(spt, "SPT复制成功")
},
- ProfileItem(
- title = "设备ID",
- subtitle = deviceId,
- hasArrow = true
- ) {
- copyToClipboard(deviceId, "设备ID复制成功")
- },
ProfileItem(
title = "账号信息",
subtitle = "管理账号",
@@ -199,6 +199,14 @@ class ProfileFragment : WxpBaseFragment() {
ProfileSection(
title = "通知提醒",
items = listOf(
+ // 该入口只管理当前设备,不影响同一账号下的其他设备。
+ ProfileItem(
+ title = "推送通道和铃声",
+ subtitle = PushChannelCoordinator.getCurrentChannelName(),
+ hasArrow = true
+ ) {
+ WxpJumpPageUtils.jumpToPushChannelSetting(requireActivity())
+ },
ProfileItem(
title = "通知设置",
subtitle = "检查通知权限",
@@ -245,13 +253,6 @@ class ProfileFragment : WxpBaseFragment() {
) {
checkForUpdate()
},
- ProfileItem(
- title = "用户协议",
- subtitle = "查看用户和隐私协议",
- hasArrow = true
- ) {
- openUserAgreementUrl()
- },
ProfileItem(
title = "联系我们",
subtitle = "咨询和反馈问题",
@@ -261,21 +262,24 @@ class ProfileFragment : WxpBaseFragment() {
"${WxpConfig.appFeUrl}/app/#/contact",
activity
)
- },
- ProfileItem(
- title = "备案号",
- subtitle = "蜀ICP备14025423号-2A",
- hasArrow = true
- ) {
- openRecordUrl()
}
)
))
- adapter.setData(sectionData)
+ adapter.setData(sectionData, buildFooter())
adapter.notifyDataSetChanged()
}
+ /** 页面底部的协议与备案号小字,跟随列表一起滚动 */
+ private fun buildFooter(): ProfileFooter {
+ return ProfileFooter(
+ agreementText = "《用户和隐私协议》",
+ recordText = "蜀ICP备14025423号-2A",
+ onAgreementClick = { openUserAgreementUrl() },
+ onRecordClick = { openRecordUrl() }
+ )
+ }
+
private fun copyToClipboard(text: String, successMessage: String) {
val clipboard =
requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
@@ -349,6 +353,13 @@ class ProfileFragment : WxpBaseFragment() {
val action: (() -> Unit)? = null
)
+ data class ProfileFooter(
+ val agreementText: String,
+ val recordText: String,
+ val onAgreementClick: () -> Unit,
+ val onRecordClick: () -> Unit
+ )
+
// RecyclerView适配器
private class ProfileAdapter(
private val onItemClick: (ProfileItem) -> Unit
@@ -357,22 +368,25 @@ class ProfileFragment : WxpBaseFragment() {
companion object {
private const val TYPE_HEADER = 0
private const val TYPE_ITEM = 1
+ private const val TYPE_FOOTER = 2
}
private val items = mutableListOf()
- fun setData(sections: List) {
+ fun setData(sections: List, footer: ProfileFooter? = null) {
items.clear()
sections.forEach { section ->
items.add(section.title) // 添加section header
items.addAll(section.items) // 添加section items
}
+ footer?.let { items.add(it) } // 底部协议和备案号
}
override fun getItemViewType(position: Int): Int {
return when (items[position]) {
is String -> TYPE_HEADER
+ is ProfileFooter -> TYPE_FOOTER
is ProfileItem -> TYPE_ITEM
else -> TYPE_ITEM
}
@@ -386,6 +400,11 @@ class ProfileFragment : WxpBaseFragment() {
SectionHeaderViewHolder(view)
}
+ TYPE_FOOTER -> {
+ val view = inflater.inflate(R.layout.item_profile_footer, parent, false)
+ FooterViewHolder(view)
+ }
+
else -> {
val view = inflater.inflate(R.layout.item_profile, parent, false)
ItemViewHolder(view)
@@ -406,6 +425,10 @@ class ProfileFragment : WxpBaseFragment() {
val isLastInSection = isLastItemInSection(position)
holder.bind(item, onItemClick, isLastInSection)
}
+
+ is FooterViewHolder -> {
+ holder.bind(items[position] as ProfileFooter)
+ }
}
}
@@ -413,8 +436,8 @@ class ProfileFragment : WxpBaseFragment() {
// 如果是最后一个item,肯定是section的最后一个
if (position == items.size - 1) return true
- // 如果下一个item是String类型(section header),说明当前item是section的最后一个
- if (position + 1 < items.size && items[position + 1] is String) return true
+ // 如果下一个item不是普通item(section header 或底部小字),说明当前item是section的最后一个
+ if (position + 1 < items.size && items[position + 1] !is ProfileItem) return true
return false
}
@@ -430,6 +453,19 @@ class ProfileFragment : WxpBaseFragment() {
}
}
+ // Footer ViewHolder:底部协议和备案号
+ private class FooterViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+ private val agreementTextView: TextView = itemView.findViewById(R.id.tv_footer_agreement)
+ private val recordTextView: TextView = itemView.findViewById(R.id.tv_footer_record)
+
+ fun bind(footer: ProfileFooter) {
+ agreementTextView.text = footer.agreementText
+ agreementTextView.setOnClickListener { footer.onAgreementClick() }
+ recordTextView.text = footer.recordText
+ recordTextView.setOnClickListener { footer.onRecordClick() }
+ }
+ }
+
// Item ViewHolder
private class ItemViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val titleTextView: TextView = itemView.findViewById(R.id.tv_title)
diff --git a/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/page/pushchannel/PushChannelSettingActivity.kt b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/page/pushchannel/PushChannelSettingActivity.kt
new file mode 100644
index 00000000..178317c8
--- /dev/null
+++ b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/page/pushchannel/PushChannelSettingActivity.kt
@@ -0,0 +1,552 @@
+package com.smjcco.wxpusher.page.pushchannel
+
+import android.Manifest
+import android.app.AlertDialog
+import android.app.NotificationManager
+import android.content.Context
+import android.content.Intent
+import android.content.res.ColorStateList
+import android.os.Bundle
+import android.view.Menu
+import android.view.MenuItem
+import android.view.View
+import android.widget.ImageView
+import android.widget.RadioButton
+import android.widget.TextView
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.content.ContextCompat
+import com.google.android.material.card.MaterialCardView
+import com.smjcco.wxpusher.R
+import com.smjcco.wxpusher.WxpConfig
+import com.smjcco.wxpusher.base.WxpBaseActivity
+import com.smjcco.wxpusher.base.common.WxpSaveService
+import com.smjcco.wxpusher.base.common.WxpToastUtils
+import com.smjcco.wxpusher.bean.DevicePlatform
+import com.smjcco.wxpusher.dialog.DialogManager
+import com.smjcco.wxpusher.push.PushChannel
+import com.smjcco.wxpusher.push.PushChannelCoordinator
+import com.smjcco.wxpusher.push.PushChannelSnapshot
+import com.smjcco.wxpusher.push.PushPlatformResolver
+import com.smjcco.wxpusher.push.VendorAvailability
+import com.smjcco.wxpusher.push.ws.alert.WsAlertStore
+import com.smjcco.wxpusher.push.ws.connect.WsManager
+import com.smjcco.wxpusher.push.ws.keepalive.KeepWsAliveService
+import com.smjcco.wxpusher.push.ws.keepalive.KeepWsAliveServiceStarter
+import com.smjcco.wxpusher.utils.DeviceUtils
+import com.smjcco.wxpusher.utils.PermissionRequester
+import com.smjcco.wxpusher.utils.PermissionUtils
+import com.smjcco.wxpusher.utils.WxpJumpPageUtils
+
+/**
+ * 当前设备的推送通道设置页。
+ *
+ * 页面只负责展示状态和接收用户操作,实际切换、持久化与降级策略由
+ * [PushChannelCoordinator] 统一处理。
+ */
+class PushChannelSettingActivity : WxpBaseActivity() {
+ private lateinit var vendorCard: MaterialCardView
+ private lateinit var vendorTitle: TextView
+ private lateinit var vendorState: TextView
+ private lateinit var vendorRadio: RadioButton
+ private lateinit var retryVendor: TextView
+ private lateinit var vendorAlertSetting: View
+ private lateinit var vendorAlertSummary: TextView
+ private lateinit var wsCard: MaterialCardView
+ private lateinit var wsState: TextView
+ private lateinit var wsRadio: RadioButton
+ private lateinit var wsAlertSetting: View
+ private lateinit var wsAlertSummary: TextView
+ private lateinit var wsDependencySection: View
+ private lateinit var wsDependencySummary: TextView
+ private lateinit var foregroundNotificationCheck: DependencyCheckViews
+ private lateinit var autoStartCheck: DependencyCheckViews
+ private lateinit var backgroundCheck: DependencyCheckViews
+ private lateinit var foregroundNotificationRequester: PermissionRequester
+ private var lastErrorMessage: String? = null
+ private var latestSnapshot: PushChannelSnapshot? = null
+ private var wsDependencyActionsEnabled = false
+ private var waitingForAutoStartConfirmation = false
+
+ private data class DependencyCheckViews(
+ val row: View,
+ val icon: ImageView,
+ val status: TextView,
+ )
+
+ // 通道状态变化时刷新卡片选中、可用和错误状态。
+ private val channelListener: (PushChannelSnapshot) -> Unit = { render(it) }
+
+ // WS 连接状态独立变化,不能只依赖通道快照刷新。
+ private val wsConnectListener = object : WsManager.IWsConnectChangedListener {
+ override fun onChanged(connectStatus: WsManager.WsConnectStatus) {
+ renderWsState(connectStatus)
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_push_channel_setting)
+ supportActionBar?.setDisplayHomeAsUpEnabled(true)
+ title = "推送通道设置"
+ bindViews()
+ setupForegroundNotificationRequester()
+ bindActions()
+ }
+
+ override fun onStart() {
+ super.onStart()
+ PushChannelCoordinator.addListener(channelListener)
+ WsManager.addConnectChangedListener(wsConnectListener)
+ }
+
+ override fun onResume() {
+ super.onResume()
+ // 从提醒方式设置页返回后刷新摘要,同时维持“当前实际通道”决定入口可用性的规则。
+ val snapshot = latestSnapshot ?: PushChannelCoordinator.getSnapshot()
+ renderWsAlertSetting(snapshot)
+ renderWsDependencyChecks(snapshot)
+ if (waitingForAutoStartConfirmation) {
+ waitingForAutoStartConfirmation = false
+ showAutoStartConfirmationDialog()
+ }
+ }
+
+ override fun onStop() {
+ WsManager.removeConnectChangedListener(wsConnectListener)
+ PushChannelCoordinator.removeListener(channelListener)
+ super.onStop()
+ }
+
+ private fun bindViews() {
+ vendorCard = findViewById(R.id.card_vendor_push)
+ vendorTitle = findViewById(R.id.tv_vendor_title)
+ vendorState = findViewById(R.id.tv_vendor_state)
+ vendorRadio = findViewById(R.id.radio_vendor)
+ retryVendor = findViewById(R.id.btn_retry_vendor)
+ vendorAlertSetting = findViewById(R.id.layout_vendor_alert_setting)
+ vendorAlertSummary = findViewById(R.id.tv_vendor_alert_summary)
+ wsCard = findViewById(R.id.card_ws_push)
+ wsState = findViewById(R.id.tv_ws_state)
+ wsRadio = findViewById(R.id.radio_ws)
+ wsAlertSetting = findViewById(R.id.layout_ws_alert_setting)
+ wsAlertSummary = findViewById(R.id.tv_ws_alert_summary)
+ wsDependencySection = findViewById(R.id.layout_ws_dependency_section)
+ wsDependencySummary = findViewById(R.id.tv_ws_dependency_summary)
+ foregroundNotificationCheck = DependencyCheckViews(
+ row = findViewById(R.id.layout_ws_foreground_notification_check),
+ icon = findViewById(R.id.iv_ws_foreground_notification_status),
+ status = findViewById(R.id.tv_ws_foreground_notification_status),
+ )
+ autoStartCheck = DependencyCheckViews(
+ row = findViewById(R.id.layout_ws_auto_start_check),
+ icon = findViewById(R.id.iv_ws_auto_start_status),
+ status = findViewById(R.id.tv_ws_auto_start_status),
+ )
+ backgroundCheck = DependencyCheckViews(
+ row = findViewById(R.id.layout_ws_background_check),
+ icon = findViewById(R.id.iv_ws_background_status),
+ status = findViewById(R.id.tv_ws_background_status),
+ )
+ }
+
+ private fun setupForegroundNotificationRequester() {
+ foregroundNotificationRequester = PermissionRequester(
+ activity = this,
+ permission = Manifest.permission.POST_NOTIFICATIONS,
+ explainTitle = "需要保活前台通知权限",
+ explainMessage = "自建链接需要显示一条常驻通知,才能尽量保持后台连接。",
+ guideTitle = "开启保活前台通知权限",
+ guideMessage = "请在系统通知设置中允许 WxPusher 显示通知。",
+ gotoSetting = { WxpJumpPageUtils.jumpToSystemNotificationSettingPage(this) },
+ )
+ }
+
+ private fun bindActions() {
+ vendorCard.setOnClickListener { PushChannelCoordinator.selectVendor() }
+ wsCard.setOnClickListener {
+ PushChannelCoordinator.selectWebSocket()
+ }
+ retryVendor.setOnClickListener { PushChannelCoordinator.retryVendorRegistration() }
+ // 系统推送铃声只能由系统通知设置修改。入口是否可用由当前“实际生效”的
+ // 通道决定,不能用用户偏好判断,否则厂商通道还未切换成功时会误导用户。
+ vendorAlertSetting.setOnClickListener {
+ if (vendorAlertSetting.isEnabled) {
+ WxpJumpPageUtils.jumpToSystemPushSoundGuide(this)
+ }
+ }
+ // 子 view 自己消费点击,不会连带触发卡片的「选中 WS 通道」。
+ // 仅在 WS 已实际生效时允许打开,避免用户误以为它能影响厂商系统推送。
+ wsAlertSetting.setOnClickListener {
+ if (wsAlertSetting.isEnabled) {
+ WxpJumpPageUtils.jumpToWsAlertSetting(this)
+ }
+ }
+ foregroundNotificationCheck.row.setOnClickListener {
+ if (wsDependencyActionsEnabled) {
+ handleForegroundNotificationCheck()
+ }
+ }
+ autoStartCheck.row.setOnClickListener {
+ if (wsDependencyActionsEnabled) {
+ handleAutoStartCheck()
+ }
+ }
+ backgroundCheck.row.setOnClickListener {
+ if (wsDependencyActionsEnabled) {
+ handleBackgroundCheck()
+ }
+ }
+ }
+
+ /** 根据协调器快照完整刷新两个通道卡片。 */
+ private fun render(snapshot: PushChannelSnapshot) {
+ latestSnapshot = snapshot
+ vendorTitle.text = snapshot.vendorName
+ vendorState.text = getVendorState(snapshot.vendorAvailability)
+ vendorRadio.isChecked = snapshot.effectiveChannel == PushChannel.VENDOR
+ wsRadio.isChecked = snapshot.effectiveChannel == PushChannel.WEBSOCKET
+ renderWsState()
+
+ val vendorEnabled = snapshot.vendorAvailability == VendorAvailability.READY
+ && !snapshot.switching
+ vendorCard.isEnabled = vendorEnabled
+ vendorCard.alpha = if (vendorEnabled) {
+ 1f
+ } else {
+ 0.55f
+ }
+ wsCard.isEnabled = !snapshot.switching
+ wsCard.alpha = if (snapshot.switching) {
+ 0.7f
+ } else {
+ 1f
+ }
+ retryVendor.visibility = if (
+ snapshot.vendorAvailability == VendorAvailability.REGISTER_FAILED && !snapshot.switching
+ ) {
+ View.VISIBLE
+ } else {
+ View.GONE
+ }
+ renderVendorAlertSetting(snapshot)
+ renderWsAlertSetting(snapshot)
+ renderWsDependencyChecks(snapshot)
+
+ val selectedColor = ContextCompat.getColor(this, R.color.colorPrimary)
+ val normalColor = ContextCompat.getColor(this, R.color.input_border_color)
+ vendorCard.strokeColor = if (vendorRadio.isChecked) {
+ selectedColor
+ } else {
+ normalColor
+ }
+ vendorCard.strokeWidth = if (vendorRadio.isChecked) {
+ 2
+ } else {
+ 1
+ }
+ wsCard.strokeColor = if (wsRadio.isChecked) {
+ selectedColor
+ } else {
+ normalColor
+ }
+ wsCard.strokeWidth = if (wsRadio.isChecked) {
+ 2
+ } else {
+ 1
+ }
+ vendorRadio.buttonTintList = ColorStateList.valueOf(selectedColor)
+ wsRadio.buttonTintList = ColorStateList.valueOf(selectedColor)
+
+ snapshot.errorMessage?.let {
+ if (it != lastErrorMessage) {
+ WxpToastUtils.showToast(it)
+ lastErrorMessage = it
+ }
+ }
+ if (snapshot.errorMessage == null) {
+ lastErrorMessage = null
+ }
+ }
+
+ /**
+ * 与 WS 的本地提醒设置不同,厂商推送的声音归系统通知类别所有。
+ * 只有厂商推送已经真正生效时才允许进入,避免用户把它误认为 WS 提醒设置。
+ */
+ private fun renderVendorAlertSetting(snapshot: PushChannelSnapshot) {
+ val vendorEffective = snapshot.effectiveChannel == PushChannel.VENDOR
+ vendorAlertSetting.isEnabled = vendorEffective
+ vendorAlertSetting.alpha = if (vendorEffective) 1f else 0.45f
+ vendorAlertSummary.text = if (vendorEffective) {
+ "去系统设置铃声"
+ } else if (snapshot.preference == com.smjcco.wxpusher.push.PushChannelPreference.VENDOR) {
+ "系统推送生效后可设置"
+ } else {
+ "切换至系统推送后可设置"
+ }
+ }
+
+ /**
+ * WS 提醒由 App 本地执行,只在自建链接已经实际生效时才允许修改。
+ * 这与厂商系统推送铃声入口使用相同的可用性判断,均以实际通道而非用户偏好为准。
+ */
+ private fun renderWsAlertSetting(snapshot: PushChannelSnapshot) {
+ val wsEffective = snapshot.effectiveChannel == PushChannel.WEBSOCKET
+ wsAlertSetting.isEnabled = wsEffective
+ wsAlertSetting.alpha = if (wsEffective) 1f else 0.45f
+ wsAlertSummary.text = if (wsEffective) {
+ WsAlertStore.summary()
+ } else {
+ "切换至自建链接可设置"
+ }
+ }
+
+ /**
+ * 长连接运行保障只对已经实际生效的 WS 通道有意义。
+ * 通知和后台限制可以读取系统状态;自启动没有公开查询 API,只展示用户确认状态。
+ */
+ private fun renderWsDependencyChecks(snapshot: PushChannelSnapshot) {
+ val wsEffective = snapshot.effectiveChannel == PushChannel.WEBSOCKET
+ wsDependencyActionsEnabled = wsEffective
+ wsDependencySection.visibility = if (wsEffective) View.VISIBLE else View.GONE
+ if (!wsEffective) {
+ return
+ }
+
+ val notificationReady = isKeepAliveNotificationReady()
+ val autoStartConfirmed = WxpSaveService.get(KEY_WS_AUTO_START_CONFIRMED, false)
+ val backgroundReady = DeviceUtils.canRunInBackgroundWithoutBatteryRestrictions()
+ renderDependencyStatus(foregroundNotificationCheck, notificationReady, "已开启")
+ renderDependencyStatus(autoStartCheck, autoStartConfirmed, "已确认")
+ renderDependencyStatus(backgroundCheck, backgroundReady, "已开启")
+
+ val completedCount = listOf(
+ notificationReady,
+ autoStartConfirmed,
+ backgroundReady,
+ ).count { it }
+ wsDependencySummary.text = if (completedCount == 3) {
+ "3/3 已完成"
+ } else {
+ "还需设置 ${3 - completedCount} 项"
+ }
+ wsDependencySummary.setTextColor(
+ ContextCompat.getColor(
+ this,
+ if (completedCount == 3) R.color.check_success else R.color.check_error,
+ ),
+ )
+ }
+
+ private fun renderDependencyStatus(
+ views: DependencyCheckViews,
+ ready: Boolean,
+ readyText: String,
+ ) {
+ val color = ContextCompat.getColor(
+ this,
+ if (ready) R.color.check_success else R.color.check_error,
+ )
+ views.icon.setImageResource(if (ready) R.drawable.ic_done else R.drawable.ic_warning)
+ views.icon.imageTintList = ColorStateList.valueOf(color)
+ views.status.text = if (ready) readyText else "请设置"
+ views.status.setTextColor(color)
+ views.icon.contentDescription = views.status.text
+ }
+
+ private fun isKeepAliveNotificationReady(): Boolean {
+ if (!PermissionUtils.hasNotificationPermission(this) ||
+ !NotificationManagerCompat.from(this).areNotificationsEnabled()
+ ) {
+ return false
+ }
+ val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ val channel = manager.getNotificationChannel(
+ KeepWsAliveService.KeepWsAliveNotificationChannelId,
+ )
+ // 首次启动服务前通道还不存在;只要应用通知权限可用,服务创建通道后即可展示。
+ return channel == null || channel.importance != NotificationManager.IMPORTANCE_NONE
+ }
+
+ private fun handleForegroundNotificationCheck() {
+ if (!PermissionUtils.hasNotificationPermission(this)) {
+ foregroundNotificationRequester.request { granted ->
+ if (granted && PermissionUtils.hasNotificationPermission(this)) {
+ KeepWsAliveServiceStarter.start(this)
+ }
+ renderWsDependencyChecks(latestSnapshot ?: PushChannelCoordinator.getSnapshot())
+ }
+ return
+ }
+ if (!NotificationManagerCompat.from(this).areNotificationsEnabled()) {
+ WxpToastUtils.showToast("请在系统设置中允许 WxPusher 显示通知")
+ WxpJumpPageUtils.jumpToSystemNotificationSettingPage(this)
+ return
+ }
+
+ val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ val channel = manager.getNotificationChannel(
+ KeepWsAliveService.KeepWsAliveNotificationChannelId,
+ )
+ if (channel != null && channel.importance == NotificationManager.IMPORTANCE_NONE) {
+ WxpJumpPageUtils.jumpToSystemNotificationChannelSettings(
+ channelId = channel.id,
+ activity = this,
+ soundOnly = false,
+ )
+ } else {
+ WxpToastUtils.showToast("保活前台通知权限已开启")
+ }
+ }
+
+ private fun handleAutoStartCheck() {
+ waitingForAutoStartConfirmation = true
+ if (!WxpJumpPageUtils.jumpToSystemAutoStartSettings(this)) {
+ waitingForAutoStartConfirmation = false
+ showManualAutoStartGuide()
+ }
+ }
+
+ /** 厂商没有可用直达入口时,展示可操作的手动路径,不把“无法检测”误报为无权限。 */
+ private fun showManualAutoStartGuide() {
+ val snapshot = latestSnapshot ?: PushChannelCoordinator.getSnapshot()
+ val dialog = AlertDialog.Builder(this)
+ .setTitle("手动开启自启动")
+ .setMessage(getManualAutoStartGuide(snapshot.vendorPlatform))
+ .setPositiveButton("打开应用详情") { _, _ ->
+ waitingForAutoStartConfirmation = true
+ WxpJumpPageUtils.jumpToSystemAppSettings(this)
+ }
+ .setNeutralButton("我已开启") { _, _ ->
+ saveAutoStartConfirmation(true)
+ }
+ .setNegativeButton("取消", null)
+ .create()
+ DialogManager.show(this, dialog)
+ }
+
+ private fun getManualAutoStartGuide(platform: DevicePlatform): String {
+ val path = when (platform) {
+ DevicePlatform.Android_XIAOMI ->
+ "设置 → 应用设置 → 授权管理 → 自启动管理,允许 WxPusher 自启动。"
+
+ DevicePlatform.Android_HUAWEI, DevicePlatform.Android_HONOR ->
+ "手机管家 → 应用启动管理 → WxPusher,关闭自动管理,并允许自启动和后台活动。"
+
+ DevicePlatform.Android_VIVO ->
+ "设置 → 应用与权限 → 权限管理 → 自启动,允许 WxPusher 自启动。"
+
+ DevicePlatform.Android_OPPO ->
+ "设置 → 应用 → 自启动或关联启动管理,允许 WxPusher 自启动和后台运行。"
+
+ DevicePlatform.Android_MEIZU ->
+ "手机管家 → 权限管理 → 后台管理,允许 WxPusher 后台运行和自启动。"
+
+ else ->
+ "请在系统设置或手机管家中找到应用、自启动或后台运行设置,允许 WxPusher 自启动。"
+ }
+ return "$path\n\n不同品牌不同系统版本的菜单名称可能略有不同。WxPusher 无法确定此项状态,请你自行前往手机系统进行设置。"
+ }
+
+ private fun showAutoStartConfirmationDialog() {
+ if (latestSnapshot?.effectiveChannel != PushChannel.WEBSOCKET) {
+ return
+ }
+ val dialog = AlertDialog.Builder(this)
+ .setTitle("确认自启动设置")
+ .setMessage("系统不提供自启动权限的状态查询。请确认你已经允许 WxPusher 自启动或后台启动。")
+ .setPositiveButton("已允许") { _, _ -> saveAutoStartConfirmation(true) }
+ .setNegativeButton("暂未允许") { _, _ -> saveAutoStartConfirmation(false) }
+ .create()
+ DialogManager.show(this, dialog)
+ }
+
+ private fun saveAutoStartConfirmation(confirmed: Boolean) {
+ WxpSaveService.set(KEY_WS_AUTO_START_CONFIRMED, confirmed)
+ renderWsDependencyChecks(latestSnapshot ?: PushChannelCoordinator.getSnapshot())
+ }
+
+ private fun handleBackgroundCheck() {
+ if (DeviceUtils.canRunInBackgroundWithoutBatteryRestrictions()) {
+ WxpToastUtils.showToast("后台运行和电量优化设置已就绪")
+ return
+ }
+ if (!DeviceUtils.isIgnoringBatteryOptimizations()) {
+ if(PushPlatformResolver.detectVendorPushPlatform()== DevicePlatform.Android_XIAOMI){
+ WxpToastUtils.showToast("请选择:无限制")
+ }else{
+ WxpToastUtils.showToast("请允许 WxPusher 忽略电池优化(一直在后台运行)")
+ }
+ WxpJumpPageUtils.jumpToSystemIgnoreBatteryOptimizationSettings(this)
+ return
+ }
+
+ val dialog = AlertDialog.Builder(this)
+ .setTitle("允许应用后台运行")
+ .setMessage("系统已限制 WxPusher 在后台运行。请在应用详情的电池或后台运行设置中选择“允许后台运行”或“不限制”。")
+ .setPositiveButton("打开应用详情") { _, _ ->
+ WxpJumpPageUtils.jumpToSystemAppSettings(this)
+ }
+ .setNegativeButton("取消", null)
+ .create()
+ DialogManager.show(this, dialog)
+ }
+
+ /** 仅在 WS 实际生效时显示实时连接状态。 */
+ private fun renderWsState(
+ connectStatus: WsManager.WsConnectStatus = WsManager.getConnectStatus(),
+ ) {
+ if (latestSnapshot?.effectiveChannel != PushChannel.WEBSOCKET) {
+ // 保留状态行占位,避免切换通道时卡片高度发生跳动。
+ wsState.visibility = View.INVISIBLE
+ return
+ }
+ wsState.visibility = View.VISIBLE
+ wsState.text = when (connectStatus) {
+ WsManager.WsConnectStatus.Connected -> "长连接状态:已连接"
+ WsManager.WsConnectStatus.Connecting -> "长连接状态:连接中…"
+ WsManager.WsConnectStatus.Closing -> "长连接状态:正在断开…"
+ WsManager.WsConnectStatus.NotConnect -> "长连接状态:未连接"
+ }
+ }
+
+ /** 将厂商注册状态转换为面向用户的说明。 */
+ private fun getVendorState(availability: VendorAvailability): String = when (availability) {
+ VendorAvailability.READY -> "系统级推送,更稳定、更省电"
+ VendorAvailability.REGISTERING -> "正在注册系统推送…"
+ VendorAvailability.UNSUPPORTED -> "当前手机不支持系统推送"
+ VendorAvailability.REGISTER_FAILED -> "注册系统推送失败,当前暂不可用"
+ VendorAvailability.CONFIG_DISABLED -> "系统已暂停当前厂商推送通道"
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu): Boolean {
+ menuInflater.inflate(R.menu.menu_push_channel_setting, menu)
+ return true
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ return when (item.itemId) {
+ android.R.id.home -> {
+ finish()
+ true
+ }
+
+ R.id.menu_push_channel_test -> {
+ WxpJumpPageUtils.jumpToWebUrl(
+ "${WxpConfig.appFeUrl}/app/#/send-test-guide",
+ this,
+ )
+ true
+ }
+
+ else -> super.onOptionsItemSelected(item)
+ }
+ }
+
+ companion object {
+ private const val KEY_WS_AUTO_START_CONFIRMED = "WsKeepAlive_AutoStartConfirmed"
+
+ fun start(context: Context) {
+ context.startActivity(Intent(context, PushChannelSettingActivity::class.java))
+ }
+ }
+}
diff --git a/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/page/pushchannel/alert/SystemPushSoundGuideActivity.kt b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/page/pushchannel/alert/SystemPushSoundGuideActivity.kt
new file mode 100644
index 00000000..949cc550
--- /dev/null
+++ b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/page/pushchannel/alert/SystemPushSoundGuideActivity.kt
@@ -0,0 +1,223 @@
+package com.smjcco.wxpusher.page.pushchannel.alert
+
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.content.Context
+import android.content.Intent
+import android.media.RingtoneManager
+import android.os.Bundle
+import android.view.Menu
+import android.view.MenuItem
+import android.view.View
+import android.widget.TextView
+import androidx.core.content.ContextCompat
+import com.google.android.material.button.MaterialButton
+import com.smjcco.wxpusher.R
+import com.smjcco.wxpusher.WxpConfig
+import com.smjcco.wxpusher.base.WxpBaseActivity
+import com.smjcco.wxpusher.bean.DevicePlatform
+import com.smjcco.wxpusher.push.PushChannel
+import com.smjcco.wxpusher.push.PushChannelCoordinator
+import com.smjcco.wxpusher.push.PushChannelSnapshot
+import com.smjcco.wxpusher.utils.WxpJumpPageUtils
+
+/**
+ * 厂商系统推送的铃声设置引导。
+ *
+ * Android 的通知类别创建后,声音由用户在系统设置中控制。本页只读取已验证的类别并引导
+ * 用户跳转,绝不会删除、重建或修改任何通知类别。
+ */
+class SystemPushSoundGuideActivity : WxpBaseActivity() {
+ private lateinit var pushStatus: TextView
+ private lateinit var channelSection: View
+ private lateinit var channelName: TextView
+ private lateinit var channelSound: TextView
+ private lateinit var directHint: TextView
+ private lateinit var openChannelSettingButton: MaterialButton
+ private lateinit var openAppNotificationSettingButton: MaterialButton
+ private lateinit var manualGuide: TextView
+ private lateinit var videoGuideButton: MaterialButton
+
+ private var directChannel: NotificationChannel? = null
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_system_push_sound_guide)
+ supportActionBar?.setDisplayHomeAsUpEnabled(true)
+ title = "系统推送铃声设置"
+ bindViews()
+ bindActions()
+ }
+
+ override fun onResume() {
+ super.onResume()
+ // 用户从系统设置返回后,重新读取铃声和通知类别状态。
+ render(PushChannelCoordinator.getSnapshot())
+ }
+
+ private fun bindViews() {
+ pushStatus = findViewById(R.id.tv_system_push_status)
+ channelSection = findViewById(R.id.layout_channel_section)
+ channelName = findViewById(R.id.tv_channel_name)
+ channelSound = findViewById(R.id.tv_channel_sound)
+ directHint = findViewById(R.id.tv_direct_hint)
+ openChannelSettingButton = findViewById(R.id.btn_open_channel_setting)
+ openAppNotificationSettingButton = findViewById(R.id.btn_open_app_notification_setting)
+ manualGuide = findViewById(R.id.tv_manual_guide)
+ videoGuideButton = findViewById(R.id.btn_video_guide)
+ }
+
+ private fun bindActions() {
+ openChannelSettingButton.setOnClickListener {
+ directChannel?.let { channel ->
+ WxpJumpPageUtils.jumpToSystemNotificationChannelSettings(channel.id, this)
+ }
+ }
+ openAppNotificationSettingButton.setOnClickListener {
+ WxpJumpPageUtils.jumpToSystemNotificationSettingPage(this)
+ }
+ videoGuideButton.setOnClickListener {
+ val platform = PushChannelCoordinator.getSnapshot().vendorPlatform
+ WxpJumpPageUtils.jumpToWebUrl(getGuidePageUrl(platform), this)
+ }
+ }
+
+ private fun render(snapshot: PushChannelSnapshot) {
+ val vendorEffective = snapshot.effectiveChannel == PushChannel.VENDOR
+ if (!vendorEffective) {
+ directChannel = null
+ pushStatus.text = "当前实际使用 ${snapshot.effectiveName},系统推送铃声暂不能设置。"
+ channelSection.visibility = View.GONE
+ directHint.text = "请先在上一页切换至系统推送,并等待切换成功后再设置铃声。"
+ setDirectHintSecondary(false)
+ openChannelSettingButton.visibility = View.GONE
+ setSystemSettingsEnabled(false)
+ manualGuide.text = "系统推送未生效时,修改系统通知铃声不会影响当前消息提醒。"
+ videoGuideButton.visibility = if (hasGuideVideo(snapshot.vendorPlatform)) {
+ View.VISIBLE
+ } else {
+ View.GONE
+ }
+ return
+ }
+
+ pushStatus.text = "当前使用${snapshot.effectiveName},铃声由手机系统控制。"
+ directChannel = findVerifiedDirectChannel(snapshot.vendorPlatform)
+ val channel = directChannel
+ channelSection.visibility = if (channel == null) View.GONE else View.VISIBLE
+ openChannelSettingButton.visibility = if (channel == null) View.GONE else View.VISIBLE
+ setSystemSettingsEnabled(true)
+
+ if (channel != null) {
+ channelName.text = channel.name
+ channelSound.text = getChannelSoundDescription(channel)
+ directHint.text = "已找到系统消息类别,可直接前往修改铃声。"
+ setDirectHintSecondary(true)
+ } else {
+ directHint.text = getDirectUnavailableHint(snapshot.vendorPlatform)
+ setDirectHintSecondary(false)
+ }
+ manualGuide.text = "打开手机系统设置,选择应用,找到“WxPusher”,选择 WxPusher 的“订阅消息”或“消息通知”,然后可调整提醒声音、震动、悬浮弹窗等(不同品牌手机功能不一样)。"
+ videoGuideButton.visibility = if (hasGuideVideo(snapshot.vendorPlatform)) {
+ View.VISIBLE
+ } else {
+ View.GONE
+ }
+ }
+
+ private fun setSystemSettingsEnabled(enabled: Boolean) {
+ openAppNotificationSettingButton.isEnabled = enabled
+ openAppNotificationSettingButton.alpha = if (enabled) 1f else 0.45f
+ }
+
+ /** 成功识别后的补充说明弱化一级,其余引导仍使用正文颜色。 */
+ private fun setDirectHintSecondary(secondary: Boolean) {
+ val colorRes = if (secondary) {
+ R.color.text_fit_theme_second
+ } else {
+ R.color.text_fit_theme_first
+ }
+ directHint.setTextColor(ContextCompat.getColor(this, colorRes))
+ }
+
+ /**
+ * 仅返回经项目验证、确实属于当前厂商系统推送的类别。
+ * 不能猜测华为、荣耀、vivo、OPPO 或魅族的类别 ID,以免把保活或 WS 通知带到错误页面。
+ */
+ private fun findVerifiedDirectChannel(platform: DevicePlatform): NotificationChannel? {
+ val channelId = when (platform) {
+ DevicePlatform.Android_XIAOMI -> "mipush|$packageName|135072"
+ else -> return null
+ }
+ val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ return notificationManager.getNotificationChannel(channelId)
+ }
+
+ private fun getChannelSoundDescription(channel: NotificationChannel): String {
+ val soundUri = channel.sound ?: return "静音"
+ val ringtoneName = runCatching {
+ RingtoneManager.getRingtone(this, soundUri)?.getTitle(this)
+ }.getOrNull()
+ return if (ringtoneName.isNullOrEmpty()) {
+ "已设置系统铃声"
+ } else {
+ ringtoneName
+ }
+ }
+
+ private fun getDirectUnavailableHint(platform: DevicePlatform): String = when (platform) {
+ DevicePlatform.Android_XIAOMI ->
+ "暂未找到“订阅消息”类别。请先接收一条系统推送消息(可以点击右上角的测试发送一个消息),再返回此页设置铃声。"
+
+ DevicePlatform.Android_MEIZU ->
+ "魅族系统推送无法修改通知提醒铃声,如需修改提醒铃声,可切换成WxPusher自建通道。"
+ DevicePlatform.Android_HUAWEI ->
+ "华为系统推送无法修改通知提醒铃声,如需修改提醒铃声,可切换成WxPusher自建通道。"
+ DevicePlatform.Android_HONOR ->
+ "荣耀系统推送无法修改通知提醒铃声,如需修改提醒铃声,可切换成WxPusher自建通道。"
+ DevicePlatform.Android_VIVO ->
+ "VIVO系统推送无法修改通知提醒铃声,如需修改提醒铃声,可切换成WxPusher自建通道。"
+
+ else -> "当前手机品牌未识别到可直达系统的消息类别,请通过系统通知设置或视频教程修改铃声。"
+ }
+
+ private fun hasGuideVideo(platform: DevicePlatform): Boolean = when (platform) {
+ DevicePlatform.Android_XIAOMI,
+ DevicePlatform.Android_HUAWEI,
+ DevicePlatform.Android_VIVO,
+ DevicePlatform.Android_HONOR -> true
+
+ else -> false
+ }
+
+ private fun getGuidePageUrl(platform: DevicePlatform): String =
+ "https://wxpusher.zjiecode.com/docs/open-app-note/index.html?brand=${platform.getPlatform()}"
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
+ android.R.id.home -> {
+ finish()
+ true
+ }
+
+ R.id.menu_push_channel_test -> {
+ WxpJumpPageUtils.jumpToWebUrl(
+ "${WxpConfig.appFeUrl}/app/#/send-test-guide",
+ this,
+ )
+ true
+ }
+
+ else -> super.onOptionsItemSelected(item)
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu): Boolean {
+ menuInflater.inflate(R.menu.menu_push_channel_setting, menu)
+ return true
+ }
+
+ companion object {
+ fun start(context: Context) {
+ context.startActivity(Intent(context, SystemPushSoundGuideActivity::class.java))
+ }
+ }
+}
diff --git a/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/page/pushchannel/alert/WsAlertSettingActivity.kt b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/page/pushchannel/alert/WsAlertSettingActivity.kt
new file mode 100644
index 00000000..ec1148af
--- /dev/null
+++ b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/page/pushchannel/alert/WsAlertSettingActivity.kt
@@ -0,0 +1,190 @@
+package com.smjcco.wxpusher.page.pushchannel.alert
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.view.MenuItem
+import android.view.View
+import android.view.ViewGroup
+import android.widget.TextView
+import com.google.android.material.slider.Slider
+import com.google.android.material.switchmaterial.SwitchMaterial
+import com.smjcco.wxpusher.R
+import com.smjcco.wxpusher.base.WxpBaseActivity
+import com.smjcco.wxpusher.dialog.ActionSheetDialogFragment
+import com.smjcco.wxpusher.dialog.ActionSheetItem
+import com.smjcco.wxpusher.push.ws.alert.WsAlertPlayer
+import com.smjcco.wxpusher.push.ws.alert.WsAlertStore
+import com.smjcco.wxpusher.push.ws.alert.WsAlertTones
+
+/**
+ * WS 通道的提醒方式设置页。
+ *
+ * 纯本地设置,改一项存一项,所以没有「保存」按钮,也没有加载失败态——这点和 iOS 的
+ * 提醒铃声页不同,iOS 的铃声要存服务端。
+ *
+ * 提醒时长是这一页的主控件:拖到 0 就完全不额外提醒,下面几个开关也随之失效。
+ */
+class WsAlertSettingActivity : WxpBaseActivity() {
+ private lateinit var durationSlider: Slider
+ private lateinit var durationDes: TextView
+ private lateinit var alertSectionHeader: TextView
+ private lateinit var alertGroup: ViewGroup
+ private lateinit var vibrateSwitch: SwitchMaterial
+ private lateinit var torchSwitch: SwitchMaterial
+ private lateinit var soundSwitch: SwitchMaterial
+ private lateinit var forceLoudSwitch: SwitchMaterial
+ private lateinit var toneRow: View
+ private lateinit var toneValue: TextView
+ private lateinit var tryButton: TextView
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_ws_alert_setting)
+ supportActionBar?.setDisplayHomeAsUpEnabled(true)
+ title = "提醒方式设置"
+ bindViews()
+ bindActions()
+ render()
+ }
+
+ override fun onPause() {
+ // 试听最长能有 60 秒,跟着用户离开页面继续响是不能接受的
+ WsAlertPlayer.stopAll()
+ super.onPause()
+ }
+
+ private fun bindViews() {
+ durationSlider = findViewById(R.id.slider_duration)
+ durationDes = findViewById(R.id.tv_duration_des)
+ alertSectionHeader = findViewById(R.id.tv_alert_section_header)
+ alertGroup = findViewById(R.id.layout_alert_group)
+ vibrateSwitch = findViewById(R.id.switch_vibrate)
+ torchSwitch = findViewById(R.id.switch_torch)
+ soundSwitch = findViewById(R.id.switch_sound)
+ forceLoudSwitch = findViewById(R.id.switch_force_loud)
+ toneRow = findViewById(R.id.layout_tone)
+ toneValue = findViewById(R.id.tv_tone_value)
+ tryButton = findViewById(R.id.btn_try)
+ }
+
+ private fun bindActions() {
+ durationSlider.valueFrom = 0f
+ durationSlider.valueTo = WsAlertStore.DURATION_MAX.toFloat()
+ durationSlider.stepSize = WsAlertStore.DURATION_STEP.toFloat()
+ durationSlider.setLabelFormatter { "${it.toInt()} 秒" }
+ // 拖动过程中只刷新界面,松手才落盘,避免一次拖动写几十次 SharedPreferences
+ durationSlider.addOnChangeListener { _, value, _ ->
+ renderDuration(value.toInt())
+ }
+ durationSlider.addOnSliderTouchListener(object : Slider.OnSliderTouchListener {
+ override fun onStartTrackingTouch(slider: Slider) = Unit
+
+ override fun onStopTrackingTouch(slider: Slider) {
+ WsAlertStore.setDurationSeconds(slider.value.toInt())
+ render()
+ }
+ })
+
+ findViewById(R.id.layout_vibrate).setOnClickListener {
+ WsAlertStore.setVibrateEnabled(!WsAlertStore.isVibrateEnabled())
+ render()
+ }
+ findViewById(R.id.layout_torch).setOnClickListener {
+ WsAlertStore.setTorchEnabled(!WsAlertStore.isTorchEnabled())
+ render()
+ }
+ findViewById(R.id.layout_sound).setOnClickListener {
+ WsAlertStore.setSoundEnabled(!WsAlertStore.isSoundEnabled())
+ render()
+ }
+ findViewById(R.id.layout_force_loud).setOnClickListener {
+ WsAlertStore.setForceLoud(!WsAlertStore.isForceLoud())
+ render()
+ }
+ toneRow.setOnClickListener { showToneChooser() }
+ tryButton.setOnClickListener { WsAlertPlayer.alertOnce() }
+ }
+
+ /** 选中即试听,和 iOS 的提醒铃声页一致。 */
+ private fun showToneChooser() {
+ val currentKey = WsAlertStore.getSoundKey()
+ val items = WsAlertTones.all.map { option ->
+ val label = if (option.key == currentKey) {
+ "${option.name} ✓"
+ } else {
+ option.name
+ }
+ ActionSheetItem(label) {
+ WsAlertStore.setSoundKey(option.key)
+ render()
+ WsAlertPlayer.previewTone(option.key)
+ }
+ }
+ ActionSheetDialogFragment(listOf(items)).show(supportFragmentManager, "wsAlertTone")
+ }
+
+ private fun render() {
+ renderDuration(WsAlertStore.getDurationSeconds())
+ durationSlider.value = WsAlertStore.getDurationSeconds().toFloat()
+ vibrateSwitch.isChecked = WsAlertStore.isVibrateEnabled()
+ torchSwitch.isChecked = WsAlertStore.isTorchEnabled()
+ soundSwitch.isChecked = WsAlertStore.isSoundEnabled()
+ forceLoudSwitch.isChecked = WsAlertStore.isForceLoud()
+ toneValue.text = WsAlertTones.find(WsAlertStore.getSoundKey()).name
+
+ // 时长为 0 时 App 不做任何额外提醒,下面几个开关也就没有意义了
+ val alertEnabled = WsAlertStore.getDurationSeconds() > 0
+ setRowGroupEnabled(alertEnabled)
+ // 响铃关掉时,提示音和「静音时也响铃」跟着失效
+ val soundEnabled = alertEnabled && WsAlertStore.isSoundEnabled()
+ setRowEnabled(toneRow, soundEnabled)
+ setRowEnabled(findViewById(R.id.layout_force_loud), soundEnabled)
+ }
+
+ private fun renderDuration(seconds: Int) {
+ durationDes.text = if (seconds <= 0) {
+ "不额外提醒,按系统设置在通知栏静默显示"
+ } else {
+ "收到消息后持续提醒 $seconds 秒"
+ }
+ }
+
+ private fun setRowGroupEnabled(enabled: Boolean) {
+ alertSectionHeader.text = if (enabled) {
+ "提醒方式"
+ } else {
+ "提醒方式(把提醒时长拖到 0 以上后可用)"
+ }
+ for (index in 0 until alertGroup.childCount) {
+ setRowEnabled(alertGroup.getChildAt(index), enabled)
+ }
+ setRowEnabled(tryButton, enabled)
+ }
+
+ private fun setRowEnabled(row: View, enabled: Boolean) {
+ row.isEnabled = enabled
+ row.alpha = if (enabled) {
+ 1f
+ } else {
+ 0.45f
+ }
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ return when (item.itemId) {
+ android.R.id.home -> {
+ finish()
+ true
+ }
+
+ else -> super.onOptionsItemSelected(item)
+ }
+ }
+
+ companion object {
+ fun start(context: Context) {
+ context.startActivity(Intent(context, WsAlertSettingActivity::class.java))
+ }
+ }
+}
diff --git a/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/page/web/WxpWebViewFragment.kt b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/page/web/WxpWebViewFragment.kt
index b30dba38..361e623f 100644
--- a/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/page/web/WxpWebViewFragment.kt
+++ b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/page/web/WxpWebViewFragment.kt
@@ -449,7 +449,8 @@ open class WxpWebViewFragment : WxpBaseFragment() {
val loginInfo = WxpAppDataService.getLoginInfo()
val deviceToken = loginInfo?.deviceToken ?: ""
val versionName = WxpBaseInfoService.getAppVersionName()
- val platform = WxpBaseInfoService.getPlatform()
+ // WebView 请求头描述的是 App FE 所处的客户端环境,不用于选择后端推送通道。
+ val platform = WxpBaseInfoService.getClientPlatform()
headers[DEVICE_TOKEN_KEY] = deviceToken
headers[DEVICE_VERSION_NAME_KEY] = versionName
@@ -867,4 +868,4 @@ open class WxpWebViewFragment : WxpBaseFragment() {
super.onDestroyView()
}
-}
\ No newline at end of file
+}
diff --git a/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/PushActiveReportLifecycle.kt b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/PushActiveReportLifecycle.kt
new file mode 100644
index 00000000..fb410679
--- /dev/null
+++ b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/PushActiveReportLifecycle.kt
@@ -0,0 +1,47 @@
+package com.smjcco.wxpusher.push
+
+import android.app.Activity
+import android.app.Application
+import android.os.Bundle
+
+/**
+ * 监听 Activity 启动,并触发设备活跃信息检查。
+ *
+ * onActivityStarted 在每次页面切换时都会触发,并不等价于应用回到前台;协调器会通过最近
+ * 一次成功上报时间统一限制一小时内不再发起网络请求,因此这里不额外维护 Activity 数量状态。
+ */
+internal object PushActiveReportLifecycle : Application.ActivityLifecycleCallbacks {
+ private var initialized = false
+
+ /** 注册全局 Activity 生命周期监听,同一进程只注册一次。 */
+ fun init(application: Application) {
+ if (initialized) {
+ return
+ }
+ initialized = true
+ application.registerActivityLifecycleCallbacks(this)
+ }
+
+ /** 任意 Activity 启动时检查是否需要补充上报设备活跃信息。 */
+ override fun onActivityStarted(activity: Activity) {
+ PushChannelCoordinator.reportActiveIfNeeded()
+ }
+
+ override fun onActivityStopped(activity: Activity) {
+ }
+
+ override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
+ }
+
+ override fun onActivityResumed(activity: Activity) {
+ }
+
+ override fun onActivityPaused(activity: Activity) {
+ }
+
+ override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {
+ }
+
+ override fun onActivityDestroyed(activity: Activity) {
+ }
+}
diff --git a/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/PushChannelCoordinator.kt b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/PushChannelCoordinator.kt
new file mode 100644
index 00000000..3527e65a
--- /dev/null
+++ b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/PushChannelCoordinator.kt
@@ -0,0 +1,783 @@
+package com.smjcco.wxpusher.push
+
+import android.app.Application
+import com.smjcco.wxpusher.api.WxpApiService
+import com.smjcco.wxpusher.base.biz.WxpAppDataService
+import com.smjcco.wxpusher.base.biz.bean.WxpUpdateInfoReq
+import com.smjcco.wxpusher.base.common.ApplicationUtils
+import com.smjcco.wxpusher.base.common.WxpLogUtils
+import com.smjcco.wxpusher.base.common.WxpScopeUtils
+import com.smjcco.wxpusher.bean.DevicePlatform
+import com.smjcco.wxpusher.config.ConfigItem
+import com.smjcco.wxpusher.config.ConfigManager
+import com.smjcco.wxpusher.push.ws.WxpNotificationManager
+import com.smjcco.wxpusher.push.ws.connect.WsManager
+import com.smjcco.wxpusher.push.ws.keepalive.KeepWsAliveServiceStarter
+import com.smjcco.wxpusher.utils.ThreadUtils
+import kotlinx.coroutines.launch
+
+/**
+ * 推送通道设置页使用的只读状态快照。
+ *
+ * 这里同时包含“用户选择”“当前实际生效结果”和“厂商通道能力”三类状态,三者含义不同:
+ *
+ * - [preference] 只表示用户最后一次主动选择,不会被首次注册失败或系统降级开关覆盖。
+ * - [effectiveChannel] 表示最近一次已经成功生效、当前真正用于消息路由的通道。
+ * - [vendorAvailability] 表示当前手机上的厂商系统推送能否被选择,与当前实际使用哪个通道无关。
+ *
+ * 因此在切换请求完成前,[preference] 和 [effectiveChannel] 可能不同;系统强制降级到
+ * WxPusher 自建链接时,用户的 [preference] 也可能仍然是厂商系统推送。
+ * 例如 `preference=VENDOR`、`effectiveChannel=WEBSOCKET`、
+ * `wsReason=INITIAL_VENDOR_FALLBACK` 表示用户选择厂商推送,但因为首次注册失败,当前已降级为 WS。
+ *
+ * @property vendorPlatform 当前手机识别出的厂商推送平台,例如小米、华为或荣耀;
+ * 不支持厂商系统推送时为 [DevicePlatform.Android]。
+ * @property vendorName [vendorPlatform] 对应的用户可读名称,例如“小米系统推送”。
+ * @property vendorAvailability 厂商系统推送当前的注册和可用状态,用于控制厂商选项是否可选
+ * 以及展示注册中、不支持、注册失败或系统禁用等原因。
+ * @property preference 用户持久化的主动选择。自动降级只改变实际通道,不修改该字段,
+ * 以便厂商通道恢复可用后仍能按用户原选择自动恢复。
+ * @property effectiveChannel 最近一次已经成功生效的通道。已登录设备以服务端同步成功为准;
+ * 未登录设备以本地已经应用的目标为准。页面选中状态和运行时判断应使用该字段,而不是
+ * 直接使用 [preference]。
+ * @property effectiveName [effectiveChannel] 对应的用户可读名称;厂商通道会显示具体厂商,
+ * 自建通道显示“WxPusher自建链接”。
+ * @property wsReason 当前选择或请求启用 WxPusher 自建链接的原因;主动选择、首次注册失败、
+ * 系统强制降级和设备不支持厂商推送会返回不同值。该字段描述“为什么目标是 WS”,不代表
+ * WS 已经成功生效,是否真正生效仍以 [effectiveChannel] 为准;没有 WS 启用原因时为空。
+ * @property switching 是否正在向后端同步新的平台和 token。为 `true` 时,用户选择可能已经保存,
+ * 但 [effectiveChannel] 仍保持上一次成功同步的通道。
+ * @property errorMessage 最近一次厂商注册、通道切换或同步失败时需要展示的错误信息;
+ * 没有待展示错误时为空。
+ */
+data class PushChannelSnapshot(
+ val vendorPlatform: DevicePlatform,
+ val vendorName: String,
+ val vendorAvailability: VendorAvailability,
+ val preference: PushChannelPreference,
+ val effectiveChannel: PushChannel,
+ val effectiveName: String,
+ val wsReason: WsActivationReason?,
+ val switching: Boolean,
+ val errorMessage: String?,
+)
+
+/**
+ * Android 推送通道唯一协调器。
+ *
+ * 用户选择、系统配置和 token 回调只负责更新各自状态,最终统一通过
+ * [syncDesiredChannel] 计算目标通道并串行同步到后端。
+ */
+object PushChannelCoordinator {
+ private const val TAG = "PushChannelCoordinator"
+
+ /** 等待厂商 SDK 返回 token 的最长时间。 */
+ private const val VENDOR_REGISTER_TIMEOUT_MILLIS = 10_000L
+
+ /** 自动路由同步失败后的有限退避间隔。 */
+ private val AUTOMATIC_SYNC_RETRY_DELAYS_MILLIS = longArrayOf(
+ 30_000L,
+ 2 * 60_000L,
+ 10 * 60_000L,
+ )
+
+ private lateinit var application: Application
+ private var initialized = false
+
+ /**
+ * 当前设备识别到的厂商推送平台。
+ *
+ * 初始化和云端配置变化时重新识别,其余协调流程复用本次结果,避免反复调用厂商 SDK
+ * 能力检查,并保证一次通道决策期间平台保持一致。
+ */
+ private var vendorPlatform = DevicePlatform.Android
+
+ private var vendorAvailability = VendorAvailability.REGISTERING
+ private var switching = false
+ private var errorMessage: String? = null
+
+ // 同一时间只允许一个路由请求;请求期间只保留最新目标,完成后重新对账。
+ private var inFlightTarget: PushChannelTarget? = null
+ private var pendingTarget: PushChannelTarget? = null
+ private var automaticRetryIndex = 0
+
+ private val listeners = mutableSetOf<(PushChannelSnapshot) -> Unit>()
+
+ private val vendorTimeoutRunnable = Runnable {
+ if (vendorAvailability != VendorAvailability.REGISTERING) {
+ return@Runnable
+ }
+ WxpLogUtils.w(TAG, "获取厂商pushToken超时,platform=${vendorPlatform.getPlatform()}")
+ handleVendorTokenFailed(vendorPlatform)
+ }
+
+ private val automaticSyncRetryRunnable = Runnable {
+ if (!shouldRetryAutomatically()) {
+ cancelAutomaticSyncRetry()
+ return@Runnable
+ }
+ syncDesiredChannel()
+ }
+
+ private val configListener: (ConfigItem) -> Unit = {
+ onConfigChanged()
+ }
+
+ /** 初始化厂商能力、迁移旧数据,并恢复当前通道。 */
+ fun init(app: Application = ApplicationUtils.getApplication()) {
+ if (initialized) {
+ return
+ }
+ initialized = true
+ application = app
+
+ vendorPlatform = PushPlatformResolver.detectVendorPushPlatform()
+ PushChannelStore.migrateIfNeeded()
+ restoreEffectivePlatform()
+ restoreLocalEffectiveChannel()
+ ConfigManager.addListener(configListener)
+ applyCurrentDecision()
+ }
+
+ /** 从持久化状态恢复运行时生效平台和旧业务仍使用的通用 token。 */
+ private fun restoreEffectivePlatform() {
+ val defaultPlatform = if (getDesiredChannel() == PushChannel.WEBSOCKET) {
+ DevicePlatform.Android
+ } else {
+ vendorPlatform
+ }
+ val savedTarget = PushPlatformState.restoreEffectivePushPlatform(defaultPlatform)
+ if (savedTarget != null) {
+ WxpAppDataService.savePushToken(savedTarget.token)
+ }
+ }
+
+ /** 按服务端最后确认的通道恢复本地 WS 服务,避免进程重启后出现接收空窗。 */
+ private fun restoreLocalEffectiveChannel() {
+ val effectiveTarget = PushPlatformState.getPersistedEffectivePushTarget()
+ if (
+ effectiveTarget?.platform == DevicePlatform.Android
+ && effectiveTarget.token.isNotEmpty()
+ ) {
+ ensureWsRunning()
+ } else if (PushChannelStore.isWsRequested()) {
+ stopWsInfrastructure()
+ }
+ }
+
+ /** 根据设备能力、ConfigManager 和用户选择执行一次完整启动决策。 */
+ private fun applyCurrentDecision() {
+ cancelAutomaticSyncRetry()
+
+ if (!hasVendorSupport()) {
+ vendorAvailability = VendorAvailability.UNSUPPORTED
+ PushChannelStore.setWsReason(WsActivationReason.UNSUPPORTED_DEVICE)
+ ensureWsRunning()
+ syncDesiredChannel(force = true)
+ return
+ }
+
+ if (!isVendorAllowed()) {
+ vendorAvailability = VendorAvailability.CONFIG_DISABLED
+ PushChannelStore.setWsReason(WsActivationReason.SYSTEM_CONFIG_FORCED)
+ ensureWsRunning()
+ syncDesiredChannel(force = true)
+ return
+ }
+
+ clearExpiredForcedWsReason()
+ val vendorTarget = getCurrentVendorTarget()
+ vendorAvailability = if (vendorTarget == null) {
+ VendorAvailability.REGISTERING
+ } else {
+ VendorAvailability.READY
+ }
+
+ if (PushChannelStore.getPreference() == PushChannelPreference.WEBSOCKET) {
+ PushChannelStore.setWsReason(WsActivationReason.USER_SELECTED)
+ ensureWsRunning()
+ startVendorRegistration()
+ syncDesiredChannel(force = true)
+ return
+ }
+
+ if (PushChannelStore.getWsReason() == WsActivationReason.USER_SELECTED) {
+ PushChannelStore.setWsReason(null)
+ }
+
+ val effectiveTarget = PushPlatformState.getPersistedEffectivePushTarget()
+ if (
+ vendorTarget == null
+ && effectiveTarget?.platform == DevicePlatform.Android
+ && effectiveTarget.token.isNotEmpty()
+ ) {
+ // 已经生效的 WS 在新厂商 token 返回前继续工作,成功后自动恢复厂商通道。
+ PushChannelStore.setWsReason(WsActivationReason.INITIAL_VENDOR_FALLBACK)
+ ensureWsRunning()
+ }
+
+ startVendorRegistration()
+ syncDesiredChannel(force = true)
+ }
+
+ /** 厂商 SDK 成功返回 token 后的统一入口。 */
+ fun onVendorToken(token: String, platform: DevicePlatform) {
+ ThreadUtils.runOnMainThread {
+ handleVendorToken(token, platform)
+ }
+ }
+
+ /** 在主线程校验并处理厂商 token。 */
+ private fun handleVendorToken(token: String, platform: DevicePlatform) {
+ if (token.isEmpty() || platform != vendorPlatform) {
+ WxpLogUtils.i(
+ TAG,
+ "忽略非当前厂商token,callback=${platform.getPlatform()}, vendor=${vendorPlatform.getPlatform()}"
+ )
+ return
+ }
+
+ ThreadUtils.getMainThreadHandler().removeCallbacks(vendorTimeoutRunnable)
+ PushChannelStore.saveVendorRegistration(platform, token)
+ // 产品定义以成功取得合法厂商 token 为“曾注册成功”,不依赖后端同步结果。
+ PushChannelStore.setVendorEverRegistered(true)
+ vendorAvailability = if (isVendorAllowed()) {
+ VendorAvailability.READY
+ } else {
+ VendorAvailability.CONFIG_DISABLED
+ }
+ errorMessage = null
+ cancelAutomaticSyncRetry()
+
+ if (getDesiredChannel() == PushChannel.VENDOR) {
+ syncDesiredChannel()
+ } else {
+ // 当前目标是 WS 时仅缓存厂商 token,方便以后手动或自动恢复。
+ notifyChanged()
+ }
+ }
+
+ /** 厂商 SDK 注册失败或等待 token 超时后的统一入口。 */
+ fun onVendorTokenFailed(platform: DevicePlatform) {
+ ThreadUtils.runOnMainThread {
+ handleVendorTokenFailed(platform)
+ }
+ }
+
+ /** 在主线程校验并处理厂商注册失败。 */
+ private fun handleVendorTokenFailed(platform: DevicePlatform) {
+ if (platform != vendorPlatform) {
+ WxpLogUtils.i(
+ TAG,
+ "忽略非当前厂商注册失败,callback=${platform.getPlatform()}, vendor=${vendorPlatform.getPlatform()}"
+ )
+ return
+ }
+
+ ThreadUtils.getMainThreadHandler().removeCallbacks(vendorTimeoutRunnable)
+ if (!isVendorAllowed()) {
+ vendorAvailability = VendorAvailability.CONFIG_DISABLED
+ notifyChanged()
+ return
+ }
+
+ vendorAvailability = VendorAvailability.REGISTER_FAILED
+ errorMessage = "注册系统推送失败"
+
+ // 只有从未成功取得过厂商 token 时才自动降级,后续异常交由用户手动选择。
+ if (
+ !PushChannelStore.hasVendorEverRegistered()
+ && PushChannelStore.getPreference() == PushChannelPreference.VENDOR
+ ) {
+ PushChannelStore.setWsReason(WsActivationReason.INITIAL_VENDOR_FALLBACK)
+ ensureWsRunning()
+ syncDesiredChannel()
+ }
+ notifyChanged()
+ }
+
+ /** WebSocket 初始化消息返回 token 后,仅在当前目标为 WS 时同步路由。 */
+ fun onWsToken(token: String) {
+ ThreadUtils.runOnMainThread {
+ if (token.isEmpty()) {
+ return@runOnMainThread
+ }
+ PushChannelStore.setWsToken(token)
+ if (getDesiredChannel() == PushChannel.WEBSOCKET) {
+ syncDesiredChannel()
+ }
+ }
+ }
+
+ /** 用户在设置页选择厂商系统推送。 */
+ fun selectVendor() {
+ if (switching || vendorAvailability != VendorAvailability.READY || !isVendorAllowed()) {
+ return
+ }
+
+ val vendorTarget = getCurrentVendorTarget()
+ if (vendorTarget == null) {
+ vendorAvailability = VendorAvailability.REGISTERING
+ startVendorRegistration()
+ notifyChanged()
+ return
+ }
+
+ // 用户选择立即持久化;页面选中状态仍以服务端已确认的 effectiveTarget 为准。
+ PushChannelStore.setPreference(PushChannelPreference.VENDOR)
+ PushChannelStore.setWsReason(null)
+ errorMessage = null
+ cancelAutomaticSyncRetry()
+ syncDesiredChannel()
+ }
+
+ /** 用户在设置页选择 WxPusher 自建链接。 */
+ fun selectWebSocket() {
+ if (switching) {
+ return
+ }
+
+ PushChannelStore.setPreference(PushChannelPreference.WEBSOCKET)
+ PushChannelStore.setWsReason(WsActivationReason.USER_SELECTED)
+ errorMessage = null
+ cancelAutomaticSyncRetry()
+ ensureWsRunning()
+ syncDesiredChannel()
+ }
+
+ /**
+ * Activity 启动时检查设备活跃上报。
+ *
+ * 该方法会在每次页面切换时被调用,因此依赖最近一次成功上报时间做一小时节流。
+ * 超过间隔后仍通过协调器成对上报当前平台和 token,避免独立上报再次造成
+ * platform 与 token 错配。只有已经登录且存在可用目标时才会真正发起请求。
+ */
+ fun reportActiveIfNeeded() {
+ ThreadUtils.runOnMainThread {
+ if (!initialized || WxpAppDataService.getLoginInfo()?.deviceId.isNullOrEmpty()) {
+ return@runOnMainThread
+ }
+
+ if (!WxpAppDataService.isDeviceInfoReportExpired()) {
+ return@runOnMainThread
+ }
+
+ WxpLogUtils.i(TAG, "Android回到页面,重新上报当前推送路由和设备活跃信息")
+ syncDesiredChannel(force = true)
+ }
+ }
+
+ /** 用户点击“重新注册”时重试厂商 SDK,但不改变当前推送通道。 */
+ fun retryVendorRegistration() {
+ if (!hasVendorSupport() || !isVendorAllowed() || switching) {
+ return
+ }
+ vendorAvailability = VendorAvailability.REGISTERING
+ errorMessage = null
+ startVendorRegistration()
+ notifyChanged()
+ }
+
+ /** 发起厂商注册;仅在页面处于注册中时安装超时,避免 SDK 永不回调。 */
+ private fun startVendorRegistration() {
+ if (!hasVendorSupport() || !isVendorAllowed()) {
+ return
+ }
+
+ if (
+ getCurrentVendorTarget() == null
+ || vendorAvailability == VendorAvailability.REGISTER_FAILED
+ ) {
+ vendorAvailability = VendorAvailability.REGISTERING
+ }
+
+ PushManager.startVendorRegistration(application, vendorPlatform)
+ ThreadUtils.getMainThreadHandler().removeCallbacks(vendorTimeoutRunnable)
+ if (vendorAvailability == VendorAvailability.REGISTERING) {
+ ThreadUtils.runOnMainThread(
+ vendorTimeoutRunnable,
+ VENDOR_REGISTER_TIMEOUT_MILLIS,
+ )
+ }
+ notifyChanged()
+ }
+
+ /** 确保 WS 连接与保活服务已经启动。 */
+ private fun ensureWsRunning() {
+ PushChannelStore.setWsRequested(true)
+ WxpNotificationManager.init()
+ WsManager.start()
+ KeepWsAliveServiceStarter.start(application)
+ }
+
+ /**
+ * 计算最新目标并与后端对账。
+ *
+ * 已有请求时不并发写入,只记录最新目标;当前请求结束后会重新计算并继续同步,
+ * 因此不会丢失用户最后一次选择。
+ */
+ private fun syncDesiredChannel(force: Boolean = false) {
+ val desiredTarget = buildDesiredTarget()
+ if (desiredTarget == null) {
+ notifyChanged()
+ return
+ }
+
+ if (inFlightTarget != null) {
+ pendingTarget = desiredTarget
+ return
+ }
+
+ val effectiveTarget = PushPlatformState.getPersistedEffectivePushTarget()
+ if (!force && effectiveTarget == desiredTarget) {
+ applyLocalChannelState(desiredTarget)
+ return
+ }
+
+ submitTarget(desiredTarget)
+ }
+
+ /** 串行提交一个明确的平台和 token。 */
+ private fun submitTarget(target: PushChannelTarget) {
+ val deviceUuid = WxpAppDataService.getLoginInfo()?.deviceId
+ if (deviceUuid.isNullOrEmpty()) {
+ applyTargetBeforeLogin(target)
+ return
+ }
+
+ inFlightTarget = target
+ switching = true
+ errorMessage = null
+ notifyChanged()
+
+ val updateInfoReq = WxpUpdateInfoReq(
+ deviceUuid = deviceUuid,
+ pushToken = target.token,
+ platform = target.platform.getPlatform(),
+ )
+ WxpScopeUtils.getMainScope().launch {
+ val success = WxpApiService.updateDeviceInfo(req = updateInfoReq, silent = true) == true
+ if (success) {
+ // 只有服务端明确返回成功才记录上报状态,网络失败后下次回到页面仍可继续尝试。
+ WxpAppDataService.recordDeviceInfoReportSuccess(updateInfoReq)
+ }
+ onTargetSubmitted(target, success)
+ }
+ }
+
+ /**
+ * 未登录时在本地应用目标通道,为后续登录请求准备成对的平台和 token。
+ *
+ * 此时服务端尚未收到设备信息,因此不能调用 [onTargetSubmitted] 伪装请求成功,
+ * 也绝不能更新最近成功上报时间。登录请求会携带这里保存的平台和 token 完成设备注册。
+ */
+ private fun applyTargetBeforeLogin(target: PushChannelTarget) {
+ PushPlatformState.commitEffectivePushTarget(
+ target.platform,
+ target.token,
+ )
+ WxpAppDataService.savePushToken(target.token)
+ applyLocalChannelState(target)
+ }
+
+ /** 处理一次路由上报结果,并在请求期间目标变化时继续同步最新目标。 */
+ private fun onTargetSubmitted(submittedTarget: PushChannelTarget, success: Boolean) {
+ if (inFlightTarget != submittedTarget) {
+ return
+ }
+
+ inFlightTarget = null
+ val queuedTarget = pendingTarget
+ pendingTarget = null
+
+ if (success) {
+ // 每次成功都先记录服务端真实状态;即使它已经过期,也能保证进程被杀后本地与后端一致。
+ PushPlatformState.commitEffectivePushTarget(
+ submittedTarget.platform,
+ submittedTarget.token,
+ )
+ WxpAppDataService.savePushToken(submittedTarget.token)
+
+ val latestTarget = buildDesiredTarget()
+ if (latestTarget == submittedTarget) {
+ applyLocalChannelState(submittedTarget)
+ return
+ }
+
+ WxpLogUtils.i(
+ TAG,
+ "路由请求完成后目标已变化,submitted=$submittedTarget, queued=$queuedTarget, latest=$latestTarget"
+ )
+ if (latestTarget != null) {
+ submitTarget(latestTarget)
+ } else {
+ switching = false
+ notifyChanged()
+ }
+ return
+ }
+
+ val latestTarget = buildDesiredTarget()
+ if (latestTarget != null && latestTarget != submittedTarget) {
+ // 旧目标失败不影响新目标,立即继续提交当前最新选择。
+ submitTarget(latestTarget)
+ return
+ }
+
+ switching = false
+ errorMessage = "切换失败,请稍后重试"
+ if (
+ submittedTarget.platform == DevicePlatform.Android
+ && PushChannelStore.getWsReason() == WsActivationReason.USER_SELECTED
+ ) {
+ // 用户主动切换失败后停止无效 WS,保留 preference 供下次手动重试。
+ stopWsInfrastructure()
+ }
+
+ if (shouldRetryAutomatically()) {
+ scheduleAutomaticSyncRetry()
+ }
+ notifyChanged()
+ }
+
+ /** 服务端确认目标仍是最新选择后,更新本地通道和设置页。 */
+ private fun applyLocalChannelState(target: PushChannelTarget) {
+ if (buildDesiredTarget() != target) {
+ syncDesiredChannel()
+ return
+ }
+
+ if (target.platform == DevicePlatform.Android) {
+ PushChannelStore.setWsToken(target.token)
+ ensureWsRunning()
+ } else {
+ PushChannelStore.saveVendorRegistration(target.platform, target.token)
+ PushChannelStore.setVendorEverRegistered(true)
+ PushChannelStore.setWsReason(null)
+ stopWsInfrastructure()
+ }
+
+ switching = false
+ errorMessage = null
+ cancelAutomaticSyncRetry()
+ PushManager.notifyEffectiveTokenChanged(target.platform, target.token)
+ notifyChanged()
+ }
+
+ /** 停止 WS 连接、保活服务和尚未执行的启动任务。 */
+ private fun stopWsInfrastructure() {
+ PushChannelStore.setWsRequested(false)
+ WsManager.stop()
+ KeepWsAliveServiceStarter(application).stop()
+ }
+
+ /** 按有限退避间隔安排下一次系统自动同步。 */
+ private fun scheduleAutomaticSyncRetry() {
+ ThreadUtils.getMainThreadHandler().removeCallbacks(automaticSyncRetryRunnable)
+ if (automaticRetryIndex >= AUTOMATIC_SYNC_RETRY_DELAYS_MILLIS.size) {
+ WxpLogUtils.w(TAG, "自动同步推送通道已达到最大重试次数")
+ return
+ }
+
+ val delayMillis = AUTOMATIC_SYNC_RETRY_DELAYS_MILLIS[automaticRetryIndex]
+ automaticRetryIndex += 1
+ ThreadUtils.runOnMainThread(automaticSyncRetryRunnable, delayMillis)
+ }
+
+ /** 取消自动同步任务并重置退避次数。 */
+ private fun cancelAutomaticSyncRetry() {
+ ThreadUtils.getMainThreadHandler().removeCallbacks(automaticSyncRetryRunnable)
+ automaticRetryIndex = 0
+ }
+
+ /** 只有系统兜底行为失败时才自动重试,用户手动切换失败交由用户再次操作。 */
+ private fun shouldRetryAutomatically(): Boolean {
+ return when (PushChannelStore.getWsReason()) {
+ WsActivationReason.INITIAL_VENDOR_FALLBACK,
+ WsActivationReason.SYSTEM_CONFIG_FORCED,
+ WsActivationReason.UNSUPPORTED_DEVICE -> true
+
+ WsActivationReason.USER_SELECTED,
+ null -> false
+ }
+ }
+
+ /** 云端配置变化后重新识别能力,并按用户原选择重新对账。 */
+ private fun onConfigChanged() {
+ cancelAutomaticSyncRetry()
+ vendorPlatform = PushPlatformResolver.detectVendorPushPlatform()
+
+ if (!hasVendorSupport()) {
+ vendorAvailability = VendorAvailability.UNSUPPORTED
+ PushChannelStore.setWsReason(WsActivationReason.UNSUPPORTED_DEVICE)
+ ensureWsRunning()
+ syncDesiredChannel()
+ return
+ }
+
+ if (!isVendorAllowed()) {
+ ThreadUtils.getMainThreadHandler().removeCallbacks(vendorTimeoutRunnable)
+ vendorAvailability = VendorAvailability.CONFIG_DISABLED
+ errorMessage = null
+ PushChannelStore.setWsReason(WsActivationReason.SYSTEM_CONFIG_FORCED)
+ ensureWsRunning()
+ syncDesiredChannel()
+ return
+ }
+
+ clearExpiredForcedWsReason()
+ val vendorTarget = getCurrentVendorTarget()
+ vendorAvailability = if (vendorTarget == null) {
+ VendorAvailability.REGISTERING
+ } else {
+ VendorAvailability.READY
+ }
+
+ if (PushChannelStore.getPreference() == PushChannelPreference.WEBSOCKET) {
+ PushChannelStore.setWsReason(WsActivationReason.USER_SELECTED)
+ ensureWsRunning()
+ } else {
+ if (PushChannelStore.getWsReason() == WsActivationReason.USER_SELECTED) {
+ PushChannelStore.setWsReason(null)
+ }
+ if (
+ vendorTarget == null
+ && PushPlatformState.getPersistedEffectivePushTarget()?.platform
+ == DevicePlatform.Android
+ ) {
+ PushChannelStore.setWsReason(WsActivationReason.INITIAL_VENDOR_FALLBACK)
+ ensureWsRunning()
+ }
+ }
+
+ startVendorRegistration()
+ syncDesiredChannel()
+ }
+
+ /** 清除已经不再成立的系统强制原因,保留用户主动选择。 */
+ private fun clearExpiredForcedWsReason() {
+ val reason = PushChannelStore.getWsReason()
+ if (
+ reason == WsActivationReason.SYSTEM_CONFIG_FORCED
+ || reason == WsActivationReason.UNSUPPORTED_DEVICE
+ ) {
+ val restoredReason = if (
+ PushChannelStore.getPreference() == PushChannelPreference.WEBSOCKET
+ ) {
+ WsActivationReason.USER_SELECTED
+ } else {
+ null
+ }
+ PushChannelStore.setWsReason(restoredReason)
+ }
+ }
+
+ /** 根据设备能力、系统配置和用户选择计算当前目标通道。 */
+ private fun getDesiredChannel(): PushChannel {
+ if (!hasVendorSupport() || !isVendorAllowed()) {
+ return PushChannel.WEBSOCKET
+ }
+ if (PushChannelStore.getPreference() == PushChannelPreference.WEBSOCKET) {
+ return PushChannel.WEBSOCKET
+ }
+ if (
+ PushChannelStore.getWsReason() == WsActivationReason.INITIAL_VENDOR_FALLBACK
+ && getCurrentVendorTarget() == null
+ ) {
+ return PushChannel.WEBSOCKET
+ }
+ return PushChannel.VENDOR
+ }
+
+ /** 将当前目标通道转换成必须成对上报的平台和 token。 */
+ private fun buildDesiredTarget(): PushChannelTarget? {
+ return if (getDesiredChannel() == PushChannel.WEBSOCKET) {
+ val token = PushChannelStore.getWsToken()
+ if (token.isEmpty()) {
+ null
+ } else {
+ PushChannelTarget(DevicePlatform.Android, token)
+ }
+ } else {
+ getCurrentVendorTarget()
+ }
+ }
+
+ /** 读取与当前设备厂商匹配的缓存 token,发现错配时立即清理。 */
+ private fun getCurrentVendorTarget(): PushChannelTarget? {
+ val target = PushChannelStore.getVendorPushTarget() ?: return null
+ if (target.platform == vendorPlatform) {
+ return target
+ }
+
+ WxpLogUtils.w(
+ TAG,
+ "清理平台不匹配的厂商token,cached=${target.platform}, vendor=$vendorPlatform"
+ )
+ PushChannelStore.clearVendorRegistration()
+ PushChannelStore.setVendorEverRegistered(false)
+ return null
+ }
+
+ /** 注册设置页状态监听器。 */
+ fun addListener(listener: (PushChannelSnapshot) -> Unit) {
+ listeners.add(listener)
+ listener(getSnapshot())
+ }
+
+ /** 移除设置页状态监听器。 */
+ fun removeListener(listener: (PushChannelSnapshot) -> Unit) {
+ listeners.remove(listener)
+ }
+
+ /** 获取设置页展示所需的当前只读状态。 */
+ fun getSnapshot(): PushChannelSnapshot {
+ val effectivePlatform = PushPlatformState.getEffectivePushPlatform()
+ val effectiveChannel = PushPlatformState.getEffectivePushChannel()
+ return PushChannelSnapshot(
+ vendorPlatform = vendorPlatform,
+ vendorName = getVendorName(vendorPlatform),
+ vendorAvailability = vendorAvailability,
+ preference = PushChannelStore.getPreference(),
+ effectiveChannel = effectiveChannel,
+ effectiveName = if (effectiveChannel == PushChannel.WEBSOCKET) {
+ "WxPusher自建链接"
+ } else {
+ getVendorName(effectivePlatform)
+ },
+ wsReason = PushChannelStore.getWsReason(),
+ switching = switching,
+ errorMessage = errorMessage,
+ )
+ }
+
+ /** 获取当前已经由后端确认生效的通道名称。 */
+ fun getCurrentChannelName(): String = getSnapshot().effectiveName
+
+ /** 将最新快照统一回调到主线程。 */
+ private fun notifyChanged() {
+ val snapshot = getSnapshot()
+ ThreadUtils.runOnMainThread {
+ listeners.toList().forEach { it(snapshot) }
+ }
+ }
+
+ /** 判断当前手机是否识别到厂商系统推送能力。 */
+ private fun hasVendorSupport(): Boolean = vendorPlatform != DevicePlatform.Android
+
+ /** 判断当前识别到的厂商推送是否被 ConfigManager 允许使用。 */
+ private fun isVendorAllowed(): Boolean {
+ return PushPlatformResolver.isVendorPushAllowed(vendorPlatform)
+ }
+
+ /** 返回设置页展示的厂商推送名称。 */
+ fun getVendorName(platform: DevicePlatform): String = when (platform) {
+ DevicePlatform.Android_XIAOMI -> "小米系统推送"
+ DevicePlatform.Android_HUAWEI -> "华为系统推送"
+ DevicePlatform.Android_VIVO -> "VIVO 系统推送"
+ DevicePlatform.Android_HONOR -> "荣耀系统推送"
+ DevicePlatform.Android_OPPO -> "OPPO 系统推送"
+ DevicePlatform.Android_MEIZU -> "魅族系统推送"
+ else -> "系统推送"
+ }
+}
diff --git a/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/PushChannelStore.kt b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/PushChannelStore.kt
new file mode 100644
index 00000000..74b24d0e
--- /dev/null
+++ b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/PushChannelStore.kt
@@ -0,0 +1,199 @@
+package com.smjcco.wxpusher.push
+
+import com.smjcco.wxpusher.base.biz.WxpAppDataService
+import com.smjcco.wxpusher.base.common.WxpSaveService
+import com.smjcco.wxpusher.bean.DevicePlatform
+
+/** 用户主动选择的推送通道。系统强制降级不会覆盖该选择。 */
+enum class PushChannelPreference {
+ VENDOR,
+ WEBSOCKET,
+}
+
+/** 当前实际生效的推送通道,用于页面展示和运行时判断。 */
+enum class PushChannel {
+ VENDOR,
+ WEBSOCKET,
+}
+
+/**
+ * 启用 WebSocket 通道的原因。
+ *
+ * 原因会被持久化,以便应用重启后继续执行正确的恢复策略。
+ */
+enum class WsActivationReason {
+ /** 用户在设置页主动选择。 */
+ USER_SELECTED,
+
+ /** 首次获取厂商 token 失败后的临时降级。 */
+ INITIAL_VENDOR_FALLBACK,
+
+ /** ConfigManager 全局开关关闭厂商通道后的强制降级。 */
+ SYSTEM_CONFIG_FORCED,
+
+ /** 当前设备没有可用的厂商推送能力。 */
+ UNSUPPORTED_DEVICE,
+}
+
+/** 厂商推送在设置页中的可用状态。 */
+enum class VendorAvailability {
+ READY,
+ REGISTERING,
+ UNSUPPORTED,
+ REGISTER_FAILED,
+ CONFIG_DISABLED,
+}
+
+/** 持久化的推送路由目标,平台与 token 必须成对读取和写入。 */
+internal data class PushChannelTarget(
+ val platform: DevicePlatform,
+ val token: String,
+)
+
+/** Android 推送通道状态持久化。 */
+object PushChannelStore {
+ // V2 不再猜测历史厂商 token 的来源,避免把 HMS token 误标成荣耀等其他平台。
+ private const val KEY_MIGRATED_V2 = "PushChannel_MigratedV2"
+
+ // 用户选择与厂商注册状态。
+ private const val KEY_PREFERENCE = "PushChannel_UserPreference"
+ private const val KEY_VENDOR_REGISTERED = "PushChannel_VendorEverRegistered"
+ private const val KEY_VENDOR_PLATFORM = "PushChannel_VendorPlatform"
+ private const val KEY_VENDOR_TOKEN = "PushChannel_VendorToken"
+
+ // WebSocket 与当前实际生效通道的状态。
+ private const val KEY_WS_TOKEN = "PushChannel_WsToken"
+ private const val KEY_EFFECTIVE_PLATFORM = "PushChannel_EffectivePlatform"
+ private const val KEY_EFFECTIVE_TOKEN = "PushChannel_EffectiveToken"
+ private const val KEY_WS_REASON = "PushChannel_WsReason"
+ private const val KEY_WS_REQUESTED = "PushChannel_WsRequested"
+
+ /**
+ * 首次升级时迁移旧版 token。
+ *
+ * `PT_` 前缀可以明确识别为 WebSocket token;历史厂商 token 没有保存来源平台,
+ * 因此不能根据当前手机品牌猜测其平台,只能清理新结构并等待厂商 SDK 返回新 token。
+ */
+ fun migrateIfNeeded() {
+ if (WxpSaveService.get(KEY_MIGRATED_V2, false)) {
+ return
+ }
+
+ val currentToken = WxpAppDataService.getPushToken().orEmpty()
+ if (currentToken.startsWith("PT_")) {
+ setWsToken(currentToken)
+ saveEffectivePushTarget(DevicePlatform.Android, currentToken)
+ if (
+ getPreference() == PushChannelPreference.VENDOR
+ && getWsReason() == null
+ ) {
+ setWsReason(WsActivationReason.INITIAL_VENDOR_FALLBACK)
+ }
+ } else {
+ // 历史厂商 token 来源不可信,保留服务端旧路由,等待本机 SDK 重新注册后再覆盖。
+ clearVendorRegistration()
+ clearEffectivePushTarget()
+ setVendorEverRegistered(false)
+ WxpAppDataService.savePushToken("")
+ }
+ WxpSaveService.set(KEY_MIGRATED_V2, true)
+ }
+
+ /** 获取用户主动选择的推送通道。 */
+ fun getPreference(): PushChannelPreference = runCatching {
+ PushChannelPreference.valueOf(
+ WxpSaveService.get(KEY_PREFERENCE, PushChannelPreference.VENDOR.name)
+ )
+ }.getOrDefault(PushChannelPreference.VENDOR)
+
+ /** 保存用户主动选择的推送通道,系统强制降级不能覆盖该值。 */
+ fun setPreference(preference: PushChannelPreference) {
+ WxpSaveService.set(KEY_PREFERENCE, preference.name)
+ }
+
+ /** 判断当前设备是否曾成功取得过合法厂商 token。 */
+ fun hasVendorEverRegistered(): Boolean =
+ WxpSaveService.get(KEY_VENDOR_REGISTERED, false)
+
+ /** 保存当前设备是否曾成功取得过合法厂商 token。 */
+ fun setVendorEverRegistered(value: Boolean) {
+ WxpSaveService.set(KEY_VENDOR_REGISTERED, value)
+ }
+
+ /** 保存厂商推送注册成功后返回的平台和 token。 */
+ internal fun saveVendorRegistration(platform: DevicePlatform, token: String) {
+ WxpSaveService.set(KEY_VENDOR_PLATFORM, platform.getPlatform())
+ WxpSaveService.set(KEY_VENDOR_TOKEN, token)
+ }
+
+ /** 获取已缓存且平台信息完整的厂商推送目标。 */
+ internal fun getVendorPushTarget(): PushChannelTarget? {
+ val platform = DevicePlatform.find(WxpSaveService.get(KEY_VENDOR_PLATFORM, ""))
+ ?: return null
+ val token = WxpSaveService.get(KEY_VENDOR_TOKEN, "")
+ if (token.isEmpty()) {
+ return null
+ }
+ return PushChannelTarget(platform, token)
+ }
+
+ /** 获取已缓存的厂商推送 token。 */
+ fun getVendorToken(): String = WxpSaveService.get(KEY_VENDOR_TOKEN, "")
+
+ /** 清理无法确认来源或已经失效的厂商注册信息。 */
+ internal fun clearVendorRegistration() {
+ WxpSaveService.set(KEY_VENDOR_PLATFORM, "")
+ WxpSaveService.set(KEY_VENDOR_TOKEN, "")
+ }
+
+ /** 保存 WebSocket 服务返回的 token。 */
+ fun setWsToken(token: String) {
+ WxpSaveService.set(KEY_WS_TOKEN, token)
+ }
+
+ /** 获取最近一次 WebSocket 服务返回的 token。 */
+ fun getWsToken(): String = WxpSaveService.get(KEY_WS_TOKEN, "")
+
+ /** 成对保存当前已经与后端同步成功的推送平台和 token。 */
+ internal fun saveEffectivePushTarget(platform: DevicePlatform, token: String) {
+ WxpSaveService.set(KEY_EFFECTIVE_PLATFORM, platform.getPlatform())
+ WxpSaveService.set(KEY_EFFECTIVE_TOKEN, token)
+ }
+
+ /** 清理无法确认平台与 token 对应关系的历史生效目标。 */
+ internal fun clearEffectivePushTarget() {
+ WxpSaveService.set(KEY_EFFECTIVE_PLATFORM, "")
+ WxpSaveService.set(KEY_EFFECTIVE_TOKEN, "")
+ }
+
+ /**
+ * 成对读取当前已经与后端同步成功的推送平台和 token。
+ *
+ * 平台字段为空或无法识别时返回空;token 是否有效由状态层和协调器按具体场景判断。
+ */
+ internal fun getEffectivePushTarget(): PushChannelTarget? {
+ val platform = DevicePlatform.find(WxpSaveService.get(KEY_EFFECTIVE_PLATFORM, ""))
+ ?: return null
+ val token = WxpSaveService.get(KEY_EFFECTIVE_TOKEN, "")
+ return PushChannelTarget(platform, token)
+ }
+
+ /** 保存当前使用 WS 的原因;传空表示已经不再处于 WS 特殊状态。 */
+ fun setWsReason(reason: WsActivationReason?) {
+ WxpSaveService.set(KEY_WS_REASON, reason?.name ?: "")
+ }
+
+ /** 获取当前使用 WS 的原因。 */
+ fun getWsReason(): WsActivationReason? = runCatching {
+ WsActivationReason.valueOf(WxpSaveService.get(KEY_WS_REASON, ""))
+ }.getOrNull()
+
+ /** 保存当前是否需要维持 WS 连接和前台保活服务。 */
+ fun setWsRequested(requested: Boolean) {
+ WxpSaveService.set(KEY_WS_REQUESTED, requested)
+ }
+
+ /** 判断当前是否需要维持 WS 连接和前台保活服务。 */
+ fun isWsRequested(): Boolean = WxpSaveService.get(KEY_WS_REQUESTED, false)
+
+}
diff --git a/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/PushManager.kt b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/PushManager.kt
index 6bbdc132..796b6d5b 100644
--- a/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/PushManager.kt
+++ b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/PushManager.kt
@@ -11,22 +11,19 @@ import com.smjcco.wxpusher.push.huawei.HuaweiPushUtils
import com.smjcco.wxpusher.push.meizu.MeizuPushUtils
import com.smjcco.wxpusher.push.oppo.OppoPushUtils
import com.smjcco.wxpusher.push.vivo.VIVOPushUtils
-import com.smjcco.wxpusher.push.ws.WxpNotificationManager
-import com.smjcco.wxpusher.push.ws.connect.WsManager
-import com.smjcco.wxpusher.push.ws.keepalive.KeepWsAliveServiceStarter
import com.smjcco.wxpusher.push.xiaomi.XiaomiUtils
-import com.smjcco.wxpusher.utils.DeviceUtils
import com.smjcco.wxpusher.utils.PermissionUtils
-import com.smjcco.wxpusher.utils.ThreadUtils
interface IPushTokenChangedListener {
fun onPushToken(platform: DevicePlatform, pushToken: String)
}
/**
- * 管理push的一堆事儿,对厂商和通道做抽象
+ * 各厂商推送 SDK 的统一适配入口。
+ *
+ * 通道决策由 [PushChannelCoordinator] 负责,本类只发起厂商注册并转发 token 回调。
*/
-object PushManager : Runnable {
+object PushManager {
private val TAG = "PushManager"
private val pushTokenChangedListenerList: MutableList =
@@ -41,7 +38,12 @@ object PushManager : Runnable {
return
}
- val platform = DeviceUtils.getPlatform()
+ PushChannelCoordinator.init(application)
+ PushActiveReportLifecycle.init(application)
+ }
+
+ /** 由协调器调用,只负责发起当前设备对应厂商 SDK 的注册。 */
+ internal fun startVendorRegistration(application: Application, platform: DevicePlatform) {
if (platform == DevicePlatform.Android_XIAOMI) {
WxpLogUtils.i(TAG, "初始化小米推送")
XiaomiUtils.init(application)
@@ -61,59 +63,29 @@ object PushManager : Runnable {
WxpLogUtils.i(TAG, "初始化魅族推送")
MeizuPushUtils.init(application)
} else {
- WxpLogUtils.i(TAG, "初始化自建长链接")
- WxpNotificationManager.init()
- WsManager.init()
- //启动保活,必须在最后
- KeepWsAliveServiceStarter.start(application)
- }
-
- //如果不是安卓,厂商通道设置token注册超时,10秒超时以后,走自建ws推送通道
- if (platform != DevicePlatform.Android) {
- ThreadUtils.runOnMainThread(this, 10 * 1000)
+ WxpLogUtils.i(TAG, "当前设备没有可注册的厂商推送,platform=$platform")
}
}
-
- override fun run() {
- val platform = DeviceUtils.getPlatform()
- WxpLogUtils.i(
- TAG,
- "获取厂商pushToken超时,platform=【" + platform.getPlatform() + "】,初始化自建长链接"
- )
- onGetPushTokenFail(platform)
- }
-
- /**
- * 当获取pushtoken失败的时候回调
- */
+ /** 将厂商 token 获取失败事件交给通道协调器处理。 */
fun onGetPushTokenFail(platform: DevicePlatform) {
- if (platform != DevicePlatform.Android) {
- WxpLogUtils.i(
- TAG,
- "获取厂商pushToken失败【" + platform.getPlatform() + "】,初始化自建长链接"
- )
- ThreadUtils.getMainThreadHandler().removeCallbacks(this)
- //厂商推送注册失败了,设备为安卓,默认走ws通道
- DeviceUtils.setPlatform(DevicePlatform.Android)
- init()
- }
+ PushChannelCoordinator.onVendorTokenFailed(platform)
}
- /**
- * 当获取到推动token的时候,管理token的上报,更新
- */
+ /** 根据 token 来源分发给厂商通道或 WebSocket 通道。 */
fun onGetPushToken(token: String, platform: DevicePlatform) {
WxpLogUtils.i(TAG, "收到设备token,platform=${platform}, token=${token}")
- ThreadUtils.getMainThreadHandler().removeCallbacks(this)
- WxpAppDataService.savePushToken(token)
- WxpAppDataService.updateDeviceInfo(platform.getPlatform())
-
- // 发送pushToken变更的通知
- ThreadUtils.runOnMainThread {
- for (listener in pushTokenChangedListenerList) {
- listener.onPushToken(platform, token)
- }
+ if (platform == DevicePlatform.Android) {
+ PushChannelCoordinator.onWsToken(token)
+ } else {
+ PushChannelCoordinator.onVendorToken(token, platform)
+ }
+ }
+
+ /** 仅在新通道真正生效后通知旧有业务监听器。 */
+ internal fun notifyEffectiveTokenChanged(platform: DevicePlatform, token: String) {
+ for (listener in pushTokenChangedListenerList.toList()) {
+ listener.onPushToken(platform, token)
}
}
@@ -141,7 +113,7 @@ object PushManager : Runnable {
if (!PermissionUtils.hasNotificationPermission(activity)) {
return
}
- val platform = DeviceUtils.getPlatform()
+ val platform = PushPlatformState.getEffectivePushPlatform()
if (platform == DevicePlatform.Android_XIAOMI) {
XiaomiUtils.showSettingGuide(activity)
} else if (platform == DevicePlatform.Android_VIVO) {
@@ -159,11 +131,11 @@ object PushManager : Runnable {
}
fun getGuidePageUrl(): String {
- val platform = DeviceUtils.getPlatform()
+ val platform = PushPlatformResolver.detectVendorPushPlatform()
return "https://wxpusher.zjiecode.com/docs/open-app-note/index.html?brand=%s".format(
platform.getPlatform()
)
}
-}
\ No newline at end of file
+}
diff --git a/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/PushPlatformResolver.kt b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/PushPlatformResolver.kt
new file mode 100644
index 00000000..fbfc76ca
--- /dev/null
+++ b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/PushPlatformResolver.kt
@@ -0,0 +1,130 @@
+package com.smjcco.wxpusher.push
+
+import android.os.Build
+import com.heytap.msp.push.HeytapPushManager
+import com.hihonor.push.sdk.HonorPushClient
+import com.huawei.hms.api.HuaweiApiAvailability
+import com.meizu.cloud.pushsdk.PushManager as MeizuPushManager
+import com.smjcco.wxpusher.base.common.ApplicationUtils
+import com.smjcco.wxpusher.bean.DevicePlatform
+import com.smjcco.wxpusher.config.ConfigManager
+import com.vivo.push.PushClient
+
+/**
+ * Android 推送平台识别器。
+ *
+ * 本类只负责根据设备能力和远程配置识别厂商推送平台,不保存当前生效通道,
+ * 也不执行注册、降级或后端上报。
+ */
+object PushPlatformResolver {
+
+ /**
+ * 仅根据设备和厂商 SDK 能力识别系统推送平台。
+ *
+ * 此方法不应用厂商推送总开关,适合设置页展示手机实际支持的系统推送类型。
+ * 判断顺序与原有逻辑保持一致,避免多个厂商 SDK 同时声明可用时改变识别结果。
+ */
+ fun detectVendorPushPlatform(): DevicePlatform {
+ if (isXiaomiDevice()) {
+ return DevicePlatform.Android_XIAOMI
+ } else if (isVivoPushSupported()) {
+ return DevicePlatform.Android_VIVO
+ } else if (isOppoPushSupported()) {
+ return DevicePlatform.Android_OPPO
+ } else if (isHonorPushSupported()) {
+ return DevicePlatform.Android_HONOR
+ } else if (isHuaweiDevice()) {
+ return DevicePlatform.Android_HUAWEI
+ } else if (isHuaweiMobileServicesAvailable()) {
+ return DevicePlatform.Android_HUAWEI
+ } else if (MeizuPushManager.isBrandMeizu()) {
+ return DevicePlatform.Android_MEIZU
+ }
+ return DevicePlatform.Android
+ }
+
+ /**
+ * 识别当前远程配置允许使用的默认厂商推送平台。
+ *
+ * 首次安装尚未形成有效通道状态时使用该结果作为默认路由。判断条件和顺序
+ * 完整保留原有实现,不能改成“先识别厂商再判断开关”,否则可能改变兼容机型行为。
+ */
+ internal fun resolveConfigAllowedVendorPushPlatform(): DevicePlatform {
+ val config = ConfigManager.getCurrentConfig()
+ if (isXiaomiDevice() && config.xiaomiPush) {
+ return DevicePlatform.Android_XIAOMI
+ } else if (isVivoPushSupported() && config.vivoPush) {
+ return DevicePlatform.Android_VIVO
+ } else if (isOppoPushSupported() && config.oppoPush) {
+ return DevicePlatform.Android_OPPO
+ } else if (isHonorPushSupported() && config.honorPush) {
+ return DevicePlatform.Android_HONOR
+ } else if (isHuaweiDevice() && config.huaweiPush) {
+ return DevicePlatform.Android_HUAWEI
+ } else if (isHuaweiMobileServicesAvailable() && config.huaweiPushJustHcm) {
+ // 华为能力放在靠后位置,避免仅安装 HMS Core 的设备被优先识别成华为设备。
+ return DevicePlatform.Android_HUAWEI
+ } else if (MeizuPushManager.isBrandMeizu() && config.meizuPush) {
+ return DevicePlatform.Android_MEIZU
+ }
+ return DevicePlatform.Android
+ }
+
+ /**
+ * 判断指定厂商平台当前是否被 ConfigManager 允许使用。
+ *
+ * 该方法用于协调器处理系统强制降级,判断规则与原协调器逻辑保持一致。
+ */
+ internal fun isVendorPushAllowed(platform: DevicePlatform): Boolean {
+ val config = ConfigManager.getCurrentConfig()
+ return when (platform) {
+ DevicePlatform.Android_XIAOMI -> config.xiaomiPush
+ // 兼容旧逻辑:HMS 能力兜底开关可以独立启用华为通道。
+ DevicePlatform.Android_HUAWEI -> config.huaweiPush || config.huaweiPushJustHcm
+ DevicePlatform.Android_VIVO -> config.vivoPush
+ DevicePlatform.Android_HONOR -> config.honorPush
+ DevicePlatform.Android_OPPO -> config.oppoPush
+ DevicePlatform.Android_MEIZU -> config.meizuPush
+ else -> false
+ }
+ }
+
+ /** 判断平台是否属于 Android 厂商系统推送,而不是通用 Android WS 通道。 */
+ internal fun isVendorPushPlatform(platform: DevicePlatform): Boolean {
+ return platform != DevicePlatform.Android && platform.name.startsWith("Android_")
+ }
+
+ /** 判断当前设备是否为小米设备。 */
+ private fun isXiaomiDevice(): Boolean {
+ return Build.MANUFACTURER.equals("Xiaomi", true)
+ }
+
+ /** 判断当前设备是否安装并支持所需版本的 HMS Core。 */
+ private fun isHuaweiMobileServicesAvailable(): Boolean {
+ return HuaweiApiAvailability.getInstance()
+ .isHuaweiMobileServicesAvailable(ApplicationUtils.getApplication()) == 0
+ }
+
+ /** 判断当前设备是否为支持 HMS 推送的华为或荣耀设备。 */
+ private fun isHuaweiDevice(): Boolean {
+ val isHuaweiOrHonor = Build.MANUFACTURER.equals("huawei", true)
+ || Build.MANUFACTURER.equals("HONOR", true)
+ return isHuaweiOrHonor && isHuaweiMobileServicesAvailable()
+ }
+
+ /** 判断荣耀推送 SDK 是否支持当前设备。 */
+ private fun isHonorPushSupported(): Boolean {
+ return HonorPushClient.getInstance()
+ .checkSupportHonorPush(ApplicationUtils.getApplication())
+ }
+
+ /** 判断 VIVO 推送 SDK 是否支持当前设备。 */
+ private fun isVivoPushSupported(): Boolean {
+ return PushClient.getInstance(ApplicationUtils.getApplication()).isSupport
+ }
+
+ /** 判断 OPPO 推送 SDK 是否支持当前设备。 */
+ private fun isOppoPushSupported(): Boolean {
+ return HeytapPushManager.isSupportPush(ApplicationUtils.getApplication())
+ }
+}
diff --git a/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/PushPlatformState.kt b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/PushPlatformState.kt
new file mode 100644
index 00000000..56fa2369
--- /dev/null
+++ b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/PushPlatformState.kt
@@ -0,0 +1,83 @@
+package com.smjcco.wxpusher.push
+
+import com.smjcco.wxpusher.bean.DevicePlatform
+
+/**
+ * Android 推送平台运行时状态。
+ *
+ * 本类是当前生效推送平台的唯一内存状态入口。厂商平台识别由
+ * [PushPlatformResolver] 完成,持久化由 [PushChannelStore] 完成,通道切换决策由
+ * [PushChannelCoordinator] 完成。
+ */
+object PushPlatformState {
+
+ @Volatile
+ private var runtimeEffectivePushPlatform: DevicePlatform? = null
+
+ /**
+ * 获取当前用于后端消息路由的推送平台。
+ *
+ * 读取顺序保持原有逻辑不变:运行时状态、带有效 token 的持久化状态、
+ * ConfigManager 当前允许的默认厂商平台,最后由识别器兜底为 Android WS。
+ */
+ fun getEffectivePushPlatform(): DevicePlatform {
+ val runtimePlatform = runtimeEffectivePushPlatform
+ if (runtimePlatform != null) {
+ return runtimePlatform
+ }
+
+ val persistedTarget = PushChannelStore.getEffectivePushTarget()
+ if (persistedTarget != null && persistedTarget.token.isNotEmpty()) {
+ return persistedTarget.platform
+ }
+
+ return PushPlatformResolver.resolveConfigAllowedVendorPushPlatform()
+ }
+
+ /**
+ * 获取当前已经生效的推送通道。
+ *
+ * Android 平台表示使用 WxPusher 自建链接,其他 Android 厂商平台表示使用
+ * 对应的厂商系统推送。通道判断统一由状态层完成,避免调用方重复理解平台语义。
+ */
+ fun getEffectivePushChannel(): PushChannel {
+ return if (getEffectivePushPlatform() == DevicePlatform.Android) {
+ PushChannel.WEBSOCKET
+ } else {
+ PushChannel.VENDOR
+ }
+ }
+
+ /**
+ * 从持久化数据恢复运行时生效平台。
+ *
+ * 返回有效的持久化目标供协调器同步通用 pushToken;没有有效目标时使用
+ * 当前识别厂商作为首次注册阶段的默认平台。
+ */
+ internal fun restoreEffectivePushPlatform(
+ defaultPlatform: DevicePlatform,
+ ): PushChannelTarget? {
+ val persistedTarget = PushChannelStore.getEffectivePushTarget()
+ if (persistedTarget != null && persistedTarget.token.isNotEmpty()) {
+ runtimeEffectivePushPlatform = persistedTarget.platform
+ return persistedTarget
+ }
+ runtimeEffectivePushPlatform = defaultPlatform
+ return null
+ }
+
+ /**
+ * 提交通道切换成功后的生效平台和 token。
+ *
+ * 持久化和内存状态在同一入口更新,避免出现两处状态由调用方手工同步的问题。
+ */
+ internal fun commitEffectivePushTarget(platform: DevicePlatform, token: String) {
+ PushChannelStore.saveEffectivePushTarget(platform, token)
+ runtimeEffectivePushPlatform = platform
+ }
+
+ /** 获取协调器决策所需的持久化生效目标,不执行任何兜底推断。 */
+ internal fun getPersistedEffectivePushTarget(): PushChannelTarget? {
+ return PushChannelStore.getEffectivePushTarget()
+ }
+}
diff --git a/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/honor/HonorPushUtils.kt b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/honor/HonorPushUtils.kt
index 9749bbe3..1ad8e7c0 100644
--- a/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/honor/HonorPushUtils.kt
+++ b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/honor/HonorPushUtils.kt
@@ -21,6 +21,7 @@ object HonorPushUtils {
override fun onSuccess(pushToken: String?) {
if (pushToken.isNullOrEmpty()) {
WxpLogUtils.w(TAG, "荣耀推送-init-onNewToken=null")
+ PushManager.onGetPushTokenFail(DevicePlatform.Android_HONOR)
return
}
PushManager.onGetPushToken(pushToken, DevicePlatform.Android_HONOR)
@@ -46,13 +47,14 @@ object HonorPushUtils {
TAG,
"荣耀推送-init-失败,errorCode=$errorCode,errorString=$errorString"
)
+ PushManager.onGetPushTokenFail(DevicePlatform.Android_HONOR)
}
})
} catch (e: ApiException) {
WxpLogUtils.e(TAG, "荣耀推送-init- 获取token失败", e)
- PushManager.onGetPushTokenFail(DevicePlatform.Android_HUAWEI)
+ PushManager.onGetPushTokenFail(DevicePlatform.Android_HONOR)
}
}
}
-}
\ No newline at end of file
+}
diff --git a/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/huawei/HuaweiHmsMessageService.kt b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/huawei/HuaweiHmsMessageService.kt
index 29ecf03a..605dc07a 100644
--- a/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/huawei/HuaweiHmsMessageService.kt
+++ b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/huawei/HuaweiHmsMessageService.kt
@@ -5,7 +5,7 @@ import com.huawei.hms.push.HmsMessageService
import com.smjcco.wxpusher.base.common.WxpLogUtils
import com.smjcco.wxpusher.bean.DevicePlatform
import com.smjcco.wxpusher.push.PushManager
-import com.smjcco.wxpusher.utils.DeviceUtils
+import com.smjcco.wxpusher.push.PushPlatformResolver
class HuaweiHmsMessageService : HmsMessageService() {
@@ -13,15 +13,19 @@ class HuaweiHmsMessageService : HmsMessageService() {
override fun onNewToken(s: String?) {
if (s.isNullOrEmpty()) {
WxpLogUtils.w(TAG, "华为推送-onNewToken=null")
- PushManager.onGetPushTokenFail(DevicePlatform.Android_HONOR)
+ // 其他品牌安装 HMS 后也可能收到空回调,必须与成功分支使用相同的厂商守卫。
+ if (PushPlatformResolver.detectVendorPushPlatform() == DevicePlatform.Android_HUAWEI) {
+ PushManager.onGetPushTokenFail(DevicePlatform.Android_HUAWEI)
+ }
return
}
WxpLogUtils.i(TAG, "华为推送-通过HuaweiHmsMessageService获取token=" + s)
- if (DeviceUtils.getPlatform() == DevicePlatform.Android_HUAWEI) {
+ // 其他品牌安装 HMS 后也可能收到回调,因此按设备厂商能力过滤,不能按当前推送通道过滤。
+ if (PushPlatformResolver.detectVendorPushPlatform() == DevicePlatform.Android_HUAWEI) {
PushManager.onGetPushToken(s, DevicePlatform.Android_HUAWEI)
} else {
- //安装有HCM的时候 ,可能会自动回调token,所以不进行回调,避免被覆盖
+ // 非华为推送设备忽略该 token,避免覆盖设备真正的厂商 token。
WxpLogUtils.i(TAG, "华为推送-但是是[" + Build.MANUFACTURER + "]设备,忽略华为token=" + s)
}
}
-}
\ No newline at end of file
+}
diff --git a/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/vivo/VIVOPushUtils.kt b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/vivo/VIVOPushUtils.kt
index 3d41eeff..bd17c154 100644
--- a/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/vivo/VIVOPushUtils.kt
+++ b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/vivo/VIVOPushUtils.kt
@@ -40,6 +40,7 @@ object VIVOPushUtils {
}
} catch (e: Throwable) {
WxpLogUtils.w(TAG, "VIVO推送初始化错误", e)
+ PushManager.onGetPushTokenFail(DevicePlatform.Android_VIVO)
}
}
@@ -63,4 +64,4 @@ object VIVOPushUtils {
})
}
-}
\ No newline at end of file
+}
diff --git a/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/ws/WxpNotificationManager.kt b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/ws/WxpNotificationManager.kt
index a8ea407b..c32efa22 100644
--- a/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/ws/WxpNotificationManager.kt
+++ b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/ws/WxpNotificationManager.kt
@@ -8,13 +8,13 @@ import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
-import android.provider.Settings
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.smjcco.wxpusher.R
import com.smjcco.wxpusher.base.common.ApplicationUtils
import com.smjcco.wxpusher.page.WebViewActivity
import com.smjcco.wxpusher.page.main.WxpMainActivity
+import com.smjcco.wxpusher.push.ws.alert.WsAlertPlayer
import com.smjcco.wxpusher.push.ws.connect.PushMsgDeviceMsg
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
@@ -23,7 +23,22 @@ import java.util.concurrent.atomic.AtomicInteger
object WxpNotificationManager {
private var messageId = AtomicInteger(10000)
- const val WxPusherSystemChannelId = "WxPusherSystemChannelId"
+
+ /**
+ * 旧的业务消息渠道,声音和震动写死在渠道属性里,用户改不了。
+ * 只保留 id 用于升级时删除,不要再往这个渠道发通知。
+ */
+ private const val LegacyBizChannelId = "WxPusherSystemChannelId"
+
+ /**
+ * 业务消息渠道。
+ *
+ * 渠道本身是静音无震动的,提醒全部交给 [WsAlertPlayer] 按用户设置执行——
+ * NotificationChannel 创建之后声音和震动就无法用代码修改,想让用户自定义只能这么做。
+ * 换了新 id 是因为删掉再用同名 id 重建,系统会把旧属性一起恢复回来。
+ */
+ const val WxPusherWsMessageChannelId = "WxPusherWsMessageChannelIdV2"
+
private var sysNotificationManager: NotificationManager? = null
private var init = AtomicBoolean(false)
@@ -33,18 +48,20 @@ object WxpNotificationManager {
}
init.set(true)
initNotificationChannelGroup()
- createNotificationChannel(
- WxPusherSystemChannelId,
+ createBizNotificationChannel(
+ WxPusherWsMessageChannelId,
ChannelGroup.WxPusherSystem,
- "WxPusher系统公告和通知", "WxPusher的公告、升级通知、异常提醒、订阅通知等",
+ "WxPusher自建链接通知", "通过WxPusher自建链接发送订阅通知提醒,提醒方式在App内设置",
)
+ // 旧渠道自带声音和震动,留着会和 App 自己的提醒双响。
+ runCatching { getSysNotificationManager().deleteNotificationChannel(LegacyBizChannelId) }
}
/**
* 发送业务消息推送通知
*/
fun sendBizMessageNotification(message: PushMsgDeviceMsg) {
- val channel: String = WxPusherSystemChannelId
+ val channel: String = WxPusherWsMessageChannelId
// 创建Intent,用于在点击通知时启动Activity
val intent = Intent(ApplicationUtils.getApplication(), WxpMainActivity::class.java)
intent.putExtra(
@@ -68,13 +85,16 @@ object WxpNotificationManager {
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.setGroup("bizMsg")
- .setDefaults(Notification.DEFAULT_ALL)
+ // 不设 DEFAULT_ALL,也不能用 setSilent:前者会重新引入渠道之外的声音和震动,
+ // 后者会连悬浮通知一起压掉。悬浮通知只取决于渠道的 importance。
.setPriority(NotificationManager.IMPORTANCE_HIGH)
//显示更多文本,长按可以展开
.setStyle(NotificationCompat.BigTextStyle().bigText(message.summary))
.build()
sendNotification(notification)
+ // 渠道是静音的,震动、闪光灯、响铃由 App 按用户设置执行。
+ WsAlertPlayer.alert()
}
private fun sendNotification(notification: Notification) {
@@ -83,9 +103,12 @@ object WxpNotificationManager {
}
/**
- * 创建业务消息的通知渠道
+ * 创建业务消息的通知渠道。
+ *
+ * 刻意不设声音和震动:这两项一旦写进渠道就再也改不了,而提醒方式是要让用户自定义的,
+ * 所以统一交给 [WsAlertPlayer]。importance 仍然是 HIGH,悬浮通知不受影响。
*/
- private fun createNotificationChannel(
+ private fun createBizNotificationChannel(
id: String,
group: ChannelGroup,
name: String,
@@ -94,13 +117,9 @@ object WxpNotificationManager {
val channel = NotificationChannel(id, name, NotificationManager.IMPORTANCE_HIGH)
channel.description = des
channel.enableLights(true)
- channel.enableVibration(true)
- channel.vibrationPattern = longArrayOf(100, 200, 300, 400, 500, 400, 300, 200, 400)
+ channel.enableVibration(false)
channel.setShowBadge(true)
- channel.setSound(
- Settings.System.DEFAULT_NOTIFICATION_URI,
- Notification.AUDIO_ATTRIBUTES_DEFAULT
- )
+ channel.setSound(null, null)
channel.group = group.id
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
channel.setAllowBubbles(true)
diff --git a/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/ws/alert/WsAlertPlayer.kt b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/ws/alert/WsAlertPlayer.kt
new file mode 100644
index 00000000..ca181ab1
--- /dev/null
+++ b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/ws/alert/WsAlertPlayer.kt
@@ -0,0 +1,349 @@
+package com.smjcco.wxpusher.push.ws.alert
+
+import android.content.Context
+import android.hardware.camera2.CameraCharacteristics
+import android.hardware.camera2.CameraManager
+import android.media.AudioAttributes
+import android.media.MediaPlayer
+import android.media.RingtoneManager
+import android.net.Uri
+import android.os.Build
+import android.os.SystemClock
+import android.os.VibrationEffect
+import android.os.Vibrator
+import android.os.VibratorManager
+import androidx.core.os.HandlerCompat
+import com.smjcco.wxpusher.base.common.ApplicationUtils
+import com.smjcco.wxpusher.base.common.WxpLogUtils
+import com.smjcco.wxpusher.utils.ThreadUtils
+
+/**
+ * WS 消息的提醒执行器:震动、闪光灯、响铃。
+ *
+ * WS 通道下 App 进程一定活着([com.smjcco.wxpusher.push.ws.keepalive.KeepWsAliveService]
+ * 是持有唤醒锁的前台服务),所以提醒可以完全由 App 自己执行,不受「通知渠道创建后声音和
+ * 震动就改不了」这条系统限制约束。对应地,业务通知渠道必须是静音的,否则会双响。
+ *
+ * 全部状态只在主线程访问:WS 消息回调跑在 OkHttp 线程,所以入口一律先切主线程。定时任务
+ * 带同一个 token 投递,[stopAll] 一次性撤销。
+ */
+object WsAlertPlayer {
+ private const val TAG = "WsAlertPlayer"
+
+ /** 一遍提示音放完后隔多久重播。留出间隔才有「节拍」,连播会糊成一片噪音。 */
+ private const val TONE_GAP_MS = 600L
+
+ /** 闪光灯的亮灭切换周期。 */
+ private const val TORCH_INTERVAL_MS = 250L
+
+ /** 震动的一个循环:震 400ms、停 300ms。 */
+ private val VIBRATE_PATTERN = longArrayOf(0, 400, 300)
+
+ /** 短于这个时长就不做音量渐强,直接全音量,否则一次性的提醒会显得没底气。 */
+ private const val VOLUME_RAMP_MIN_SECONDS = 5
+ private const val VOLUME_RAMP_START = 0.55f
+ private const val VOLUME_RAMP_STEP_MS = 500L
+
+ // Handler 撤销用的 token,本模块投递的所有任务都带上它。
+ private val token = Any()
+
+ private var player: MediaPlayer? = null
+ private var torchCameraId: String? = null
+
+ // 音量渐强是「一轮提醒」级别的,不是「一遍提示音」级别的:重播只是接着爬,
+ // 不能每遍都从头小声起。rampToMs 为 0 表示本轮不渐强。
+ private var rampStartedAt = 0L
+ private var rampToMs = 0L
+
+ /**
+ * 收到 WS 消息时调用。
+ *
+ * 会先结束上一条消息还没放完的提醒,再从头开始新的一轮,时长不累加——连续来消息时
+ * 用户听到的永远是「最新一条的完整提醒」,而不是几路提醒叠在一起。
+ */
+ fun alert() {
+ if (!WsAlertStore.hasAnyAlert()) {
+ return
+ }
+ runOnMain {
+ start(
+ durationSeconds = WsAlertStore.getDurationSeconds(),
+ vibrate = WsAlertStore.isVibrateEnabled(),
+ torch = WsAlertStore.isTorchEnabled(),
+ soundKey = if (WsAlertStore.isSoundEnabled()) WsAlertStore.getSoundKey() else null,
+ )
+ }
+ }
+
+ /** 设置页「试一试」:按当前配置完整跑一遍。 */
+ fun alertOnce() {
+ alert()
+ }
+
+ /** 设置页选提示音时试听,只放一遍,不受提醒时长影响。 */
+ fun previewTone(soundKey: String) {
+ runOnMain {
+ stopAllOnMain()
+ playTone(soundKey, endAt = 0L)
+ }
+ }
+
+ /**
+ * 结束所有进行中的提醒。
+ *
+ * 主动停止、页面退出、用户打开 App 都会调用,必须幂等,且任何一步失败都不能影响
+ * 后面的清理——尤其是手电筒,忘了关会一直亮着。
+ */
+ fun stopAll() {
+ runOnMain { stopAllOnMain() }
+ }
+
+ private fun stopAllOnMain() {
+ ThreadUtils.getMainThreadHandler().removeCallbacksAndMessages(token)
+ rampToMs = 0L
+ stopVibrate()
+ stopTone()
+ turnTorchOff()
+ }
+
+ private fun start(durationSeconds: Int, vibrate: Boolean, torch: Boolean, soundKey: String?) {
+ stopAllOnMain()
+ // 时长约定的是「什么时候不再重复」,不是硬切断当前这一遍;否则默认的 1 秒会把
+ // 1.05 秒的提示音拦腰截断。
+ val endAt = SystemClock.elapsedRealtime() + durationSeconds * 1000L
+ if (vibrate) {
+ startVibrate(durationSeconds)
+ }
+ if (torch) {
+ startTorch(durationSeconds)
+ }
+ if (soundKey != null) {
+ if (durationSeconds >= VOLUME_RAMP_MIN_SECONDS) {
+ // 前三分之一升满,避免 60 秒时前 40 秒都很小声
+ rampStartedAt = SystemClock.elapsedRealtime()
+ rampToMs = durationSeconds * 1000L / 3
+ rampVolume()
+ }
+ playTone(soundKey, endAt)
+ }
+ }
+
+ // region 震动
+
+ private fun startVibrate(durationSeconds: Int) {
+ val vibrator = getVibrator() ?: return
+ runCatching {
+ val effect = VibrationEffect.createWaveform(VIBRATE_PATTERN, /* repeat = */ 0)
+ vibrator.vibrate(effect, buildAudioAttributes())
+ }.onFailure { WxpLogUtils.w(tag = TAG, message = "启动震动失败", throwable = it) }
+ postDelayed(durationSeconds * 1000L) { stopVibrate() }
+ }
+
+ private fun stopVibrate() {
+ runCatching { getVibrator()?.cancel() }
+ .onFailure { WxpLogUtils.w(tag = TAG, message = "取消震动失败", throwable = it) }
+ }
+
+ private fun getVibrator(): Vibrator? {
+ val application = ApplicationUtils.getApplication()
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ val manager = application
+ .getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager
+ manager?.defaultVibrator
+ } else {
+ application.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
+ }
+ }
+
+ // endregion
+
+ // region 响铃
+
+ /**
+ * @param endAt 到这个时刻之后不再重播;传 0 表示只放一遍(试听)
+ */
+ private fun playTone(soundKey: String, endAt: Long) {
+ val uri = resolveToneUri(soundKey) ?: return
+ val application = ApplicationUtils.getApplication()
+ val newPlayer = MediaPlayer()
+ val volume = currentRampVolume()
+ val started = runCatching {
+ newPlayer.setAudioAttributes(buildAudioAttributes())
+ newPlayer.setDataSource(application, uri)
+ newPlayer.prepare()
+ // 不用 isLooping:无缝循环会把短促的提示音连成一串噪音,听不出节拍。
+ newPlayer.setOnCompletionListener { onToneFinished(endAt, soundKey) }
+ newPlayer.setVolume(volume, volume)
+ newPlayer.start()
+ }.isSuccess
+
+ if (!started) {
+ WxpLogUtils.w(tag = TAG, message = "播放提示音失败,uri=$uri")
+ runCatching { newPlayer.release() }
+ return
+ }
+ player = newPlayer
+ }
+
+ private fun onToneFinished(endAt: Long, soundKey: String) {
+ stopTone()
+ if (SystemClock.elapsedRealtime() >= endAt) {
+ return
+ }
+ postDelayed(TONE_GAP_MS) {
+ if (SystemClock.elapsedRealtime() < endAt) {
+ playTone(soundKey, endAt)
+ }
+ }
+ }
+
+ /**
+ * 音量线性爬升,让长时间提醒不至于一上来就轰人,也不会一直很小声。
+ *
+ * 只跟本轮提醒的起点有关,与当前是第几遍无关,所以整轮只起一条链;重播时由
+ * [playTone] 用 [currentRampVolume] 接上当前音量。
+ */
+ private fun rampVolume() {
+ postDelayed(VOLUME_RAMP_STEP_MS) {
+ val volume = currentRampVolume()
+ player?.let { runCatching { it.setVolume(volume, volume) } }
+ if (volume < 1f) {
+ rampVolume()
+ }
+ }
+ }
+
+ private fun currentRampVolume(): Float {
+ if (rampToMs <= 0L) {
+ return 1f
+ }
+ val progress = (SystemClock.elapsedRealtime() - rampStartedAt).toFloat() / rampToMs
+ return (VOLUME_RAMP_START + (1f - VOLUME_RAMP_START) * progress)
+ .coerceIn(VOLUME_RAMP_START, 1f)
+ }
+
+ private fun stopTone() {
+ val current = player ?: return
+ player = null
+ runCatching {
+ current.setOnCompletionListener(null)
+ current.stop()
+ }
+ runCatching { current.release() }
+ .onFailure { WxpLogUtils.w(tag = TAG, message = "释放播放器失败", throwable = it) }
+ }
+
+ /** 「跟随系统默认」没有随包音频,取系统的默认通知音。 */
+ private fun resolveToneUri(soundKey: String): Uri? {
+ val rawRes = WsAlertTones.rawResOf(soundKey)
+ ?: return RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
+ val packageName = ApplicationUtils.getApplication().packageName
+ return Uri.parse("android.resource://$packageName/$rawRes")
+ }
+
+ // endregion
+
+ // region 闪光灯
+
+ private fun startTorch(durationSeconds: Int) {
+ val cameraId = findTorchCameraId() ?: return
+ torchCameraId = cameraId
+ val endAt = SystemClock.elapsedRealtime() + durationSeconds * 1000L
+ blinkTorch(endAt, on = true)
+ }
+
+ private fun blinkTorch(endAt: Long, on: Boolean) {
+ if (SystemClock.elapsedRealtime() >= endAt) {
+ turnTorchOff()
+ return
+ }
+ if (!setTorchMode(on)) {
+ // 相机被其他应用占用等情况下直接放弃,不用一路重试刷日志;
+ // 但要尽力把灯关掉,不能亮着就不管了
+ turnTorchOff()
+ return
+ }
+ postDelayed(TORCH_INTERVAL_MS) { blinkTorch(endAt, !on) }
+ }
+
+ private fun turnTorchOff() {
+ if (torchCameraId == null) {
+ return
+ }
+ setTorchMode(false)
+ torchCameraId = null
+ }
+
+ private fun setTorchMode(on: Boolean): Boolean {
+ val cameraId = torchCameraId ?: return false
+ return runCatching {
+ getCameraManager().setTorchMode(cameraId, on)
+ true
+ }.getOrElse {
+ // CameraAccessException:相机被占用;IllegalArgumentException:摄像头已不可用
+ WxpLogUtils.w(tag = TAG, message = "切换闪光灯失败", throwable = it)
+ false
+ }
+ }
+
+ /** 优先后置摄像头,找不到就退而求其次用任意一个带闪光灯的。 */
+ private fun findTorchCameraId(): String? = runCatching {
+ val manager = getCameraManager()
+ var fallback: String? = null
+ for (id in manager.cameraIdList) {
+ val characteristics = manager.getCameraCharacteristics(id)
+ val hasFlash = characteristics.get(CameraCharacteristics.FLASH_INFO_AVAILABLE) == true
+ if (!hasFlash) {
+ continue
+ }
+ val facing = characteristics.get(CameraCharacteristics.LENS_FACING)
+ if (facing == CameraCharacteristics.LENS_FACING_BACK) {
+ return@runCatching id
+ }
+ if (fallback == null) {
+ fallback = id
+ }
+ }
+ fallback
+ }.getOrElse {
+ WxpLogUtils.w(tag = TAG, message = "查找闪光灯失败", throwable = it)
+ null
+ }
+
+ private fun getCameraManager(): CameraManager =
+ ApplicationUtils.getApplication()
+ .getSystemService(Context.CAMERA_SERVICE) as CameraManager
+
+ // endregion
+
+ /**
+ * 「静音时也响铃」打开时当成闹钟播,静音和勿扰都拦不住;否则按普通通知播,与系统
+ * 通知的行为保持一致。震动也用同一份属性,否则勿扰下震不出来。
+ */
+ private fun buildAudioAttributes(): AudioAttributes {
+ val usage = if (WsAlertStore.isForceLoud()) {
+ AudioAttributes.USAGE_ALARM
+ } else {
+ AudioAttributes.USAGE_NOTIFICATION
+ }
+ return AudioAttributes.Builder()
+ .setUsage(usage)
+ .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
+ .build()
+ }
+
+ private fun runOnMain(action: () -> Unit) {
+ ThreadUtils.runOnMainThread(action)
+ }
+
+ private fun postDelayed(delayMillis: Long, action: () -> Unit) {
+ // Handler 自带的 postDelayed(Runnable, Object, Long) 是 API 28 才有的,
+ // minSdk 是 26,只能走 HandlerCompat。
+ HandlerCompat.postDelayed(
+ ThreadUtils.getMainThreadHandler(),
+ action,
+ token,
+ delayMillis,
+ )
+ }
+}
diff --git a/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/ws/alert/WsAlertSettings.kt b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/ws/alert/WsAlertSettings.kt
new file mode 100644
index 00000000..2f45b219
--- /dev/null
+++ b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/ws/alert/WsAlertSettings.kt
@@ -0,0 +1,140 @@
+package com.smjcco.wxpusher.push.ws.alert
+
+import androidx.annotation.RawRes
+import com.smjcco.wxpusher.R
+import com.smjcco.wxpusher.base.common.WxpSaveService
+import com.smjcco.wxpusher.page.notificationsound.WxpNotificationSoundOption
+import com.smjcco.wxpusher.page.notificationsound.WxpNotificationSoundOptions
+
+/**
+ * WS 通道收到消息时的提醒方式设置。
+ *
+ * 只对当前设备生效,不同步服务端:WS 的通知是本机构建的,震动和闪光灯本来也无法由
+ * 服务端下发,存本地还能离线读取、不受登录态影响。
+ *
+ * 震动、闪光灯、响铃共用同一个 [getDurationSeconds],避免三个不一致的时长互相打架。
+ */
+object WsAlertStore {
+ private const val KEY_DURATION = "WsAlert_DurationSeconds"
+ private const val KEY_VIBRATE = "WsAlert_VibrateEnabled"
+ private const val KEY_TORCH = "WsAlert_TorchEnabled"
+ private const val KEY_SOUND = "WsAlert_SoundEnabled"
+ private const val KEY_SOUND_KEY = "WsAlert_SoundKey"
+ private const val KEY_FORCE_LOUD = "WsAlert_ForceLoud"
+
+ /** 提醒时长上限,再长就只是折磨用户和耗电了。 */
+ const val DURATION_MAX = 60
+
+ /** 滑块步长。默认值是 1 秒,所以必须是 1 秒粒度。 */
+ const val DURATION_STEP = 1
+
+ /**
+ * 默认响一次就停。
+ *
+ * 这个默认值配合默认开启的震动和「跟随系统默认」提示音,观感与旧版本(通知渠道
+ * 自带铃声 + 震动)基本一致,老用户升级后不会觉得提醒变了。
+ */
+ private const val DURATION_DEFAULT = 1
+
+ /**
+ * 时长为 0 表示不做任何额外提醒,只留一条静默的通知栏消息。
+ */
+ fun getDurationSeconds(): Int =
+ WxpSaveService.get(KEY_DURATION, DURATION_DEFAULT).coerceIn(0, DURATION_MAX)
+
+ fun setDurationSeconds(seconds: Int) {
+ WxpSaveService.set(KEY_DURATION, seconds.coerceIn(0, DURATION_MAX))
+ }
+
+ fun isVibrateEnabled(): Boolean = WxpSaveService.get(KEY_VIBRATE, true)
+
+ fun setVibrateEnabled(enabled: Boolean) {
+ WxpSaveService.set(KEY_VIBRATE, enabled)
+ }
+
+ fun isTorchEnabled(): Boolean = WxpSaveService.get(KEY_TORCH, false)
+
+ fun setTorchEnabled(enabled: Boolean) {
+ WxpSaveService.set(KEY_TORCH, enabled)
+ }
+
+ fun isSoundEnabled(): Boolean = WxpSaveService.get(KEY_SOUND, true)
+
+ fun setSoundEnabled(enabled: Boolean) {
+ WxpSaveService.set(KEY_SOUND, enabled)
+ }
+
+ /** 当前提示音的 key,未知值由 [WsAlertTones.find] 回落到「跟随系统默认」。 */
+ fun getSoundKey(): String =
+ WxpSaveService.get(KEY_SOUND_KEY, WxpNotificationSoundOptions.DEFAULT_KEY)
+
+ fun setSoundKey(key: String) {
+ WxpSaveService.set(KEY_SOUND_KEY, key)
+ }
+
+ /**
+ * 打开后按闹钟音频属性播放,手机静音或勿扰时也会响。默认关闭,跟随系统行为。
+ */
+ fun isForceLoud(): Boolean = WxpSaveService.get(KEY_FORCE_LOUD, false)
+
+ fun setForceLoud(force: Boolean) {
+ WxpSaveService.set(KEY_FORCE_LOUD, force)
+ }
+
+ /** 是否需要 App 自己执行提醒。三个开关全关时等同于时长为 0。 */
+ fun hasAnyAlert(): Boolean =
+ getDurationSeconds() > 0
+ && (isVibrateEnabled() || isTorchEnabled() || isSoundEnabled())
+
+ /** 推送通道设置页入口行展示的一句话摘要。 */
+ fun summary(): String {
+ if (!hasAnyAlert()) {
+ return "仅通知栏提示"
+ }
+ val parts = mutableListOf("${getDurationSeconds()} 秒")
+ if (isVibrateEnabled()) {
+ parts.add("震动")
+ }
+ if (isSoundEnabled()) {
+ parts.add(WsAlertTones.find(getSoundKey()).name)
+ }
+ if (isTorchEnabled()) {
+ parts.add("闪光灯")
+ }
+ return parts.joinToString(" · ")
+ }
+}
+
+/**
+ * Android 可用的提示音清单。
+ *
+ * 名称和描述直接复用 shared 里的 [WxpNotificationSoundOptions],与 iOS 同一份文案。
+ * iOS 的 long3~long20 不在其中:Android 的提醒时长由 [WsAlertStore] 统一控制,靠循环
+ * 重播短音实现,不需要预渲染的长音频。
+ */
+object WsAlertTones {
+ /** 与 shared 清单里的 key 对应,顺序即页面展示顺序。 */
+ private val RAW_BY_KEY: Map = linkedMapOf(
+ WxpNotificationSoundOptions.DEFAULT_KEY to null,
+ "ding" to R.raw.wxp_ding,
+ "bell" to R.raw.wxp_bell,
+ "chime" to R.raw.wxp_chime,
+ "alarm" to R.raw.wxp_alarm,
+ "drop" to R.raw.wxp_drop,
+ )
+
+ val all: List =
+ RAW_BY_KEY.keys.map { WxpNotificationSoundOptions.find(it) }
+
+ /** 未知 key 回落到「跟随系统默认」,与 shared 的行为一致。 */
+ fun find(key: String?): WxpNotificationSoundOption {
+ if (key == null || !RAW_BY_KEY.containsKey(key)) {
+ return all.first()
+ }
+ return WxpNotificationSoundOptions.find(key)
+ }
+
+ /** 返回 res/raw 资源 id;为空表示用系统默认通知音。 */
+ @RawRes
+ fun rawResOf(key: String?): Int? = RAW_BY_KEY[key]
+}
diff --git a/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/ws/connect/WsManager.kt b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/ws/connect/WsManager.kt
index 28a804fc..1066c811 100644
--- a/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/ws/connect/WsManager.kt
+++ b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/ws/connect/WsManager.kt
@@ -7,12 +7,12 @@ import android.net.ConnectivityManager
import android.net.Network
import android.os.Build
import com.smjcco.wxpusher.WxpConfig
-import com.smjcco.wxpusher.base.biz.WxpAppDataService
import com.smjcco.wxpusher.base.common.ApplicationUtils
import com.smjcco.wxpusher.base.common.WxpBaseInfoService
import com.smjcco.wxpusher.base.common.WxpLogUtils
import com.smjcco.wxpusher.base.common.WxpScopeUtils
import com.smjcco.wxpusher.bean.DevicePlatform
+import com.smjcco.wxpusher.push.PushChannelStore
import com.smjcco.wxpusher.push.PushManager
import com.smjcco.wxpusher.push.ws.WxpNotificationManager.sendBizMessageNotification
import com.smjcco.wxpusher.utils.DeviceUtils
@@ -30,7 +30,7 @@ import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
-
+/** WebSocket 连接、重连、消息分发及连接状态通知的统一管理器。 */
object WsManager {
const val TAG = "WsManager"
private val msgListenerMap: MutableMap>> =
@@ -44,10 +44,10 @@ object WsManager {
.readTimeout(10, TimeUnit.SECONDS) // 设置读取超时时间
.build()
- //是否已经链接
+ // 当前连接状态。
private var connectStatus = AtomicReference(WsConnectStatus.NotConnect)
- //不同的重试次数,延迟不一样
+ // 连续失败次数越多,重连等待时间越长。
private val RETRY_SECONDS = listOf(5, 10, 15, 20, 30, 45, 60, 120)
//持续重试次数
@@ -57,12 +57,19 @@ object WsManager {
private var init = AtomicBoolean(false)
- //拒绝链接
+ // 服务端要求停止连接或通道被关闭时阻止继续连接。
private var disableConnect = false
+ // 当前是否允许建立和重连 WS,支持用户在同一进程内反复切换通道。
+ @Volatile
+ private var enabled = false
+
private var alarmManager: AlarmManager? = null
+ private val reconnectRunnable = Runnable { tryConnect() }
+ private val reconnectAlarmListener = AlarmManager.OnAlarmListener { tryConnect() }
+ /** 只初始化一次消息监听和网络监听,不代表当前一定启用 WS。 */
fun init() {
if (init.get()) {
return
@@ -70,12 +77,20 @@ object WsManager {
alarmManager =
ApplicationUtils.getApplication().getSystemService(ALARM_SERVICE) as AlarmManager
init.set(true)
- //初始化监听器
+ // 初始化消息监听器。
initMsgListener()
- //监听网络变化,尝试建立连接
+ // 监听网络变化,网络恢复后按需重新连接。
listenNetworkAvailable()
}
+ /** 启用 WS 通道并立即尝试连接。 */
+ fun start() {
+ enabled = true
+ disableConnect = false
+ init()
+ tryConnect()
+ }
+
/**
* 监听网络可用的时候,重新建立连接
* 避免用户关闭网络后,连接断开,不能及时建立连接
@@ -118,8 +133,9 @@ object WsManager {
sb.append("/ws?")
sb.append("version=${WxpBaseInfoService.getAppVersionName()}")
sb.append("&")
- sb.append("platform=${DeviceUtils.getPlatform().getPlatform()}")
- val pushToken = WxpAppDataService.getPushToken()
+ sb.append("platform=${DevicePlatform.Android.getPlatform()}")
+ // WS token 与厂商 token 分开保存,避免切换通道时覆盖彼此。
+ val pushToken = PushChannelStore.getWsToken()
if (!pushToken.isNullOrEmpty() && pushToken.startsWith("PT_")) {
sb.append("&")
sb.append("pushToken=${pushToken}")
@@ -132,6 +148,10 @@ object WsManager {
*/
fun tryConnect() {
synchronized(this) {
+ if (!enabled) {
+ WxpLogUtils.d(TAG, "connect: WS通道未启用")
+ return
+ }
if (connectStatus.get() == WsConnectStatus.Connected) {
// 连接状态不打印日志,否则日志太多了
WxpLogUtils.d(TAG, "connect: 已经链接,不重建连接")
@@ -150,7 +170,7 @@ object WsManager {
return
}
if (disableConnect) {
- WxpLogUtils.i(TAG, "connect:客户端版本低,不进行链接")
+ WxpLogUtils.i(TAG, "connect: WS连接已禁用")
return
}
webSocket?.close(1000, "重新建立连接前,关闭原来的WS连接")
@@ -172,6 +192,9 @@ object WsManager {
* 当连接断开后,延迟一点时间,重新建立连接
*/
private fun tryConnectDelay() {
+ if (!enabled) {
+ return
+ }
val retrySeconds = RETRY_SECONDS.getOrNull(reTryCount) ?: RETRY_SECONDS.last()
WxpLogUtils.d(message = "延迟${retrySeconds}重新尝试WS连接")
val reconnectTime = Calendar.getInstance()
@@ -182,19 +205,20 @@ object WsManager {
AlarmManager.RTC_WAKEUP,
reconnectTime.timeInMillis,
"WS-RECONNECT",
- { tryConnect() },
+ reconnectAlarmListener,
null
)
} else {
WxpLogUtils.d(message = "不能调用alarmManager,通过post delay来重启WS")
- ThreadUtils.runOnMainThread({ tryConnect() }, retrySeconds.toLong())
+ ThreadUtils.getMainThreadHandler().removeCallbacks(reconnectRunnable)
+ ThreadUtils.runOnMainThread(reconnectRunnable, retrySeconds * 1000L)
}
} else {
alarmManager?.setExact(
AlarmManager.RTC_WAKEUP,
reconnectTime.timeInMillis,
"WS-RECONNECT",
- { tryConnect() },
+ reconnectAlarmListener,
null
)
}
@@ -218,40 +242,46 @@ object WsManager {
}
private fun setConnectStatus(status: WsConnectStatus) {
- notifyConnectedChanged(status)
- connectStatus.set(status)
+ // 状态未变化时不重复通知页面,减少无效刷新。
+ if (connectStatus.getAndSet(status) != status) {
+ notifyConnectStatusChanged(status)
+ }
}
fun getConnectStatus(): WsConnectStatus = connectStatus.get()
- /**
- * 关闭链接
- */
+ /** 兼容原有调用入口,语义等同于完全停止 WS 通道。 */
fun disconnect() {
- WxpLogUtils.i(TAG, "disconnect() called,主动断开ws链接")
- disableConnect = true
- webSocket?.close(1000, null)
+ stop()
}
/**
- * 通知链接变化
+ * 停止当前连接,并取消已经安排的所有重连任务。
+ * 切换到厂商通道后必须调用,避免旧 WS 通道继续耗电或接收消息。
*/
- private fun notifyConnectedChanged(status: WsConnectStatus) {
- //链接状态变成已经链接或者未链接,才进行通知
- if (connectStatus.get() != status &&
- (status == WsConnectStatus.Connected || status == WsConnectStatus.NotConnect)
- ) {
- WxpScopeUtils.getMainScope().launch {
- connectListenerList.forEach {
- it.onChanged(status == WsConnectStatus.Connected)
- }
+ fun stop() {
+ WxpLogUtils.i(TAG, "stop() called,停止WS通道")
+ enabled = false
+ disableConnect = true
+ ThreadUtils.getMainThreadHandler().removeCallbacks(reconnectRunnable)
+ alarmManager?.cancel(reconnectAlarmListener)
+ val socket = webSocket
+ webSocket = null
+ socket?.close(1000, "切换推送通道")
+ setConnectStatus(WsConnectStatus.NotConnect)
+ }
+
+
+ /** 在主线程通知页面完整的 WS 连接状态。 */
+ private fun notifyConnectStatusChanged(status: WsConnectStatus) {
+ WxpScopeUtils.getMainScope().launch {
+ connectListenerList.toList().forEach {
+ it.onChanged(status)
}
}
}
- /**
- * 网络链接状态
- */
+ /** WebSocket 网络连接状态。 */
enum class WsConnectStatus(val code: Int, val des: String) {
NotConnect(1, "无链接"),
Connecting(2, "链接中"),
@@ -260,25 +290,37 @@ object WsManager {
}
interface IWsConnectChangedListener {
- fun onChanged(connectStatus: Boolean)
+ fun onChanged(connectStatus: WsConnectStatus)
}
class WsListener() : WebSocketListener() {
private val TAG = "WsManager"
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
+ // 热切换时旧连接可能晚到回调,必须忽略,避免覆盖新连接状态。
+ if (WsManager.webSocket !== webSocket) {
+ return
+ }
+ WsManager.webSocket = null
WxpLogUtils.i(TAG, "onClosed: 链接关闭,code=${code},reason=${reason}")
setConnectStatus(WsConnectStatus.NotConnect)
tryConnectDelay()
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
+ if (WsManager.webSocket !== webSocket) {
+ return
+ }
WxpLogUtils.i(TAG, "onClosing: code=${code},reason=${reason}")
setConnectStatus(WsConnectStatus.NotConnect)
tryConnectDelay()
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
+ if (WsManager.webSocket !== webSocket) {
+ return
+ }
+ WsManager.webSocket = null
WxpLogUtils.i(TAG, "onFailure: error=${t.message}")
t.printStackTrace()
setConnectStatus(WsConnectStatus.NotConnect)
@@ -286,6 +328,9 @@ object WsManager {
}
override fun onMessage(webSocket: WebSocket, text: String) {
+ if (!enabled || WsManager.webSocket !== webSocket) {
+ return
+ }
setConnectStatus(WsConnectStatus.Connected)
reTryCount = 0
WxpLogUtils.i(TAG, "onMessage() called with: webSocket = $webSocket, text = $text")
@@ -322,11 +367,18 @@ object WsManager {
}
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
+ if (!enabled || WsManager.webSocket !== webSocket) {
+ return
+ }
WxpLogUtils.i(TAG, "onMessage: 收到二进制数据")
setConnectStatus(WsConnectStatus.Connected)
}
override fun onOpen(webSocket: WebSocket, response: Response) {
+ if (!enabled || WsManager.webSocket !== webSocket) {
+ webSocket.close(1000, "WS通道已关闭")
+ return
+ }
WxpLogUtils.i(TAG, "onOpen: WS链接打开")
setConnectStatus(WsConnectStatus.Connected)
reTryCount = 0
diff --git a/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/ws/keepalive/KeepWsAliveService.kt b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/ws/keepalive/KeepWsAliveService.kt
index 66eab818..98cb35ca 100644
--- a/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/ws/keepalive/KeepWsAliveService.kt
+++ b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/ws/keepalive/KeepWsAliveService.kt
@@ -20,7 +20,7 @@ import com.smjcco.wxpusher.R
import com.smjcco.wxpusher.base.common.ApplicationUtils
import com.smjcco.wxpusher.base.common.WxpLogUtils
import com.smjcco.wxpusher.page.main.WxpMainActivity
-import com.smjcco.wxpusher.page.web.WxpImageSaveHelper
+import com.smjcco.wxpusher.push.PushChannelStore
import com.smjcco.wxpusher.push.ws.ChannelGroup
import com.smjcco.wxpusher.push.ws.WxpNotificationManager
import com.smjcco.wxpusher.push.ws.connect.WsManager
@@ -41,12 +41,16 @@ class KeepWsAliveService : Service() {
private var hasStartCheckLoop = false
+ private val loopCheckRunnable = Runnable { tryConnectAndAlarmLoopCheck() }
+ private val loopAlarmListener = AlarmManager.OnAlarmListener { tryConnectAndAlarmLoopCheck() }
+
override fun onBind(intent: Intent): IBinder? {
return null
}
companion object {
- val KeepWsAliveServiceNotificationId = 1
+ const val KeepWsAliveServiceNotificationId = 1
+ const val KeepWsAliveNotificationChannelId = "WxPusherKeepAliveNotificationChannelId"
fun start(context: Context = ApplicationUtils.getApplication()) {
Intent(context, KeepWsAliveService::class.java).also {
@@ -56,10 +60,8 @@ class KeepWsAliveService : Service() {
}
fun stop(context: Context = ApplicationUtils.getApplication()) {
- Intent(context, KeepWsAliveService::class.java).also {
- it.action = Actions.STOP.name
- ContextCompat.startForegroundService(context, it)
- }
+ // 停止服务不能再通过 startForegroundService 发送 STOP,否则服务未运行时会被先拉起。
+ context.stopService(Intent(context, KeepWsAliveService::class.java))
}
}
@@ -67,15 +69,29 @@ class KeepWsAliveService : Service() {
if (intent != null) {
val action = intent.action
when (action) {
- Actions.START.name -> startService()
+ Actions.START.name -> syncServiceWithChannelState()
Actions.STOP.name -> stopService()
- else -> startService() //系统重启的时候, 可能没有action
+ else -> syncServiceWithChannelState()
}
} else {
+ // 系统重建服务时 intent 可能为空,此时以持久化的通道状态为准。
+ syncServiceWithChannelState()
+ }
+ // 只有 WS 仍被选中时才允许系统在服务被杀后重建。
+ return if (PushChannelStore.isWsRequested()) {
+ START_STICKY
+ } else {
+ START_NOT_STICKY
+ }
+ }
+
+ /** 根据协调器持久化的 WS 启用状态启动或停止保活工作。 */
+ private fun syncServiceWithChannelState() {
+ if (PushChannelStore.isWsRequested()) {
startService()
+ } else {
+ stopService()
}
- // by returning this we make sure the service is restarted if the system kills the service
- return START_STICKY
}
override fun onCreate() {
@@ -83,14 +99,15 @@ class KeepWsAliveService : Service() {
WxpLogUtils.i(message = "KeepWsAliveService onCreate")
val notification = createNotification()
startForeground(KeepWsAliveServiceNotificationId, notification)
- if (!hasStartCheckLoop) {
+ if (PushChannelStore.isWsRequested() && !hasStartCheckLoop) {
hasStartCheckLoop = true
- //启动检查循环,但是不执行一次内容
+ // 启动定时检查循环,但首次不重复执行连接操作。
tryConnectAndAlarmLoopCheck(false)
}
}
override fun onDestroy() {
+ cleanupServiceResources()
super.onDestroy()
WxpLogUtils.i(message = "KeepWsAliveService onDestroy")
}
@@ -100,18 +117,13 @@ class KeepWsAliveService : Service() {
* 这里添加一个定时器,让服务在稍后重启
*/
override fun onTaskRemoved(rootIntent: Intent) {
+ if (!PushChannelStore.isWsRequested()) {
+ return
+ }
WxpLogUtils.i(message = "KeepWsAliveService onTaskRemoved-使用定时器重新启动任务")
- val restartServiceIntent = Intent(applicationContext, KeepWsAliveService::class.java).also {
- it.setPackage(packageName)
- };
- val restartServicePendingIntent: PendingIntent =
- PendingIntent.getService(
- this, 1, restartServiceIntent,
- PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE
- );
- applicationContext.getSystemService(ALARM_SERVICE);
+ val restartServicePendingIntent = createRestartServicePendingIntent()
val alarmService: AlarmManager =
- applicationContext.getSystemService(ALARM_SERVICE) as AlarmManager;
+ applicationContext.getSystemService(ALARM_SERVICE) as AlarmManager
alarmService.set(
AlarmManager.ELAPSED_REALTIME,
SystemClock.elapsedRealtime() + 3000,
@@ -122,6 +134,10 @@ class KeepWsAliveService : Service() {
@SuppressLint("WakelockTimeout")
@OptIn(DelicateCoroutinesApi::class)
private fun startService() {
+ if (!PushChannelStore.isWsRequested()) {
+ stopService()
+ return
+ }
if (isServiceStarted) {
//检查前台的通知是否存在,不存在就加回来,避免通知被用户删除了
if (!WxpNotificationManager.hasNotificationById(KeepWsAliveServiceNotificationId)) {
@@ -132,7 +148,7 @@ class KeepWsAliveService : Service() {
}
WxpLogUtils.i(message = "KeepWsAliveService is started")
isServiceStarted = true
- // we need this lock so our service gets not affected by Doze Mode
+ // 获取局部唤醒锁,降低系统休眠模式对 WS 保活服务的影响。
wakeLock =
(getSystemService(POWER_SERVICE) as PowerManager).run {
newWakeLock(
@@ -148,19 +164,53 @@ class KeepWsAliveService : Service() {
private fun stopService() {
WxpLogUtils.i(message = "KeepWsAliveService stopService")
try {
- wakeLock?.let {
- if (it.isHeld) {
- it.release()
- }
- }
- stopForeground(STOP_FOREGROUND_REMOVE)
+ cleanupServiceResources()
stopSelf()
} catch (e: Exception) {
- WxpLogUtils.w(message = "KeepWsAliveService stopService")
+ WxpLogUtils.w(message = "KeepWsAliveService stopService", throwable = e)
}
isServiceStarted = false
}
+ /** 创建任务栏移除后用于重启服务的 PendingIntent。 */
+ private fun createRestartServicePendingIntent(): PendingIntent {
+ val restartServiceIntent = Intent(applicationContext, KeepWsAliveService::class.java).also {
+ it.setPackage(packageName)
+ }
+ return PendingIntent.getService(
+ this,
+ 1,
+ restartServiceIntent,
+ PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ }
+
+ /**
+ * 统一清理前台服务持有的系统资源。
+ *
+ * 方法保持幂等,主动停止和系统销毁都会调用,避免残留 Alarm 或 Handler 再次拉起 WS。
+ */
+ private fun cleanupServiceResources() {
+ releaseWakeLock()
+ ThreadUtils.getMainThreadHandler().removeCallbacks(loopCheckRunnable)
+ val alarmManager = getSystemService(ALARM_SERVICE) as AlarmManager
+ alarmManager.cancel(loopAlarmListener)
+ alarmManager.cancel(createRestartServicePendingIntent())
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ hasStartCheckLoop = false
+ isServiceStarted = false
+ }
+
+ /** 安全释放 WS 保活使用的局部唤醒锁。 */
+ private fun releaseWakeLock() {
+ wakeLock?.let {
+ if (it.isHeld) {
+ it.release()
+ }
+ }
+ wakeLock = null
+ }
+
private fun doWork() {
WsManager.tryConnect()
}
@@ -169,7 +219,7 @@ class KeepWsAliveService : Service() {
//初始化一下通知服务,避免通知分组没有创建
WxpNotificationManager.init()
- val notificationChannelId = "WxPusherKeepAliveNotificationChannelId"
+ val notificationChannelId = KeepWsAliveNotificationChannelId
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
@@ -177,10 +227,10 @@ class KeepWsAliveService : Service() {
if (notificationManager.getNotificationChannel(notificationChannelId) == null) {
val channel = NotificationChannel(
notificationChannelId,
- "WxPusher监听消息通知",
+ "WxPusher保活通知",
NotificationManager.IMPORTANCE_HIGH
).apply {
- description = "用于让WxPusher持续监听消息"
+ description = "让WxPusher持续在后台运行,避免遗漏消息"
enableLights(true)
lightColor = Color.GREEN
enableVibration(true)
@@ -219,10 +269,14 @@ class KeepWsAliveService : Service() {
/**
- * 使用系统闹钟,5分钟检查一次连接,来做兜底。
+ * 使用系统非精确闹钟每 5 分钟检查一次连接,作为系统回收或网络波动后的兜底。
+ * 通道已切回厂商推送时不再安排下一轮任务。
*/
private fun tryConnectAndAlarmLoopCheck(doWork: Boolean = true) {
- WxpLogUtils.d(message = "tryConnectAndAlarmLoopCheck,系统闹钟定时兜底")
+ if (!PushChannelStore.isWsRequested()) {
+ return
+ }
+ WxpLogUtils.d(message = "tryConnectAndAlarmLoopCheck,系统非精确闹钟定时兜底")
val application = ApplicationUtils.getApplication()
if (doWork) {
WsManager.tryConnect()
@@ -234,27 +288,25 @@ class KeepWsAliveService : Service() {
val alarmManager = application.getSystemService(ALARM_SERVICE) as AlarmManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (alarmManager.canScheduleExactAlarms()) {
- alarmManager.setExact(
+ alarmManager.set(
AlarmManager.RTC_WAKEUP,
reconnectTime.timeInMillis,
"WS-tryAlarmLoopCheck",
- { tryConnectAndAlarmLoopCheck() },
- null
+ loopAlarmListener,
+ null,
)
} else {
- WxpLogUtils.d(message = "tryAlarmLoopCheck,不能调用alarmManager,通过post delay来检查")
- ThreadUtils.runOnMainThread(
- { tryConnectAndAlarmLoopCheck() },
- delayTime * 60 * 1000L
- )
+ WxpLogUtils.d(message = "不能调用alarmManager,通过post delay来检查")
+ ThreadUtils.getMainThreadHandler().removeCallbacks(loopCheckRunnable)
+ ThreadUtils.runOnMainThread(loopCheckRunnable, delayTime * 60 * 1000L)
}
} else {
- alarmManager.setExact(
+ alarmManager.set(
AlarmManager.RTC_WAKEUP,
reconnectTime.timeInMillis,
"WS-tryAlarmLoopCheck",
- { tryConnectAndAlarmLoopCheck() },
- null
+ loopAlarmListener,
+ null,
)
}
}
diff --git a/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/ws/keepalive/KeepWsAliveServiceStarter.kt b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/ws/keepalive/KeepWsAliveServiceStarter.kt
index f65b3de7..ea8d5158 100644
--- a/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/ws/keepalive/KeepWsAliveServiceStarter.kt
+++ b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/push/ws/keepalive/KeepWsAliveServiceStarter.kt
@@ -9,23 +9,20 @@ import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.smjcco.wxpusher.base.common.ApplicationUtils
import com.smjcco.wxpusher.base.common.WxpLogUtils
-import com.smjcco.wxpusher.bean.DevicePlatform
+import com.smjcco.wxpusher.push.PushChannelStore
import com.smjcco.wxpusher.push.ws.WxpNotificationManager
-import com.smjcco.wxpusher.utils.DeviceUtils
import com.smjcco.wxpusher.utils.PermissionUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
- * This class only manages the SubscriberService, i.e. it starts or stops it.
- * It's used in multiple activities.
+ * WS 前台保活服务的启动器。
*
- * We are starting the service via a worker and not directly because since Android 7
- * (but officially since Lollipop!), any process called by a BroadcastReceiver
- * (only manifest-declared receiver) is run at low priority and hence eventually
- * killed by Android.
+ * 通过 WorkManager 间接启动服务,避免应用从广播或后台场景直接启动前台服务时
+ * 受到系统后台启动限制。停止 WS 时同时取消未执行的启动任务,防止服务被再次拉起。
*/
class KeepWsAliveServiceStarter(private val context: Context) {
+ /** 提交唯一的保活服务启动任务。 */
fun start() {
WxpLogUtils.d(message = "通过ServiceStartWorker 拉活 KeepWsAliveService")
val workManager = WorkManager.getInstance(context)
@@ -37,7 +34,9 @@ class KeepWsAliveServiceStarter(private val context: Context) {
)
}
+ /** 取消待执行任务并通知前台服务停止。 */
fun stop() {
+ WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME_ONCE)
KeepWsAliveService.stop()
}
@@ -49,6 +48,10 @@ class KeepWsAliveServiceStarter(private val context: Context) {
WxpLogUtils.i(message = "ServiceStartWorker: Failed, no application found (work ID: ${id})")
return Result.failure()
}
+ if (!PushChannelStore.isWsRequested()) {
+ // 任务执行前用户可能已经切回厂商通道,此时不再启动服务。
+ return Result.success()
+ }
withContext(Dispatchers.IO) {
WxpLogUtils.d(message = "ServiceStartWorker call KeepWsAliveService.start() (work ID: ${id})")
KeepWsAliveService.start()
@@ -61,19 +64,18 @@ class KeepWsAliveServiceStarter(private val context: Context) {
const val WORK_NAME_ONCE = "KeepWsAliveServiceStarter"
fun start(context: Context) {
- //如果通知还存在 ,那就说明前台服务应该还在 ,不用再启动一次,主要是为了省电
+ if (!PushChannelStore.isWsRequested()) {
+ return
+ }
+ // 通知仍存在说明前台服务大概率仍在运行,无需重复提交任务。
if (WxpNotificationManager.hasNotificationById(KeepWsAliveService.KeepWsAliveServiceNotificationId)) {
return
}
- //只有走自建通道,并且打开通知权限,才开启WS保活
- if (DeviceUtils.getPlatform() == DevicePlatform.Android) {
- val currentActivity = ApplicationUtils.getCurrentActivity()
- if (currentActivity == null
- || PermissionUtils.hasNotificationPermission(currentActivity)
- ) {
- val manager = KeepWsAliveServiceStarter(context)
- manager.start()
- }
+ // 只有 WS 被协调器启用且具备通知权限时,才开启前台保活服务。
+ val currentActivity = ApplicationUtils.getCurrentActivity()
+ if (currentActivity == null || PermissionUtils.hasNotificationPermission(currentActivity)) {
+ val manager = KeepWsAliveServiceStarter(context)
+ manager.start()
}
}
}
diff --git a/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/utils/AppMarketNavigator.kt b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/utils/AppMarketNavigator.kt
index 3a31a3ae..4d973a2e 100644
--- a/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/utils/AppMarketNavigator.kt
+++ b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/utils/AppMarketNavigator.kt
@@ -8,6 +8,7 @@ import com.smjcco.wxpusher.base.common.ApplicationUtils
import com.smjcco.wxpusher.base.common.WxpLogUtils
import com.smjcco.wxpusher.bean.DevicePlatform
import com.smjcco.wxpusher.biz.version.WxpAppMarketNavigator
+import com.smjcco.wxpusher.push.PushPlatformResolver
import com.tencent.upgrade.core.UpgradeManager
import com.tencent.upgrade.core.UpgradeReqCallbackForUserManualCheck
@@ -26,8 +27,11 @@ object AppMarketNavigator : WxpAppMarketNavigator {
override fun willShowInternalDialog(downgradeToTbs: Boolean): Boolean {
// 服务端下发降级 或 未识别厂商 → 走 TBS,TBS 自己会弹升级窗
- if (downgradeToTbs) return true
- return vendorMarketPkg(DeviceUtils.getPlatform()) == null
+ if (downgradeToTbs) {
+ return true
+ }
+ // 应用市场只取决于设备厂商,不能随用户选择的推送通道变化。
+ return vendorMarketPkg(PushPlatformResolver.detectVendorPushPlatform()) == null
}
override fun jumpToMarket(downloadUrl: String, downgradeToTbs: Boolean) {
@@ -39,7 +43,8 @@ object AppMarketNavigator : WxpAppMarketNavigator {
return
}
- val platform = DeviceUtils.getPlatform()
+ // 即使当前使用 WS,也应继续打开当前手机对应的厂商应用市场。
+ val platform = PushPlatformResolver.detectVendorPushPlatform()
val marketPkg = vendorMarketPkg(platform)
if (marketPkg != null) {
if (tryOpenVendorMarket(ctx, marketPkg)) {
diff --git a/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/utils/DeviceUtils.kt b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/utils/DeviceUtils.kt
index 28eccb6d..3d312cfb 100644
--- a/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/utils/DeviceUtils.kt
+++ b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/utils/DeviceUtils.kt
@@ -1,5 +1,6 @@
package com.smjcco.wxpusher.utils
+import android.app.ActivityManager
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
@@ -8,112 +9,10 @@ import android.os.PowerManager
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
-import com.heytap.msp.push.HeytapPushManager
-import com.hihonor.push.sdk.HonorPushClient
-import com.huawei.hms.api.HuaweiApiAvailability
-import com.meizu.cloud.pushsdk.PushManager
import com.smjcco.wxpusher.base.common.ApplicationUtils
-import com.smjcco.wxpusher.bean.DevicePlatform
-import com.smjcco.wxpusher.config.ConfigManager
-import com.vivo.push.PushClient
object DeviceUtils {
- //运行的时候,如果有降级,就使用这个新的设备类型
- var runtimePlatform: DevicePlatform? = null
-
- //是否是小米设备
- fun isMIUI(): Boolean {
- return "Xiaomi".equals(Build.MANUFACTURER, true)
- }
-
- /**
- * 是否支持华为推送
- * 验证HMS Core(APK)在设备上是否成功安装和集成,检查已经安装的HMS Core(APK)版本号是否为client所需要的版本号或比需要的更新。该类为抽象方法,需要子类实现。
- * https://developer.huawei.com/consumer/cn/doc/hmscore-common-References/huaweiapiavailability-0000001050121134#section9492524178
- */
- fun isHuaweiMobileServicesAvailable(): Boolean {
- return 0 == HuaweiApiAvailability.getInstance()
- .isHuaweiMobileServicesAvailable(ApplicationUtils.getApplication())
- }
-
- /**
- * 是否支持华为推送
- * 华为或者荣耀的制造商,并且HCM可用
- */
- fun isHuawei(): Boolean {
- //因为安装了HCM就会识别成华为,所以判断一下制造商
- return (Build.MANUFACTURER.equals("huawei", true)
- || Build.MANUFACTURER.equals("HONOR", true))
- && isHuaweiMobileServicesAvailable()
- }
-
- /**
- * 是否是荣耀设备
- */
- fun isHonorDevice(): Boolean {
- return Build.MANUFACTURER.equals("HONOR", true)
- }
-
- /**
- * 国内Magic UI 4.0及以上 支持荣耀推送
- */
- fun isMagicOs(): Boolean {
- if (!isHonorDevice()) {
- // 非荣耀设备,暂不支持
- return false
- }
-
- // Android Q版本对应MagicUI 4.0
- if (Build.VERSION.SDK_INT > 29) {
- return true
- }
- // Android Q以下版本返回-1
- return false
- }
-
- fun isHonorPush(): Boolean {
- val supportPush =
- HonorPushClient.getInstance().checkSupportHonorPush(ApplicationUtils.getApplication())
- return supportPush
- }
-
- fun isVivo(): Boolean {
- return PushClient.getInstance(ApplicationUtils.getApplication()).isSupport
- }
-
- fun isOppo(): Boolean {
- return HeytapPushManager.isSupportPush(ApplicationUtils.getApplication())
- }
-
- fun getPlatform(): DevicePlatform {
- //如果运行过程中有降级,比如注册华为推送失败,最后走了ws,就用降级后的设备类型
- if (runtimePlatform != null) {
- return runtimePlatform!!
- }
- if (isMIUI() && ConfigManager.getCurrentConfig().xiaomiPush) {
- return DevicePlatform.Android_XIAOMI
- } else if (isVivo() && ConfigManager.getCurrentConfig().vivoPush) {
- return DevicePlatform.Android_VIVO
- } else if (isOppo() && ConfigManager.getCurrentConfig().oppoPush) {
- return DevicePlatform.Android_OPPO
- } else if (isHonorPush() && ConfigManager.getCurrentConfig().honorPush) {
- return DevicePlatform.Android_HONOR
- } else if (isHuawei() && ConfigManager.getCurrentConfig().huaweiPush) {
- return DevicePlatform.Android_HUAWEI
- } else if (isHuaweiMobileServicesAvailable() && ConfigManager.getCurrentConfig().huaweiPushJustHcm) {
- //华为需要放在最后面,因为安装了HCM就会识别成华为,后面需要处理一下
- return DevicePlatform.Android_HUAWEI
- } else if (PushManager.isBrandMeizu() && ConfigManager.getCurrentConfig().meizuPush) {
- return DevicePlatform.Android_MEIZU
- }
- return DevicePlatform.Android
- }
-
- fun setPlatform(platform: DevicePlatform) {
- runtimePlatform = platform
- }
-
/**
* 调用设备振动
*/
@@ -153,6 +52,21 @@ object DeviceUtils {
return powerManager.isIgnoringBatteryOptimizations(appName)
}
+ /** 用户是否在系统电池设置中明确限制了本应用后台运行。 */
+ fun isBackgroundRestricted(): Boolean {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
+ return false
+ }
+ val context = ApplicationUtils.getApplication()
+ val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
+ return activityManager.isBackgroundRestricted
+ }
+
+ /** WS 长连接是否同时避开了系统后台限制和电池优化。 */
+ fun canRunInBackgroundWithoutBatteryRestrictions(): Boolean {
+ return !isBackgroundRestricted() && isIgnoringBatteryOptimizations()
+ }
+
/**
* 检查设备是否连接到网络
*
@@ -171,4 +85,4 @@ object DeviceUtils {
)
}
-}
\ No newline at end of file
+}
diff --git a/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/utils/WxpJumpPageUtils.kt b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/utils/WxpJumpPageUtils.kt
index af5394ab..907fd3d6 100644
--- a/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/utils/WxpJumpPageUtils.kt
+++ b/androidApp/src/androidMain/kotlin/com/smjcco/wxpusher/utils/WxpJumpPageUtils.kt
@@ -2,6 +2,8 @@ package com.smjcco.wxpusher.utils
import android.annotation.SuppressLint
import android.app.Activity
+import android.app.NotificationChannel
+import android.content.ComponentName
import android.content.Intent
import android.net.Uri
import android.os.Build
@@ -20,6 +22,9 @@ import com.smjcco.wxpusher.page.login.WxpBindPageData
import com.smjcco.wxpusher.page.login.WxpLoginActivity
import com.smjcco.wxpusher.page.login.WxpPhoneBind
import com.smjcco.wxpusher.page.main.WxpMainActivity
+import com.smjcco.wxpusher.page.pushchannel.PushChannelSettingActivity
+import com.smjcco.wxpusher.page.pushchannel.alert.SystemPushSoundGuideActivity
+import com.smjcco.wxpusher.page.pushchannel.alert.WsAlertSettingActivity
import com.smjcco.wxpusher.page.registerorbind.WxpRegisterOrBindActivity
import com.smjcco.wxpusher.page.scan.WxpScanActivity
import com.smjcco.wxpusher.page.useragreement.WxpUserAgreementActivity
@@ -64,6 +69,104 @@ object WxpJumpPageUtils {
}
}
+ /**
+ * 打开某个已存在通知类别的系统设置页。
+ *
+ * 铃声属于通知类别的用户设置,App 不能直接写入。Android 12 及以后会请求系统
+ * 只显示声音相关设置;厂商系统可以选择忽略该筛选,因此仍需保留完整类别页的兼容性。
+ */
+ fun jumpToSystemNotificationChannelSettings(
+ channelId: String,
+ activity: Activity? = null,
+ soundOnly: Boolean = true,
+ ) {
+ withActivity(activity) { currentActivity ->
+ val intent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply {
+ putExtra(Settings.EXTRA_APP_PACKAGE, currentActivity.packageName)
+ putExtra(Settings.EXTRA_CHANNEL_ID, channelId)
+ if (soundOnly && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ putStringArrayListExtra(
+ Settings.EXTRA_CHANNEL_FILTER_LIST,
+ arrayListOf(NotificationChannel.EDIT_SOUND),
+ )
+ }
+ }
+ try {
+ if (intent.resolveActivity(currentActivity.packageManager) == null) {
+ WxpToastUtils.showToast("无法直达通知类别设置,已打开通知设置")
+ jumpToSystemNotificationSettingPage(currentActivity)
+ return@withActivity
+ }
+ currentActivity.startActivity(intent)
+ } catch (e: Exception) {
+ WxpLogUtils.w(message = "打开通知类别设置失败,channelId=$channelId", throwable = e)
+ WxpToastUtils.showToast("无法直达通知类别设置,已打开通知设置")
+ jumpToSystemNotificationSettingPage(currentActivity)
+ }
+ }
+ }
+
+ /**
+ * 尝试打开各厂商的自启动管理页。
+ * Android 没有统一的自启动设置 Intent,因此这里只负责尝试直达,不推断授权结果;
+ * 返回 false 时由调用页面展示手动路径说明。
+ */
+ fun jumpToSystemAutoStartSettings(activity: Activity? = null): Boolean {
+ var opened = false
+ withActivity(activity) { currentActivity ->
+ val manufacturer = Build.MANUFACTURER.lowercase()
+ val targets = when {
+ manufacturer.contains("xiaomi") -> listOf(
+ "com.miui.securitycenter/com.miui.permcenter.autostart.AutoStartManagementActivity",
+ "com.miui.securitycenter/com.miui.powercenter.PowerSettings",
+ )
+
+ manufacturer.contains("huawei") -> listOf(
+ "com.huawei.systemmanager/com.huawei.systemmanager.startupmgr.ui.StartupNormalAppListActivity",
+ "com.huawei.systemmanager/com.huawei.systemmanager.optimize.process.ProtectActivity",
+ )
+
+ manufacturer.contains("honor") -> listOf(
+ "com.hihonor.systemmanager/com.hihonor.systemmanager.startupmgr.ui.StartupNormalAppListActivity",
+ "com.huawei.systemmanager/com.huawei.systemmanager.startupmgr.ui.StartupNormalAppListActivity",
+ )
+
+ manufacturer.contains("oppo")
+ || manufacturer.contains("oneplus")
+ || manufacturer.contains("realme") -> listOf(
+ "com.oplus.safecenter/com.oplus.safecenter.startupapp.StartupAppListActivity",
+ "com.coloros.safecenter/com.coloros.safecenter.startupapp.StartupAppListActivity",
+ "com.coloros.oppoguardelf/com.coloros.powermanager.fuelgaue.PowerUsageModelActivity",
+ )
+
+ manufacturer.contains("vivo") || manufacturer.contains("iqoo") -> listOf(
+ "com.vivo.permissionmanager/com.vivo.permissionmanager.activity.BgStartUpManagerActivity",
+ "com.iqoo.secure/com.iqoo.secure.ui.phoneoptimize.AddWhiteListActivity",
+ )
+
+ manufacturer.contains("meizu") -> listOf(
+ "com.meizu.safe/com.meizu.safe.permission.SmartBGActivity",
+ "com.meizu.safe/com.meizu.safe.security.SHOW_APPSEC",
+ )
+
+ else -> emptyList()
+ }
+
+ for (target in targets) {
+ val component = ComponentName.unflattenFromString(target) ?: continue
+ val success = runCatching {
+ currentActivity.startActivity(Intent().setComponent(component))
+ }.isSuccess
+ if (success) {
+ opened = true
+ return@withActivity
+ }
+ }
+
+ }
+ return opened
+ }
+
/**
* 跳转到电池优化设置
*/
@@ -201,6 +304,27 @@ object WxpJumpPageUtils {
}
}
+ /** 打开仅作用于当前设备的推送通道设置页。 */
+ fun jumpToPushChannelSetting(activity: Activity? = null) {
+ withActivity(activity) {
+ PushChannelSettingActivity.start(it)
+ }
+ }
+
+ /** 打开 WS 通道收到消息时的提醒方式设置页。 */
+ fun jumpToWsAlertSetting(activity: Activity? = null) {
+ withActivity(activity) {
+ WsAlertSettingActivity.start(it)
+ }
+ }
+
+ /** 打开厂商系统推送的铃声设置引导页。 */
+ fun jumpToSystemPushSoundGuide(activity: Activity? = null) {
+ withActivity(activity) {
+ SystemPushSoundGuideActivity.start(it)
+ }
+ }
+
fun jumpToRemoveAccount(activity: Activity? = null) {
withActivity(activity) {
val intent = Intent(it, WxpRemoveAccountActivity::class.java)
@@ -208,4 +332,4 @@ object WxpJumpPageUtils {
}
}
-}
\ No newline at end of file
+}
diff --git a/androidApp/src/androidMain/res/drawable/ic_recommended.xml b/androidApp/src/androidMain/res/drawable/ic_recommended.xml
new file mode 100644
index 00000000..6d7b0bf6
--- /dev/null
+++ b/androidApp/src/androidMain/res/drawable/ic_recommended.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/androidApp/src/androidMain/res/layout/activity_push_channel_setting.xml b/androidApp/src/androidMain/res/layout/activity_push_channel_setting.xml
new file mode 100644
index 00000000..a81b010a
--- /dev/null
+++ b/androidApp/src/androidMain/res/layout/activity_push_channel_setting.xml
@@ -0,0 +1,545 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/androidApp/src/androidMain/res/layout/activity_system_push_sound_guide.xml b/androidApp/src/androidMain/res/layout/activity_system_push_sound_guide.xml
new file mode 100644
index 00000000..cb3da08a
--- /dev/null
+++ b/androidApp/src/androidMain/res/layout/activity_system_push_sound_guide.xml
@@ -0,0 +1,153 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/androidApp/src/androidMain/res/layout/activity_ws_alert_setting.xml b/androidApp/src/androidMain/res/layout/activity_ws_alert_setting.xml
new file mode 100644
index 00000000..5627faf3
--- /dev/null
+++ b/androidApp/src/androidMain/res/layout/activity_ws_alert_setting.xml
@@ -0,0 +1,235 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/androidApp/src/androidMain/res/layout/item_profile_footer.xml b/androidApp/src/androidMain/res/layout/item_profile_footer.xml
new file mode 100644
index 00000000..ecb1e610
--- /dev/null
+++ b/androidApp/src/androidMain/res/layout/item_profile_footer.xml
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/androidApp/src/androidMain/res/menu/menu_push_channel_setting.xml b/androidApp/src/androidMain/res/menu/menu_push_channel_setting.xml
new file mode 100644
index 00000000..3cba5076
--- /dev/null
+++ b/androidApp/src/androidMain/res/menu/menu_push_channel_setting.xml
@@ -0,0 +1,9 @@
+
+
diff --git a/androidApp/src/androidMain/res/raw/wxp_alarm.wav b/androidApp/src/androidMain/res/raw/wxp_alarm.wav
new file mode 100644
index 00000000..3ec0f227
Binary files /dev/null and b/androidApp/src/androidMain/res/raw/wxp_alarm.wav differ
diff --git a/androidApp/src/androidMain/res/raw/wxp_bell.wav b/androidApp/src/androidMain/res/raw/wxp_bell.wav
new file mode 100644
index 00000000..46629240
Binary files /dev/null and b/androidApp/src/androidMain/res/raw/wxp_bell.wav differ
diff --git a/androidApp/src/androidMain/res/raw/wxp_chime.wav b/androidApp/src/androidMain/res/raw/wxp_chime.wav
new file mode 100644
index 00000000..42b05a0f
Binary files /dev/null and b/androidApp/src/androidMain/res/raw/wxp_chime.wav differ
diff --git a/androidApp/src/androidMain/res/raw/wxp_ding.wav b/androidApp/src/androidMain/res/raw/wxp_ding.wav
new file mode 100644
index 00000000..668d148c
Binary files /dev/null and b/androidApp/src/androidMain/res/raw/wxp_ding.wav differ
diff --git a/androidApp/src/androidMain/res/raw/wxp_drop.wav b/androidApp/src/androidMain/res/raw/wxp_drop.wav
new file mode 100644
index 00000000..7f34a4ad
Binary files /dev/null and b/androidApp/src/androidMain/res/raw/wxp_drop.wav differ
diff --git a/androidApp/src/androidMain/res/values/style.xml b/androidApp/src/androidMain/res/values/style.xml
index d6dbd535..f790bace 100644
--- a/androidApp/src/androidMain/res/values/style.xml
+++ b/androidApp/src/androidMain/res/values/style.xml
@@ -36,4 +36,56 @@
- @anim/slide_up
- @anim/slide_down
-
\ No newline at end of file
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/androidApp/src/androidOffline/kotlin/com/smjcco/wxpusher/page/TestPanelActivity.kt b/androidApp/src/androidOffline/kotlin/com/smjcco/wxpusher/page/TestPanelActivity.kt
index b5ce1306..657a56b2 100644
--- a/androidApp/src/androidOffline/kotlin/com/smjcco/wxpusher/page/TestPanelActivity.kt
+++ b/androidApp/src/androidOffline/kotlin/com/smjcco/wxpusher/page/TestPanelActivity.kt
@@ -2,6 +2,10 @@ package com.smjcco.wxpusher.page
import android.os.Bundle
import android.view.View
+import android.webkit.CookieManager
+import android.webkit.WebStorage
+import android.webkit.WebView
+import android.webkit.WebViewDatabase
import android.widget.Button
import android.widget.EditText
import android.widget.RadioButton
@@ -40,6 +44,7 @@ class TestPanelActivity : ComponentActivity() {
private lateinit var confirmButton: Button
private lateinit var pangleTestToolButton: Button
+ private lateinit var clearWebViewCacheButton: Button
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -66,6 +71,7 @@ class TestPanelActivity : ComponentActivity() {
confirmButton = findViewById(R.id.confirm_button)
pangleTestToolButton = findViewById(R.id.pangle_test_tool_button)
+ clearWebViewCacheButton = findViewById(R.id.clear_webview_cache_button)
}
private fun loadSavedSettings() {
@@ -128,6 +134,38 @@ class TestPanelActivity : ComponentActivity() {
pangleTestToolButton.setOnClickListener {
openPangleTestTool()
}
+
+ clearWebViewCacheButton.setOnClickListener {
+ clearWebViewCache()
+ }
+ }
+
+ /**
+ * 清空 WebView 的所有网站数据(HTTP 缓存/Cookie/localStorage/IndexedDB 等),
+ * 便于调试 app-fe 时强制拉取最新页面,避免命中旧缓存。
+ */
+ private fun clearWebViewCache() {
+ try {
+ // clearCache/clearFormData 作用于整个进程的 WebView 存储,这里用临时实例触发即可
+ val webView = WebView(this)
+ webView.clearCache(true)
+ webView.clearHistory()
+ webView.clearFormData()
+ webView.destroy()
+
+ WebStorage.getInstance().deleteAllData()
+
+ WebViewDatabase.getInstance(this).clearHttpAuthUsernamePassword()
+
+ CookieManager.getInstance().apply {
+ removeAllCookies(null)
+ flush()
+ }
+
+ Toast.makeText(this, "WebView 缓存已清空,重新打开页面即可拉取最新", Toast.LENGTH_SHORT).show()
+ } catch (e: Throwable) {
+ Toast.makeText(this, "清空缓存失败: ${e.message}", Toast.LENGTH_SHORT).show()
+ }
}
/**
diff --git a/androidApp/src/androidOffline/res/layout/test_panel_activity.xml b/androidApp/src/androidOffline/res/layout/test_panel_activity.xml
index d81de174..e9fd213c 100644
--- a/androidApp/src/androidOffline/res/layout/test_panel_activity.xml
+++ b/androidApp/src/androidOffline/res/layout/test_panel_activity.xml
@@ -168,6 +168,14 @@
android:layout_marginTop="24dp"
android:text="穿山甲测量工具" />
+
+
+