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="穿山甲测量工具" /> + +