diff --git a/AndroidManifest.xml b/AndroidManifest.xml new file mode 100644 index 00000000..d87d75f0 --- /dev/null +++ b/AndroidManifest.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/Info.plist b/Info.plist new file mode 100644 index 00000000..0eaba5f2 --- /dev/null +++ b/Info.plist @@ -0,0 +1,8 @@ + + + + + UIBackgroundModes + remote-notification + + diff --git a/nativeResources/android/manifestPlaceholders.json b/nativeResources/android/manifestPlaceholders.json new file mode 100644 index 00000000..87b3327c --- /dev/null +++ b/nativeResources/android/manifestPlaceholders.json @@ -0,0 +1,4 @@ +{ + "JPUSH_APPKEY": "447fcdbce3b2dcac3091c0de", + "JPUSH_CHANNEL": "developer-default" +} diff --git a/package.json b/package.json index b11a9446..519c4d42 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "@dcloudio/uni-automator": "3.0.0-5010520260709002", "@dcloudio/uni-cli-shared": "3.0.0-5010520260709002", "@dcloudio/uni-stacktracey": "3.0.0-5010520260709002", + "@dcloudio/uni-uts-v1": "3.0.0-5010520260709002", "@dcloudio/vite-plugin-uni": "3.0.0-5010520260709002", "@types/node": "^24.9.1", "@uni-helper/uni-app-types": "1.0.0-alpha.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a7716d4c..5d328154 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -111,6 +111,9 @@ importers: '@dcloudio/uni-stacktracey': specifier: 3.0.0-5010520260709002 version: 3.0.0-5010520260709002 + '@dcloudio/uni-uts-v1': + specifier: 3.0.0-5010520260709002 + version: 3.0.0-5010520260709002(rollup@4.14.3) '@dcloudio/vite-plugin-uni': specifier: 3.0.0-5010520260709002 version: 3.0.0-5010520260709002(postcss@8.5.8)(rollup@4.14.3)(vite@5.2.8(@types/node@24.12.0)(sass@1.78.0)(terser@5.46.1))(vue@3.5.30(typescript@4.9.5)) @@ -820,6 +823,9 @@ packages: '@dcloudio/uni-app-vue@3.0.0-5010520260709002': resolution: {integrity: sha512-3Qo327MnpffW+NqidysJi/1hOuyETLV0Iu4X3MaKnNYo8BFl0ihHlO7q9g1X3WkUQyHNnfwLgNGTrQ9kLM6L0w==} + '@dcloudio/uni-app-x@0.7.114': + resolution: {integrity: sha512-Kh6RVYmzx1mW9PSXXZmYJt2kanG7mRwKy6dQ0wH/D0EjHgWHsNbXuRQ6++xwc7KOChkoqnRWr68AiQTP1CcT0Q==} + '@dcloudio/uni-app@3.0.0-5010520260709002': resolution: {integrity: sha512-yMfuGjj52VIgdG1VJ/YpQus3TyVnlc1JYt7rcqGfsSG+pPI7M/kTKSEIsXZ2acEPKH6hHYX/DC3k4wPdwf18Ag==} peerDependencies: @@ -916,6 +922,50 @@ packages: '@dcloudio/uni-ui@1.5.11': resolution: {integrity: sha512-DBtk046ofmeFd82zRI7d89SoEwrAxYzUN3WVPm1DIBkpLPG5F5QDNkHMnZGu2wNrMEmGBjBpUh3vqEY1L3jaMw==} + '@dcloudio/uni-uts-v1@3.0.0-5010520260709002': + resolution: {integrity: sha512-QvSWV1uiIyh3Cus6Bs3diWB5HDAC1QLwZokkHrh9NVMVANRFJNd+6MVjqUI7IvLy2S45/5F96HhWNlyhaivtbw==} + + '@dcloudio/uts-darwin-arm64@3.0.0-5010520260709002': + resolution: {integrity: sha512-Ac8QnGmBJCHgrvopyXtHwruRk3+FB8JkU9amsDcnkdprANGbnjLNf6lpNPIBsG4M3Tu6pPeygOT/DINe9pJ1Cg==} + engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] + + '@dcloudio/uts-darwin-x64@3.0.0-5010520260709002': + resolution: {integrity: sha512-7AVpGOrkiz7lvTO1y4qsZJMUjSmOQR9Ou1u99PdPBuLqcRshhI0jj3Rf9BZmYW6c9Gk+3kXEUJ3PB6CpieFIJg==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] + + '@dcloudio/uts-linux-x64-gnu@3.0.0-5010520260709002': + resolution: {integrity: sha512-3cXg/ALLzHM5GOCi7PnPEB8prXxHg9vmw8rG734HJ37H8BC6jBzXoEMbVVt7y5jyCnYSVdRYN6kx8Vht/Iezdw==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [gnu] + + '@dcloudio/uts-linux-x64-musl@3.0.0-5010520260709002': + resolution: {integrity: sha512-W7ds3jXo9VBLFkOR4Dye5Q4YFh72OOhTbXsFBt98RyzyN0legCryXI29LY3xelHHOuQv5cmNp6hhZQnpz6wTaQ==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@dcloudio/uts-win32-ia32-msvc@3.0.0-5010520260709002': + resolution: {integrity: sha512-3u3DsY6KqDNCw4Zpv15ySXewSaaJDMhLNqvTfzTx+Pq1iKqxsofxUn4hKrh8TXYA6+Qncgondrrp34casBljWQ==} + engines: {node: '>=10'} + cpu: [ia32] + os: [win32] + + '@dcloudio/uts-win32-x64-msvc@3.0.0-5010520260709002': + resolution: {integrity: sha512-BrI3+3yFc+3nCVHnbXxXi0PqDXfpgZgyNLSCM4fzY/TU59TqPeOH84gg/YVu6u1JkHmnsRj2kPoQyJtMU3he+w==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@dcloudio/uts@3.0.0-5010520260709002': + resolution: {integrity: sha512-K4KgP2vwYj3mQybgtlA+bqrfCDEa/Hn0gTJLk4i1vzL/nUdB/MnGauiXu3BUuipOlWROww14YqY3dnCw89iyLw==} + '@dcloudio/vite-plugin-uni@3.0.0-5010520260709002': resolution: {integrity: sha512-HynSNsicIj8KJ9OztI3hVcU7inFw/vzQGauqfzlLV+yBNRhAGqxx9WKN/NZiN6HVpOc8xTyICr0x4XlI7r7f7A==} engines: {node: ^14.18.0 || >=16.0.0} @@ -1796,6 +1846,9 @@ packages: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} + android-versions@1.9.0: + resolution: {integrity: sha512-13O2B6PQMEM4ej9n13ePRQeckrCoKbZrvuzlLvK+9s2QmncpHDbYzZxhgapN32sJNoifN6VAHexLnd/6CYrs7Q==} + ansi-escapes@4.3.2: resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} engines: {node: '>=8'} @@ -2038,6 +2091,10 @@ packages: colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + colors@1.4.0: + resolution: {integrity: sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==} + engines: {node: '>=0.1.90'} + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -2045,6 +2102,9 @@ packages: commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + commondir@1.0.1: + resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} + compare-versions@3.6.0: resolution: {integrity: sha512-W6Af2Iw1z4CB7q4uU4hv646dW9GQuBM+YpC0UvUCWSD8w90SJjp+ujJuXaEMtAXBtSqGfMPuFOVn4/+FlaqfBA==} @@ -2409,6 +2469,10 @@ packages: resolution: {integrity: sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==} engines: {node: '>= 0.8'} + find-cache-dir@3.3.2: + resolution: {integrity: sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==} + engines: {node: '>=8'} + find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} @@ -2504,6 +2568,9 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + graphlib@2.1.8: + resolution: {integrity: sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==} + gzip-size@6.0.0: resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==} engines: {node: '>=10'} @@ -2950,6 +3017,10 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + make-dir@3.1.0: + resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} + engines: {node: '>=8'} + make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} @@ -2961,6 +3032,11 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + md5-file@5.0.0: + resolution: {integrity: sha512-xbEFXCYVWrSx/gEKS1VPlg84h/4L20znVIulKw6kMfmBUAZNAnF00eczz9ICMl+/hjQGo5KSXRxbL/47X3rmMw==} + engines: {node: '>=10.13.0'} + hasBin: true + mdn-data@2.0.30: resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} @@ -3090,6 +3166,10 @@ packages: nwsapi@2.2.23: resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==} + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -3514,6 +3594,11 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true + semver@7.6.3: + resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} + engines: {node: '>=10'} + hasBin: true + semver@7.7.4: resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} @@ -5072,6 +5157,8 @@ snapshots: '@dcloudio/uni-app-vue@3.0.0-5010520260709002': {} + '@dcloudio/uni-app-x@0.7.114': {} + '@dcloudio/uni-app@3.0.0-5010520260709002(@dcloudio/types@3.4.30)(postcss@8.5.8)(rollup@4.14.3)(vite@5.2.8(@types/node@24.12.0)(sass@1.78.0)(terser@5.46.1))(vue@3.5.30(typescript@4.9.5))': dependencies: '@dcloudio/types': 3.4.30 @@ -5587,6 +5674,59 @@ snapshots: '@dcloudio/uni-ui@1.5.11': {} + '@dcloudio/uni-uts-v1@3.0.0-5010520260709002(rollup@4.14.3)': + dependencies: + '@babel/code-frame': 7.24.7 + '@dcloudio/uni-app-x': 0.7.114 + '@dcloudio/uts': 3.0.0-5010520260709002 + '@rollup/pluginutils': 5.1.0(rollup@4.14.3) + '@vue/shared': 3.4.21 + adm-zip: 0.5.16 + android-versions: 1.9.0 + colors: 1.4.0 + debug: 4.3.7 + fast-glob: 3.3.3 + find-cache-dir: 3.3.2 + fs-extra: 10.1.0 + graphlib: 2.1.8 + jsonc-parser: 3.3.1 + lodash: 4.17.23 + md5-file: 5.0.0 + object-hash: 3.0.0 + semver: 7.6.3 + source-map: 0.7.6 + source-map-js: 1.2.1 + transitivePeerDependencies: + - rollup + - supports-color + + '@dcloudio/uts-darwin-arm64@3.0.0-5010520260709002': + optional: true + + '@dcloudio/uts-darwin-x64@3.0.0-5010520260709002': + optional: true + + '@dcloudio/uts-linux-x64-gnu@3.0.0-5010520260709002': + optional: true + + '@dcloudio/uts-linux-x64-musl@3.0.0-5010520260709002': + optional: true + + '@dcloudio/uts-win32-ia32-msvc@3.0.0-5010520260709002': + optional: true + + '@dcloudio/uts-win32-x64-msvc@3.0.0-5010520260709002': + optional: true + + '@dcloudio/uts@3.0.0-5010520260709002': + optionalDependencies: + '@dcloudio/uts-darwin-arm64': 3.0.0-5010520260709002 + '@dcloudio/uts-darwin-x64': 3.0.0-5010520260709002 + '@dcloudio/uts-linux-x64-gnu': 3.0.0-5010520260709002 + '@dcloudio/uts-linux-x64-musl': 3.0.0-5010520260709002 + '@dcloudio/uts-win32-ia32-msvc': 3.0.0-5010520260709002 + '@dcloudio/uts-win32-x64-msvc': 3.0.0-5010520260709002 + '@dcloudio/vite-plugin-uni@3.0.0-5010520260709002(postcss@8.5.8)(rollup@4.14.3)(vite@5.2.8(@types/node@24.12.0)(sass@1.78.0)(terser@5.46.1))(vue@3.5.30(typescript@4.9.5))': dependencies: '@babel/core': 7.25.2 @@ -6746,6 +6886,10 @@ snapshots: transitivePeerDependencies: - supports-color + android-versions@1.9.0: + dependencies: + semver: 7.7.4 + ansi-escapes@4.3.2: dependencies: type-fest: 0.21.3 @@ -7029,12 +7173,16 @@ snapshots: colorette@2.0.20: {} + colors@1.4.0: {} + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 commander@2.20.3: {} + commondir@1.0.1: {} + compare-versions@3.6.0: {} computeds@0.0.1: {} @@ -7389,6 +7537,12 @@ snapshots: transitivePeerDependencies: - supports-color + find-cache-dir@3.3.2: + dependencies: + commondir: 1.0.1 + make-dir: 3.1.0 + pkg-dir: 4.2.0 + find-up@4.1.0: dependencies: locate-path: 5.0.0 @@ -7479,6 +7633,10 @@ snapshots: graceful-fs@4.2.11: {} + graphlib@2.1.8: + dependencies: + lodash: 4.17.23 + gzip-size@6.0.0: dependencies: duplexer: 0.1.2 @@ -8150,6 +8308,10 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + make-dir@3.1.0: + dependencies: + semver: 6.3.1 + make-dir@4.0.0: dependencies: semver: 7.7.4 @@ -8160,6 +8322,8 @@ snapshots: math-intrinsics@1.1.0: {} + md5-file@5.0.0: {} + mdn-data@2.0.30: {} media-typer@0.3.0: {} @@ -8252,6 +8416,8 @@ snapshots: nwsapi@2.2.23: {} + object-hash@3.0.0: {} + object-inspect@1.13.4: {} object-is@1.1.6: @@ -8674,6 +8840,8 @@ snapshots: semver@6.3.1: {} + semver@7.6.3: {} + semver@7.7.4: {} send@0.18.0: diff --git a/src/App.vue b/src/App.vue index 7258d325..ee85267a 100644 --- a/src/App.vue +++ b/src/App.vue @@ -8,6 +8,11 @@ import * as wsApi from './common/wssocket'; import chatTokenManager from './common/chat-token-manager'; import CHAT_ENV from '@/chat-env.js'; import AuthUtil from '@/sheep/api/member/auth'; +import { + initializePush, setPushForeground, consumePendingPushRoute, unbindPushUser, syncPushBadge, +} from '@/common/jpush'; +import AppVersionApi from '@/sheep/api/member/app-version'; +import appUpdates from '@/sheep/platform/app-update'; import useChatStore from '@/chat-store/chatStore.js'; @@ -24,6 +29,7 @@ const reissueChatToken = async () => { // 页面可能早于异步 onLaunch 完成加载,需在模块初始化时就注册重签能力。 chatTokenManager.setReissueToken(reissueChatToken); +appUpdates.configure(AppVersionApi); export default { /** ↓↓↓ 以下为app启动聊天相关模块初始化,WebSocket链接,加载聊天消息,加载离线消息,加载群组消息,初始化聊天相关的全局状态仓库 */ @@ -76,6 +82,7 @@ export default { complete: () => { }, }); } + syncPushBadge(count); }, // 初始化聊天 async init() { @@ -186,6 +193,7 @@ export default { this.chatStore.setLoading(false); // 刷新会话 this.chatStore.refreshChats(); + void consumePendingPushRoute(); }) .catch((e) => { console.log(e); @@ -450,6 +458,7 @@ export default { }, exit() { console.log('exit'); + void unbindPushUser({ clearPendingRoute: true }); this.isExit = true; chatTokenManager.stop(); wsApi.destroy(3099); @@ -521,6 +530,7 @@ export default { this.pullPrivateOfflineMessage(this.chatStore.privateMsgMaxId); this.pullGroupOfflineMessage(this.chatStore.groupMsgMaxId); this.configStore.setAppInit(true); + void consumePendingPushRoute(); }) .catch((e) => { console.log(e); @@ -539,6 +549,7 @@ export default { // #endif async onLaunch() { + initializePush(); uni.$on('chat-account-unavailable', (message) => { if (!this.isExit) { this.exit(); @@ -546,7 +557,13 @@ export default { } }); // 加载Shopro底层依赖 - await ShoproInit(); + try { + await ShoproInit(); + } finally { + // #ifdef APP-PLUS || APP-HARMONY + appUpdates.start(); + // #endif + } // #ifdef H5 let lastTouchEnd = 0; document.addEventListener( @@ -587,6 +604,11 @@ export default { // console.log('onError', error); // }, onShow() { + setPushForeground(true); + this.refreshUnreadBadge?.(); + // #ifdef APP-PLUS || APP-HARMONY + void appUpdates.foreground(); + // #endif // #ifndef MP-WEIXIN // H5 页面重新可见或 App 回到前台时,立即检查并重新建立轮询。 this.startChatTokenCheck(); @@ -596,13 +618,10 @@ export default { const args = plus.runtime.arguments; if (args) { } - // 获取剪贴板 - uni.getClipboardData({ - success: (res) => { }, - }); // #endif }, onHide() { + setPushForeground(false); // #ifndef MP-WEIXIN // 进入后台后停止轮询,避免无效请求和重复定时器。 chatTokenManager.stop(); diff --git a/src/common/__tests__/jpush-account-transition.test.mjs b/src/common/__tests__/jpush-account-transition.test.mjs new file mode 100644 index 00000000..9985d099 --- /dev/null +++ b/src/common/__tests__/jpush-account-transition.test.mjs @@ -0,0 +1,156 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import vm from 'node:vm'; + +const settle = () => new Promise((resolve) => setImmediate(resolve)); + +/** 保留用户资料请求,让通知可以在令牌替换与资料返回之间到达。 */ +function deferred() { + let resolve; + const promise = new Promise((complete) => { resolve = complete; }); + return { promise, resolve }; +} + +/** 同一 VM 执行实际 user 与 jpush 模块,覆盖公开登录入口的联动行为。 */ +async function fixture(authenticated = true) { + const storage = new Map(authenticated ? [['token', 'account-A-token']] : []); + const profile = deferred(); + const routes = []; + const unbindings = []; + const bindings = []; + const logins = []; + let route = 'pages/tabbar/home'; + const navigate = async ({ url }) => { + routes.push(url); + route = url.replace(/^\//, '').split('?')[0]; + }; + class NotificationChannel { + static IMPORTANCE_HIGH = 4; + setDescription() {} + enableVibration() {} + } + const context = vm.createContext({ + console: { warn() {} }, setTimeout, clearTimeout, + JPUSH_APP_KEY: 'test-app-key', JPUSH_APNS_PRODUCTION: true, + plus: { android: { + importClass: (value) => typeof value === 'string' ? NotificationChannel : value, + runtimeMainActivity: () => ({ getSystemService: () => ({ createNotificationChannel() {} }) }), + } }, + nativePush: { + setEventCallBack() {}, setDebug() {}, init() {}, requestRequiredPermission() {}, + getRegistrationId: () => 'test-registration', setAlias() {}, deleteAlias() {}, clearNotificationAll() {}, + }, + PushApi: { + bind: async (data, token) => { bindings.push({ data, token }); return { code: 0 }; }, + unbind: async (registrationId, token) => { unbindings.push({ registrationId, token }); return { code: 0 }; }, + }, + UserApi: { getUserInfo: () => profile.promise }, + PayWalletApi: { getPayWallet: async () => ({ code: 0, data: { balance: 0 } }) }, + checkPushNotificationPermission: async () => null, + wsApi: { setPushState() {}, destroy() {} }, + chatTokenManager: { stop() {} }, + appUpdates: { identityChanged() {}, authenticated() {} }, + $share: { getShareInfo() {}, bindBrokerageUser() {} }, + app: () => ({ platform: { bind_mobile: 0 } }), + cart: () => ({ emptyList() {} }), + clone: (value) => JSON.parse(JSON.stringify(value)), + cloneDeep: (value) => JSON.parse(JSON.stringify(value)), + defineStore: (definition) => { + let store; + return () => { + if (store) return store; + store = definition.state(); + for (const [name, action] of Object.entries(definition.actions)) store[name] = action.bind(store); + return store; + }; + }, + uni: { + getSystemInfoSync: () => ({ platform: 'android' }), + getStorageSync: (key) => storage.get(key), + setStorageSync: (key, value) => storage.set(key, value), + removeStorageSync: (key) => storage.delete(key), + navigateTo: navigate, redirectTo: navigate, switchTab: navigate, + }, + getCurrentPages: () => [{ route }], + getApp: () => ({ $vm: { configStore: { appInit: true }, chatStore: { loading: false } } }), + }); + const pushSource = (await readFile(new URL('../jpush.js', import.meta.url), 'utf8')) + .replace(/^import .+;\n/gm, '').replace(/^export const /gm, 'const '); + const userSource = (await readFile(new URL('../../sheep/store/user.js', import.meta.url), 'utf8')) + .replace(/^import .+;\n/gm, '').replace('export default user;', ''); + vm.runInContext(`${pushSource}\n${userSource}\nglobalThis.service = { + initializePush, bindPushUser, handlePushNotificationClick, getPushAccountId, user, + };`, context); + const service = context.service; + if (authenticated) await service.bindPushUser(42); + else service.initializePush(); + const store = service.user(); + if (authenticated) store.userInfo = { id: 42, sex: 1 }; + const loginAfter = store.loginAfter; + store.loginAfter = () => { + const task = loginAfter(); + logins.push(task); + return task; + }; + const finishProfile = async (id) => { + profile.resolve({ code: 0, data: { id, sex: 1 } }); + await Promise.all(logins); + await settle(); + }; + return { service, store, storage, routes, bindings, unbindings, finishProfile }; +} + +test('account transition rejects old-account navigation and unbinds using the old token', async () => { + const { service, store, storage, routes, bindings, unbindings, finishProfile } = await fixture(); + store.setToken('account-B-token', 'account-B-refresh'); + assert.equal(storage.get('token'), 'account-B-token'); + assert.equal(service.getPushAccountId(), ''); + service.handlePushNotificationClick({ extras: { + type: 'private_message', taskId: 'late-A', receiverId: 42, targetId: 99, + } }); + await settle(); + assert.equal(routes.length, 0); + assert.equal(unbindings.length, 1); + assert.equal(unbindings[0].token, 'account-A-token'); + assert.equal(unbindings[0].registrationId, 'test-registration'); + + await finishProfile(43); + assert.equal(service.getPushAccountId(), '43'); + assert.equal(storage.has('chat-jpush-pending-route'), false); + assert.equal(bindings.at(-1).token, 'account-B-token'); + service.handlePushNotificationClick({ extras: { + type: 'private_message', taskId: 'current-B', receiverId: 43, targetId: 100, + } }); + await settle(); + assert.equal(routes.length, 1); + assert.match(routes[0], /targetId=100&receiverId=43/); +}); + +test('refreshing a token keeps the current push account and notification handling', async () => { + const { service, store, unbindings, routes, finishProfile } = await fixture(); + store.setToken('account-A-refreshed', 'account-A-refresh', true); + assert.equal(service.getPushAccountId(), '42'); + await finishProfile(42); + assert.equal(unbindings.length, 0); + service.handlePushNotificationClick({ extras: { + type: 'private_message', taskId: 'after-refresh', receiverId: 42, targetId: 99, + } }); + await settle(); + assert.match(routes.at(-1), /targetId=99&receiverId=42/); +}); + +test('first login preserves an anonymous owned cold click until its user profile is ready', async () => { + const { service, store, storage, routes, unbindings, finishProfile } = await fixture(false); + service.handlePushNotificationClick({ extras: { + type: 'private_message', taskId: 'cold-A', receiverId: 42, targetId: 99, + } }); + await settle(); + store.setToken('account-A-token', 'account-A-refresh'); + assert.equal(storage.get('chat-jpush-pending-route').clickId, 'private_message:cold-A'); + assert.equal(unbindings.length, 0); + await finishProfile(42); + assert.equal(service.getPushAccountId(), '42'); + assert.match(routes.at(-1), /targetId=99&receiverId=42/); + assert.equal(storage.has('chat-jpush-pending-route'), false); +}); diff --git a/src/common/__tests__/jpush-permission.test.mjs b/src/common/__tests__/jpush-permission.test.mjs new file mode 100644 index 00000000..4ce0503c --- /dev/null +++ b/src/common/__tests__/jpush-permission.test.mjs @@ -0,0 +1,155 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import vm from 'node:vm'; + +const GUIDE_SHOWN_KEY = 'chat-jpush-notification-guide-shown'; +const settle = () => new Promise((resolve) => setImmediate(resolve)); + +/** 与现有推送测试相同,使用隔离 VM 注入 uni 和原生接口。 */ +async function fixture(options = {}) { + const storage = options.storage || new Map([['token', 'test-token']]); + const state = { + status: options.status ?? 0, + checks: 0, + settings: 0, + modals: [], + }; + const nativePush = { + isNotificationEnabled() { + state.checks++; + if (options.checkFails) throw new Error('Native unavailable'); + return state.status; + }, + goToAppNotificationSettings() { + state.settings++; + if (options.settingsFail) throw new Error('Settings unavailable'); + }, + }; + const context = vm.createContext({ + ...(options.native === false ? {} : { plus: { runtime: {} } }), + uni: { + getDeviceInfo: () => ({ platform: options.platform || 'android' }), + getStorageSync: (key) => storage.get(key), + setStorageSync: (key, value) => storage.set(key, value), + showModal(modal) { + state.modals.push(modal); + if (options.manualModal) return; + if (options.modalFails) modal.fail(); + else modal.success({ confirm: options.confirm ?? false }); + }, + }, + }); + let source = await readFile(new URL('../jpush-permission.js', import.meta.url), 'utf8'); + if (options.h5Build) { + source = source.replace(/\/\/ #ifdef APP-PLUS[\s\S]*?\/\/ #endif/g, ''); + } + source = source.replace(/^export const /gm, 'const '); + vm.runInContext(`${source}\nglobalThis.check = checkPushNotificationPermission;`, context); + return { check: () => context.check(nativePush), nativePush, state, storage }; +} + +test('enabled notifications continue without a guide or installation marker', async () => { + const { check, state, storage } = await fixture({ status: 1 }); + assert.equal(await check(), true); + assert.equal(state.checks, 1); + assert.equal(state.modals.length, 0); + assert.equal(storage.has(GUIDE_SHOWN_KEY), false); +}); + +test('confirmation opens official notification settings once and foreground return rechecks', async () => { + const { check, state, storage } = await fixture({ confirm: true }); + assert.equal(await check(), false); + assert.equal(state.modals.length, 1); + assert.equal(state.modals[0].title, '开启消息通知'); + assert.equal(state.modals[0].confirmText, '去开启'); + assert.equal(state.modals[0].cancelText, '暂不开启'); + assert.equal(state.settings, 1); + assert.equal(storage.get(GUIDE_SHOWN_KEY), true); + state.status = 1; + assert.equal(await check(), true); + state.status = 0; + assert.equal(await check(), false); + assert.equal(state.checks, 3); + assert.equal(state.modals.length, 1); + assert.equal(state.settings, 1); +}); + +test('declining the guide persists across reload and account changes', async () => { + const first = await fixture(); + assert.equal(await first.check(), false); + first.storage.set('token', 'second-account-token'); + assert.equal(await first.check(), false); + assert.equal(first.state.modals.length, 1); + assert.equal(first.state.settings, 0); + const reloaded = await fixture({ storage: first.storage }); + assert.equal(await reloaded.check(), false); + assert.equal(reloaded.state.modals.length, 0); +}); + +test('overlapping login and foreground checks share a single modal', async () => { + const { check, state } = await fixture({ manualModal: true }); + const first = check(); + const second = check(); + assert.equal(first, second); + await settle(); + assert.equal(state.checks, 1); + assert.equal(state.modals.length, 1); + state.modals[0].success({ confirm: false }); + assert.equal(await first, false); +}); + +test('login completion can continue while the user has not answered', async () => { + const { check, state } = await fixture({ manualModal: true }); + void check(); + await settle(); + assert.equal(state.modals.length, 1); + state.modals[0].success({ confirm: false }); + await settle(); +}); + +test('logged-out applications and iOS do not query Android notification APIs', async () => { + for (const options of [{ storage: new Map() }, { platform: 'ios' }]) { + const { check, state } = await fixture(options); + assert.equal(await check(), null); + assert.equal(state.checks, 0); + assert.equal(state.modals.length, 0); + } +}); + +test('Android browsers and preprocessed H5 builds do not access the native module', async () => { + for (const options of [{ native: false }, { h5Build: true }]) { + const { check, state } = await fixture(options); + assert.equal(await check(), null); + assert.equal(state.checks, 0); + assert.equal(state.modals.length, 0); + } +}); + +test('unknown native status and query errors do not consume the one-time guide', async () => { + for (const options of [{ status: -1 }, { checkFails: true }]) { + const { check, state, storage } = await fixture(options); + assert.equal(await check(), null); + assert.equal(state.modals.length, 0); + assert.equal(storage.has(GUIDE_SHOWN_KEY), false); + } +}); + +test('modal and settings failures resolve safely without repeating the guide', async () => { + for (const options of [{ modalFails: true }, { confirm: true, settingsFail: true }]) { + const { check, state } = await fixture(options); + await check(); + await check(); + assert.equal(state.modals.length, 1); + } +}); + +test('logout while the guide is open prevents a later settings launch', async () => { + const { check, state, storage } = await fixture({ manualModal: true }); + const pending = check(); + await settle(); + storage.delete('token'); + state.modals[0].success({ confirm: true }); + assert.equal(await pending, false); + assert.equal(state.settings, 0); +}); diff --git a/src/common/__tests__/jpush.test.mjs b/src/common/__tests__/jpush.test.mjs new file mode 100644 index 00000000..6c65eccf --- /dev/null +++ b/src/common/__tests__/jpush.test.mjs @@ -0,0 +1,149 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import vm from 'node:vm'; + +/** 控制一次页面跳转完成时机,重现两个通知点击交错到达。 */ +function deferred() { + let resolve; + const promise = new Promise((complete) => { resolve = complete; }); + return { promise, resolve }; +} + +/** 等待通知路由内部的异步消费和 finally 全部完成。 */ +const settle = () => new Promise((resolve) => setImmediate(resolve)); + +/** 通过服务公开入口初始化隔离账户和原生替身,复用项目既有 Node/VM 测试方式。 */ +async function fixture(navigate = async () => {}, authenticated = true) { + const storage = new Map(authenticated ? [['token', 'test-token']] : []); + const routes = []; + const bindings = []; + class NotificationChannel { + static IMPORTANCE_HIGH = 4; + setDescription() {} + enableVibration() {} + } + const manager = { createNotificationChannel() {} }; + const context = vm.createContext({ + console: { warn() {} }, + setTimeout, clearTimeout, + JPUSH_APP_KEY: 'test-app-key', JPUSH_APNS_PRODUCTION: true, + plus: { + android: { + importClass: (value) => typeof value === 'string' ? NotificationChannel : value, + runtimeMainActivity: () => ({ getSystemService: () => manager }), + }, + }, + nativePush: { + setEventCallBack() {}, setDebug() {}, init() {}, requestRequiredPermission() {}, + getRegistrationId: () => 'test-registration', setAlias() {}, deleteAlias() {}, clearNotificationAll() {}, + }, + PushApi: { + bind: async (data) => { bindings.push(data); return { code: 0 }; }, + unbind: async () => ({ code: 0 }), + }, + checkPushNotificationPermission: async () => null, + wsApi: { setPushState() {} }, + uni: { + getSystemInfoSync: () => ({ platform: 'android' }), + getStorageSync: (key) => storage.get(key), + setStorageSync: (key, value) => storage.set(key, value), + removeStorageSync: (key) => storage.delete(key), + navigateTo: async ({ url }) => { routes.push(url); await navigate(url); }, + redirectTo: async ({ url }) => { routes.push(url); await navigate(url); }, + switchTab: async ({ url }) => { routes.push(url); await navigate(url); }, + }, + getCurrentPages: () => [{ route: 'pages/tabbar/home' }], + getApp: () => ({ $vm: { configStore: { appInit: true }, chatStore: { loading: false } } }), + }); + const source = (await readFile(new URL('../jpush.js', import.meta.url), 'utf8')) + .replace(/^import .+;\n/gm, '') + .replace(/^export const /gm, 'const '); + vm.runInContext(`${source}\nglobalThis.service = { bindPushUser, unbindPushUser, handlePushNotificationClick };`, context); + if (authenticated) await context.service.bindPushUser(42); + return { service: context.service, storage, routes, bindings }; +} + +test('device binding reports the configured push application', async () => { + const { bindings } = await fixture(); + assert.equal(bindings[0].appKey, 'test-app-key'); + assert.equal(bindings[0].registrationId, 'test-registration'); +}); + +test('a second notification click is consumed after the first navigation completes', async () => { + const first = deferred(); + const { service, routes, storage } = await fixture((url) => url.includes('targetId=101') ? first.promise : undefined); + service.handlePushNotificationClick({ extras: { type: 'private_message', taskId: 'A', receiverId: 42, targetId: 101 } }); + service.handlePushNotificationClick({ extras: { type: 'private_message', taskId: 'B', receiverId: 42, targetId: 102 } }); + assert.equal(routes.length, 1); + first.resolve(); + await settle(); + assert.equal(routes.length, 2); + assert.match(routes[1], /targetId=102/); + assert.equal(storage.has('chat-jpush-pending-route'), false); +}); + +test('a failed notification navigation waits for another lifecycle event', async () => { + const { service, routes, storage } = await fixture(async () => { throw new Error('Page not ready'); }); + service.handlePushNotificationClick({ extras: { type: 'private_message', taskId: 'A', receiverId: 42, targetId: 101 } }); + await settle(); + await settle(); + assert.equal(routes.length, 1); + assert.equal(storage.get('chat-jpush-pending-route').clickId, 'private_message:A'); +}); + +test('legacy queued notifications use senderId and native msgId for routing and deduplication', async () => { + const { service, routes } = await fixture(); + const notificationExtras = JSON.stringify({ type: 'private_message', senderId: 99, receiverId: 42, messageType: 0 }); + service.handlePushNotificationClick({ msgId: 'native-A', notificationExtras }); + await settle(); + assert.match(routes[0], /targetId=99/); + service.handlePushNotificationClick({ msgId: 'native-A', notificationExtras }); + await settle(); + assert.equal(routes.length, 1); + service.handlePushNotificationClick({ msgId: 'native-B', notificationExtras }); + await settle(); + assert.equal(routes.length, 2); +}); + +test('anonymous authentication cleanup preserves an owned click until its receiver logs in', async () => { + const { service, storage, routes } = await fixture(undefined, false); + service.handlePushNotificationClick({ extras: { type: 'private_message', taskId: 'cold', receiverId: 42, targetId: 99 } }); + await settle(); + await service.unbindPushUser(); + await service.unbindPushUser(); + assert.equal(storage.get('chat-jpush-pending-route').clickId, 'private_message:cold'); + assert.equal(storage.get('chat-jpush-pending-route').ownerId, '42'); + storage.set('token', 'test-token'); + await service.bindPushUser(42); + await settle(); + assert.match(routes.at(-1), /targetId=99/); + assert.equal(storage.has('chat-jpush-pending-route'), false); +}); + +test('an anonymous owned click is discarded after a different account logs in', async () => { + const { service, storage, routes } = await fixture(undefined, false); + service.handlePushNotificationClick({ extras: { type: 'private_message', taskId: 'cold', receiverId: 42, targetId: 99 } }); + await settle(); + await service.unbindPushUser(); + storage.set('token', 'other-token'); + await service.bindPushUser(43); + await settle(); + assert.equal(routes.length, 1); + assert.equal(storage.has('chat-jpush-pending-route'), false); +}); + +test('explicit sign-out clears an anonymous pending click', async () => { + const { service, storage } = await fixture(undefined, false); + service.handlePushNotificationClick({ extras: { type: 'manual', taskId: 'manual-cold' } }); + await settle(); + await service.unbindPushUser({ clearPendingRoute: true }); + assert.equal(storage.has('chat-jpush-pending-route'), false); +}); + +test('authentication cleanup clears a previously bound account pending click', async () => { + const { service, storage } = await fixture(); + storage.set('chat-jpush-pending-route', { type: 'private_message', ownerId: '42', clickId: 'bound' }); + await service.unbindPushUser(); + assert.equal(storage.has('chat-jpush-pending-route'), false); +}); diff --git a/src/common/jpush-config.js b/src/common/jpush-config.js new file mode 100644 index 00000000..85db3f65 --- /dev/null +++ b/src/common/jpush-config.js @@ -0,0 +1,5 @@ +/** 当前聊天应用的极光 AppKey,与会员服务端应用保持一致。 */ +export const JPUSH_APP_KEY = "447fcdbce3b2dcac3091c0de"; + +/** 与服务端 JPUSH_APNS_PRODUCTION=true 默认环境一致,开发证书联调需同时切换两端。 */ +export const JPUSH_APNS_PRODUCTION = true; diff --git a/src/common/jpush-permission.js b/src/common/jpush-permission.js new file mode 100644 index 00000000..ea56f2e3 --- /dev/null +++ b/src/common/jpush-permission.js @@ -0,0 +1,55 @@ +/** 安装实例内只展示一次通知设置引导,退出和切换账号时保留。 */ +const GUIDE_SHOWN_KEY = 'chat-jpush-notification-guide-shown'; +/** 登录与前台恢复共用一次检查,避免重叠弹窗。 */ +let permissionTask = null; + +const isNativeAndroid = () => { + // #ifdef APP-PLUS + if (typeof plus === 'undefined' || !plus.runtime) return false; + return uni.getDeviceInfo().platform === 'android'; + // #endif + // #ifndef APP-PLUS + return false; + // #endif +}; + +/** 沿用现有权限工具的可取消确认框,显示失败时按取消处理。 */ +const confirmNotificationSettings = () => new Promise((resolve) => { + uni.showModal({ + title: '开启消息通知', + content: '开启后,应用在后台时也能及时收到新消息提醒。', + confirmText: '去开启', + cancelText: '暂不开启', + success: (result) => resolve(Boolean(result.confirm)), + fail: () => resolve(false), + }); +}); + +/** 每次回到前台重查系统状态,已展示过的引导保持静默。 */ +const checkPermissionNow = async (nativePush) => { + if (!isNativeAndroid() || !uni.getStorageSync('token') + || typeof nativePush?.isNotificationEnabled !== 'function' + || typeof nativePush?.goToAppNotificationSettings !== 'function') return null; + + const status = nativePush.isNotificationEnabled(); + if (status === 1) return true; + if (status !== 0) return null; + if (uni.getStorageSync(GUIDE_SHOWN_KEY)) return false; + + // 在弹窗前持久化,进程重启或用户拒绝后均不会再次打扰。 + uni.setStorageSync(GUIDE_SHOWN_KEY, true); + const confirmed = await confirmNotificationSettings(); + if (confirmed && uni.getStorageSync('token')) { + await nativePush.goToAppNotificationSettings(); + } + return false; +}; + +/** 登录后和前台恢复时以 void 调用,查询及设置异常均不影响聊天。 */ +export const checkPushNotificationPermission = (nativePush) => { + if (permissionTask) return permissionTask; + permissionTask = Promise.resolve().then(() => checkPermissionNow(nativePush)) + .catch(() => null) + .finally(() => { permissionTask = null; }); + return permissionTask; +}; diff --git a/src/common/jpush.js b/src/common/jpush.js new file mode 100644 index 00000000..768077a5 --- /dev/null +++ b/src/common/jpush.js @@ -0,0 +1,349 @@ +import PushApi from '@/sheep/api/member/push'; +import * as wsApi from './wssocket'; +import { checkPushNotificationPermission } from './jpush-permission'; +import { JPUSH_APP_KEY, JPUSH_APNS_PRODUCTION } from './jpush-config'; +// #ifdef APP-PLUS +import * as nativePush from '@/uni_modules/jg-jpush-u'; +// #endif + +/** 冷启动通知点击暂存键,只保存跳转标识,不保存聊天内容。 */ +const PENDING_ROUTE_KEY = 'chat-jpush-pending-route'; +/** 首次注册尚未完成时的补查间隔,单位毫秒。 */ +const REGISTRATION_RETRY_MS = 3000; +/** 单次初始化最多补查十次,之后由连接或前台恢复事件继续同步。 */ +const REGISTRATION_RETRY_LIMIT = 10; +/** 已处理点击的内存去重上限,避免原生冷启动回调重复打开页面。 */ +const HANDLED_CLICK_LIMIT = 50; +/** 与服务端 JPushGateway 固定渠道一致,Android 8+ 使用此渠道控制声音和通知展示。 */ +const ANDROID_MESSAGE_CHANNEL_ID = 'htys_message'; + +/** 极光原生初始化状态,进程内只注册一次监听。 */ +let initialized = false; +/** 当前安装实例的极光标识,不随业务账号变化。 */ +let registrationId = ''; +/** 当前要求绑定的业务会员 ID,与 IM 用户 ID 分开维护。 */ +let accountId = ''; +/** 账户代际,用于忽略换账号或退出后的异步结果。 */ +let accountVersion = 0; +/** 已成功绑定的会员和安装实例组合。 */ +let boundKey = ''; +/** 串行执行绑定、解绑,防止旧账号迟到的请求覆盖新归属。 */ +let bindingTask = Promise.resolve(); +/** 当前绑定请求的组合,合并启动、登录和原生连接的重复同步。 */ +let bindingKey = ''; +/** 当前注册补查计时器,退出后立即停止。 */ +let registrationTimer = null; +/** 当前注册补查次数,前台恢复后重新计数。 */ +let registrationRetries = 0; +/** 跳转互斥锁,避免原生点击与聊天初始化同时消费。 */ +let routing = false; +/** 进程内已经完成的原生点击标识。 */ +const handledClicks = new Set(); + +/** 判断当前是否为可调用极光原生模块的 App。 */ +const isNativeApp = () => { + // #ifdef APP-PLUS + return typeof plus !== 'undefined'; + // #endif + return false; +}; + +/** 安全解析 Android 字符串 extras 与 iOS 字典回调。 */ +const asObject = (value) => { + if (typeof value === 'string') { + try { return asObject(JSON.parse(value)); } catch { return {}; } + } + return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; +}; + +/** 获取已经确认的当前业务会员 ID,供通知路由校验账号归属。 */ +export const getPushAccountId = () => accountId; + +/** iOS 系统角标跟随现有聊天未读数,避免 APNs 累加后在阅读消息时残留。 */ +export const syncPushBadge = (unreadCount) => { + if (!initialized || !isNativeApp()) return; + // #ifdef APP-PLUS + try { + if (uni.getSystemInfoSync().platform === 'ios') { + nativePush.setBadgeNumber(Math.max(0, Math.floor(Number(unreadCount) || 0))); + } + } catch { + console.warn('[JPush] 系统角标同步暂未完成'); + } + // #endif +}; + +/** 将绑定任务异常限制在推送能力内部,保持主站登录和退出可继续。 */ +const enqueueBinding = (operation) => { + bindingTask = bindingTask.then(operation).catch(() => { + console.warn('[JPush] 设备同步暂未成功,后续连接或回到前台时重试'); + scheduleRegistrationRetry(); + }); + return bindingTask; +}; + +/** 在当前登录令牌下绑定本机,结果仅对发起请求时的账户代际生效。 */ +const syncBinding = () => { + if (!accountId || !registrationId || !uni.getStorageSync('token')) return bindingTask; + const key = `${accountId}:${registrationId}`; + if (key === boundKey || key === bindingKey) return bindingTask; + const version = accountVersion; + const userId = accountId; + const deviceId = registrationId; + const token = uni.getStorageSync('token'); + bindingKey = key; + return enqueueBinding(async () => { + if (version !== accountVersion) return; + try { + const platform = String(uni.getSystemInfoSync().platform).toLowerCase(); + const response = await PushApi.bind({ + registrationId: deviceId, + appKey: JPUSH_APP_KEY, + alias: `u${userId}`, + platform, + provider: 'jpush', + }, token); + if (version !== accountVersion) return; + if (response?.code !== 0) throw new Error('推送设备绑定暂未成功'); + // #ifdef APP-PLUS + nativePush.setAlias(version + 1, `u${userId}`); + // #endif + boundKey = key; + wsApi.setPushState({ registrationId: deviceId }); + void consumePendingPushRoute(); + } finally { + if (bindingKey === key) bindingKey = ''; + } + }); +}; + +/** 读取或更新极光注册标识,并让现有 WebSocket 心跳携带本机标识。 */ +const refreshRegistration = () => { + if (!initialized) return; + // #ifdef APP-PLUS + const currentId = String(nativePush.getRegistrationId() || '').trim(); + if (currentId) { + registrationId = currentId; + wsApi.setPushState({ registrationId: accountId ? currentId : '' }); + void syncBinding(); + return; + } + // #endif + scheduleRegistrationRetry(); +}; + +/** 注册尚未生成或服务端短时不可用时有界补试,恢复前台后可开启新一轮重试。 */ +const scheduleRegistrationRetry = () => { + if (registrationTimer || registrationRetries >= REGISTRATION_RETRY_LIMIT || !accountId) return; + registrationRetries += 1; + registrationTimer = setTimeout(() => { + registrationTimer = null; + refreshRegistration(); + }, REGISTRATION_RETRY_MS); +}; + +/** 从原生通知取出统一业务扩展字段,兼容 iOS 顶层和 Android notificationExtras。 */ +const notificationExtras = (raw) => { + const message = asObject(raw); + return { + ...message, + ...asObject(message.extra), + ...asObject(message.extras), + ...asObject(message.notificationExtras), + ...asObject(asObject(message.android).extras), + ...asObject(asObject(message.ios).extras), + }; +}; + +/** 暂存合法通知点击,聊天数据或登录尚未就绪时由后续生命周期消费。 */ +export const handlePushNotificationClick = (raw) => { + const extras = notificationExtras(raw); + if (!['private_message', 'manual'].includes(extras.type)) return; + const receiverId = String(extras.receiverId || ''); + const targetId = String(extras.targetId || extras.senderId || ''); + if (extras.type === 'private_message' && (!receiverId || !targetId)) return; + if (accountId && receiverId && receiverId !== accountId) return; + // 兼容升级前排队通知,Android 原生回调使用 msgId,iOS APNs 保留 _j_msgid。 + const clickId = `${extras.type}:${extras.taskId || extras.messageId || extras.msgId || extras.messageID || extras._j_msgid || ''}`; + if (handledClicks.has(clickId)) return; + uni.setStorageSync(PENDING_ROUTE_KEY, { + type: extras.type, + targetId, + ownerId: receiverId || accountId, + clickId, + }); + getApp().$vm?.refreshUnreadBadge?.(); + void consumePendingPushRoute(); +}; + +/** 等待主站认证和 IM 离线同步后,使用稳定 IM ID 打开会话或消息列表。 */ +export const consumePendingPushRoute = async () => { + if (routing) return; + const pending = asObject(uni.getStorageSync(PENDING_ROUTE_KEY)); + if (!pending.type) return; + const pages = getCurrentPages(); + const current = pages[pages.length - 1]; + if (!current) return; + if (!uni.getStorageSync('token')) { + if (current.route !== 'pages/login/login') { + routing = true; + try { + await uni.navigateTo({ url: '/pages/login/login' }); + } catch { + console.warn('[JPush] 登录页尚未就绪,保留通知跳转'); + } finally { + routing = false; + } + } + return; + } + if (!accountId) return; + if (pending.ownerId && pending.ownerId !== accountId) { + uni.removeStorageSync(PENDING_ROUTE_KEY); + return; + } + const app = getApp().$vm; + if (pending.type === 'private_message' && (!app?.configStore?.appInit || app?.chatStore?.loading)) return; + const version = accountVersion; + routing = true; + try { + if (pending.type === 'manual') { + await uni.switchTab({ url: '/pages/tabbar/message' }); + } else { + const currentChat = current.$vm?.chat; + const sameChat = current.route === 'pages/message/chat-box' + && currentChat?.type === 'PRIVATE' && String(currentChat.targetId) === pending.targetId; + if (!sameChat) { + const url = `/pages/message/chat-box?chatType=PRIVATE&targetId=${encodeURIComponent(pending.targetId)}&receiverId=${encodeURIComponent(accountId)}`; + if (current.route === 'pages/message/chat-box') await uni.redirectTo({ url }); + else await uni.navigateTo({ url }); + } + } + if (version !== accountVersion) return; + handledClicks.add(pending.clickId); + if (handledClicks.size > HANDLED_CLICK_LIMIT) handledClicks.delete(handledClicks.values().next().value); + if (asObject(uni.getStorageSync(PENDING_ROUTE_KEY)).clickId === pending.clickId) { + uni.removeStorageSync(PENDING_ROUTE_KEY); + } + } catch { + console.warn('[JPush] 通知页面尚未就绪,保留跳转等待下一次恢复'); + } finally { + routing = false; + const next = asObject(uni.getStorageSync(PENDING_ROUTE_KEY)); + if (next.clickId && next.clickId !== pending.clickId) void consumePendingPushRoute(); + } +}; + +/** 项目最低 Android 10,启动时幂等创建服务端指定的高优先级消息渠道。 */ +const createAndroidNotificationChannel = () => { + // #ifdef APP-PLUS + const NotificationChannel = plus.android.importClass('android.app.NotificationChannel'); + const NotificationManager = plus.android.importClass('android.app.NotificationManager'); + const manager = plus.android.runtimeMainActivity().getSystemService('notification'); + plus.android.importClass(manager); + const channel = new NotificationChannel(ANDROID_MESSAGE_CHANNEL_ID, '消息通知', NotificationManager.IMPORTANCE_HIGH); + channel.setDescription('新私聊消息和平台通知'); + channel.enableVibration(true); + manager.createNotificationChannel(channel); + // #endif +}; + +/** 只在原生 App 初始化一次,先注册点击监听以接收原生缓存的冷启动事件。 */ +export const initializePush = () => { + if (!isNativeApp() || initialized) return; + // #ifdef APP-PLUS + try { + nativePush.setEventCallBack({ callback: (event) => { + if (event.eventName === 'onClickMessage') handlePushNotificationClick(event.eventData); + if (event.eventName === 'onRegister' || event.eventName === 'onConnected') refreshRegistration(); + } }); + nativePush.setDebug(false); + const platform = String(uni.getSystemInfoSync().platform).toLowerCase(); + if (platform === 'ios') { + nativePush.initPush({ + appkey: JPUSH_APP_KEY, + channel: 'App Store', + isProduction: JPUSH_APNS_PRODUCTION, + advertisingId: '', + }); + } else { + createAndroidNotificationChannel(); + nativePush.init(); + nativePush.requestRequiredPermission(); + } + initialized = true; + refreshRegistration(); + } catch { + console.warn('[JPush] 原生推送暂不可用,请确认使用包含极光插件的自定义基座'); + } + // #endif +}; + +/** 登录或恢复主站认证后,绑定业务会员,账户发生变化时清理旧账号的待处理点击。 */ +export const bindPushUser = (userId) => { + if (!isNativeApp() || !userId) return Promise.resolve(); + const nextId = String(userId); + if (accountId && accountId !== nextId) { + uni.removeStorageSync(PENDING_ROUTE_KEY); + boundKey = ''; + try { clearNotifications(); } catch { /* 继续新账号设备绑定。 */ } + } + if (accountId !== nextId) accountVersion += 1; + accountId = nextId; + initializePush(); + // #ifdef APP-PLUS + void checkPushNotificationPermission(nativePush); + // #endif + registrationRetries = 0; + refreshRegistration(); + void consumePendingPushRoute(); + return syncBinding(); +}; + +/** 清除本机通知,防止退出或换账号后旧消息继续保留在系统通知栏。 */ +const clearNotifications = () => { + // #ifdef APP-PLUS + if (initialized) nativePush.clearNotificationAll(); + // #endif +}; + +/** 先使本地账户代际失效,再精确解绑,调用方可选择等待后再清除登录令牌。 */ +export const unbindPushUser = ({ clearPendingRoute = false } = {}) => { + const token = uni.getStorageSync('token'); + const deviceId = registrationId; + const hadBinding = Boolean(accountId || boundKey || bindingKey); + accountVersion += 1; + accountId = ''; + boundKey = ''; + bindingKey = ''; + clearTimeout(registrationTimer); + registrationTimer = null; + // 匿名启动的认证失败会重复清理会话,通知待办应保留到登录后校验接收账号。 + if (hadBinding || clearPendingRoute) uni.removeStorageSync(PENDING_ROUTE_KEY); + handledClicks.clear(); + wsApi.setPushState({ registrationId: '' }); + try { + // #ifdef APP-PLUS + if (initialized) nativePush.deleteAlias(accountVersion + 1); + // #endif + clearNotifications(); + } catch { + console.warn('[JPush] 本机通知清理暂未完成'); + } + if (!hadBinding || !isNativeApp() || !deviceId || !token) return Promise.resolve(); + return enqueueBinding(() => PushApi.unbind(deviceId, token)); +}; + +/** 前后台变化立即通过现有 IM 心跳上报,前台恢复补查注册并消费冷启动路由。 */ +export const setPushForeground = (foreground) => { + if (!isNativeApp()) return; + wsApi.setPushState({ foreground }); + if (foreground) { + // #ifdef APP-PLUS + if (accountId) void checkPushNotificationPermission(nativePush); + // #endif + registrationRetries = 0; + try { refreshRegistration(); } catch { /* 插件异常不阻止 App 回到前台。 */ } + void consumePendingPushRoute(); + } +}; diff --git a/src/common/recorder-app.js b/src/common/recorder-app.js index 389e5b65..6fee5e3e 100644 --- a/src/common/recorder-app.js +++ b/src/common/recorder-app.js @@ -1,59 +1,75 @@ import CHAT_ENV from '@/chat-env.js'; +import { ensurePermission, hasPermission, handlePermissionFailure } from '@/sheep/util/permission'; -const rc = uni.getRecorderManager(); -// 录音开始时间 -let startTime = null; -let checkIsEnable = () => { - return true; -}; +/** 原生录音管理器只注册一组回调,取消录音不会触发历史消息的重复上传。 */ +const recorder = uni.getRecorderManager(); +/** 本次录音实际开始的时间,单位毫秒。 */ +let startTime = 0; +/** 本次启动任务的回调,结束后清除。 */ +let startCallbacks = null; +/** 本次录音结束结果,允许 stop 回调早于上传入口到达。 */ +let stoppedRecording = null; +/** 等待本次 stop 事件的上传任务。 */ +let stopCallback = null; -let start = () => { +recorder.onStart(() => { + startTime = Date.now(); + if (startCallbacks?.requiresFreshGesture) { + recorder.stop(); + startCallbacks.reject(new Error('recording-permission-ready')); + } else startCallbacks?.resolve(); + startCallbacks = null; +}); +recorder.onStop((file) => { + stoppedRecording = { file, duration: Math.round((Date.now() - startTime) / 1000) }; + stopCallback?.(stoppedRecording); + stopCallback = null; +}); +recorder.onError((error) => { + void handlePermissionFailure('microphone', error); + startCallbacks?.reject(error); + startCallbacks = null; + stopCallback?.({ error }); + stopCallback = null; +}); + +/** 原生是否支持录音,授权在用户主动操作的 start 中处理。 */ +const checkIsEnable = () => Boolean(recorder); +/** 授权完成后只使用仍有效的长按手势启动录音。 */ +const start = async (shouldStart = () => true) => { + const authorizedBefore = hasPermission('microphone'); + if (!await ensurePermission('microphone', { feature: '聊天语音', purpose: '录制您主动发送的语音消息' })) { + throw new Error('麦克风权限未开启'); + } + // 系统授权框可能吞掉松手事件,首次授权成功后等待下一次明确的长按。 + if (!authorizedBefore && hasPermission('microphone')) throw new Error('recording-permission-ready'); + if (authorizedBefore && !shouldStart()) throw new Error('recording-cancelled'); + stoppedRecording = null; return new Promise((resolve, reject) => { - rc.onStart(() => { - startTime = new Date(); - resolve(); - }); - rc.onError((e) => { - console.log(e); - reject(e); - }); - rc.start({ - format: 'mp3', // 录音格式,可选值:aac/mp3 - }); + startCallbacks = { resolve, reject, requiresFreshGesture: !authorizedBefore }; + recorder.start({ format: 'mp3' }); }); }; - -let close = () => { - rc.stop(); -}; - -let upload = () => { +/** 结束当前录音,是否上传由聊天组件依据取消状态决定。 */ +const close = () => recorder.stop(); +/** 读取本次结束结果后上传,避免重复注册 onStop 导致已取消录音也被上传。 */ +const upload = async () => { + const recording = stoppedRecording || await new Promise((resolve) => { stopCallback = resolve; }); + if (recording.error) throw recording.error; return new Promise((resolve, reject) => { - rc.onStop((wavFile, a, b) => { - uni.uploadFile({ - url: CHAT_ENV.BASE_URL + '/file/upload', - header: { - accessToken: uni.getStorageSync('loginInfo').accessToken, - }, - filePath: wavFile.tempFilePath, - name: 'file', - success: (res) => { - let r = JSON.parse(res.data); - if (r.code != 200) { - reject(r.message); - } else { - const duration = (new Date().getTime() - startTime.getTime()) / 1000; - const data = { - duration: Math.round(duration), - url: r.data, - }; - resolve(data); - } - }, - fail: (e) => { - reject(e); - }, - }); + uni.uploadFile({ + url: CHAT_ENV.BASE_URL + '/file/upload', + header: { accessToken: uni.getStorageSync('loginInfo').accessToken }, + filePath: recording.file.tempFilePath, + name: 'file', + success: (response) => { + try { + const result = JSON.parse(response.data); + if (result.code !== 200) return reject(result.message); + resolve({ duration: recording.duration, url: result.data }); + } catch { reject(new Error('语音上传响应异常,请重试')); } + }, + fail: reject, }); }); }; diff --git a/src/common/recorder-h5.js b/src/common/recorder-h5.js index a82b3e8c..d00d55c7 100644 --- a/src/common/recorder-h5.js +++ b/src/common/recorder-h5.js @@ -5,6 +5,8 @@ let duration = 0; let chunks = []; let stream = null; let startTime = null; +// 浏览器不支持 Permissions API 时,记录本次页面已完成的麦克风授权。 +let microphoneReady = false; let checkIsEnable = () => { if ( origin.indexOf('https') === -1 && @@ -27,13 +29,31 @@ let checkIsEnable = () => { return true; }; -let start = () => { +let start = async (shouldStart = () => true) => { + let authorizedBefore = microphoneReady; + try { + authorizedBefore = (await navigator.permissions.query({ name: 'microphone' })).state === 'granted'; + } catch { /* 不支持查询的浏览器在首次授权后等待下一次长按。 */ } return navigator.mediaDevices.getUserMedia({ audio: true }).then((audioStream) => { + microphoneReady = true; + if (!authorizedBefore) { + audioStream.getTracks().forEach((track) => track.stop()); + throw new Error('recording-permission-ready'); + } + if (!shouldStart()) { + audioStream.getTracks().forEach((track) => track.stop()); + throw new Error('recording-cancelled'); + } startTime = new Date().getTime(); chunks = []; stream = audioStream; rc = new MediaRecorder(stream); rc.start(); + }).catch((error) => { + if (error?.name === 'NotAllowedError' || error?.name === 'PermissionDeniedError') { + throw new Error('浏览器未允许麦克风,请在网站权限中开启后重试'); + } + throw error; }); }; diff --git a/src/common/wssocket.js b/src/common/wssocket.js index fc45eaf0..18d941d1 100644 --- a/src/common/wssocket.js +++ b/src/common/wssocket.js @@ -9,6 +9,19 @@ let rec = null; let connectRetryTimer = null; let lastConnectTime = 0; let socketTask = null; +/** 极光安装实例和 App 前后台状态,仅由原生推送模块更新。 */ +let pushState = { registrationId: '', foreground: true }; +/** WebSocket 业务登录已经完成,避免连接建立与认证之间提前发送状态心跳。 */ +let authenticated = false; + +/** 生成向后兼容的可选设备状态,未接入原生极光的终端保持原协议。 */ +const pushHeartbeatData = () => pushState.registrationId ? { ...pushState } : {}; + +/** 更新原生设备状态并立即发送心跳,进入后台时保持现有 IM 连接行为。 */ +const setPushState = (state) => { + pushState = { ...pushState, ...state }; + if (authenticated) heartCheck.start(); +}; const clearReconnectTimers = () => { if (rec) { @@ -44,6 +57,7 @@ const finishConnection = (task, event) => { } socketTask = null; isConnect = false; + authenticated = false; heartCheck.stop(); closeCallBack && closeCallBack(event); }; @@ -123,6 +137,7 @@ let connect = (url, token) => { cmd: 0, data: { accessToken: token, + ...pushHeartbeatData(), }, }), }); @@ -141,6 +156,7 @@ let connect = (url, token) => { } if (sendInfo.cmd == 0) { + authenticated = true; heartCheck.start(); connectCallBack && connectCallBack(); console.log('WebSocket登录成功'); @@ -201,6 +217,7 @@ let close = (code = 1000) => { clearReconnectTimers(); heartCheck.stop(); isConnect = false; + authenticated = false; if (!socketTask) { return; } @@ -216,6 +233,7 @@ let destroy = (code = 1000) => { const task = socketTask; socketTask = null; isConnect = false; + authenticated = false; accessToken = ''; wsUrl = ''; closeSocketTask(task, code); @@ -230,7 +248,7 @@ let heartCheck = { sendMessage( JSON.stringify({ cmd: 1, - data: {}, + data: pushHeartbeatData(), }), ); } @@ -275,4 +293,4 @@ let onClose = (callback) => { closeCallBack = callback; }; -export { connect, reconnect, close, destroy, sendMessage, onConnect, onMessage, onClose }; +export { connect, reconnect, close, destroy, sendMessage, onConnect, onMessage, onClose, setPushState }; diff --git a/src/components/chat-record/chat-record.vue b/src/components/chat-record/chat-record.vue index 28685d30..af8f1aa8 100644 --- a/src/components/chat-record/chat-record.vue +++ b/src/components/chat-record/chat-record.vue @@ -8,6 +8,7 @@ @touchstart.prevent="onStartRecord" @touchmove.prevent="onTouchMove" @touchend.prevent="onEndRecord" + @touchcancel.prevent="onCancel" > {{ recording ? '正在录音' : '长按 说话' }} @@ -42,6 +43,9 @@ data() { return { recording: false, + // 授权和录音启动期间跟踪手势,松手后不再启动录音。 + holding: false, + starting: false, moveToCancel: false, recordBarTop: 0, druation: 0, @@ -54,40 +58,51 @@ this.moveToCancel = moveY < this.recordBarTop - 40; }, onCancel() { + this.holding = false; if (this.recording) { this.moveToCancel = true; this.onEndRecord(); } }, onStartRecord() { - /* 用户第一次使用语音会唤醒录音权限请求,此时会导致@touchend失效, - 一直处于录音状态,这里允许用户再次点击发送语音并结束录音 */ - if (this.recording) { - return; - } + // 权限用途弹框可能吞掉松手事件,首次授权完成后需要新的长按手势。 + if (this.recording || this.starting) return; + this.holding = true; + this.starting = true; console.log('开始录音'); this.moveToCancel = false; this.initRecordBar(); if (!this.$rc.checkIsEnable()) { + this.starting = false; return; } this.$rc - .start() + .start(() => this.holding) .then(() => { + if (!this.holding) { + this.$rc.close(); + return; + } this.recording = true; console.log('开始录音成功'); // 开始计时 this.startTimer(); }) .catch((e) => { + if (e?.message === 'recording-cancelled') return; + if (e?.message === 'recording-permission-ready') { + uni.showToast({ title: '麦克风已开启,请再次长按录音', icon: 'none' }); + return; + } console.log('录音失败' + JSON.stringify(e)); uni.showToast({ - title: '录音失败', + title: e?.message || '录音失败,请检查麦克风权限', icon: 'none', }); - }); + }).finally(() => { this.starting = false; }); }, onEndRecord() { + this.holding = false; if (!this.recording) { return; } @@ -162,6 +177,8 @@ }, unmounted() { this.stopTimer(); + this.holding = false; + if (this.recording) this.$rc.close(); this.recording = false; }, }; diff --git a/src/components/image-upload/image-upload.vue b/src/components/image-upload/image-upload.vue index ba949690..2e3b5166 100644 --- a/src/components/image-upload/image-upload.vue +++ b/src/components/image-upload/image-upload.vue @@ -6,6 +6,7 @@ + + diff --git a/src/pages/profile/face-verify/ali-face-verify.ts b/src/pages/profile/face-verify/ali-face-verify.ts index d46d2c97..e806697f 100644 --- a/src/pages/profile/face-verify/ali-face-verify.ts +++ b/src/pages/profile/face-verify/ali-face-verify.ts @@ -1,3 +1,4 @@ +import { ensurePermission, handlePermissionFailure } from '@/sheep/util/permission'; import RecentApi from '@/sheep/api/member/recent'; // @ts-ignore import sheep from '@/sheep/index'; @@ -6,7 +7,7 @@ import sheep from '@/sheep/index'; * @param certifyId 阿里人脸认证id * @param authId 发起认证的id */ -const verify = (certifyId: string, authId: string) => { +const verify = async (certifyId: string, authId: string) => { if (!certifyId) return uni.showToast({ title: 'certifyId不存在', icon: 'none' }); if (!authId) return uni.showToast({ title: 'authId不存在', icon: 'none' }); // const p = uni.getSystemInfoSync().platform; @@ -14,7 +15,14 @@ const verify = (certifyId: string, authId: string) => { // } console.log('certifyId', certifyId); + // 原生认证 SDK 仅存在于 Android/iOS,浏览器和小程序保持能力提示。 + // #ifndef APP-PLUS + return uni.showToast({ title: '人脸认证仅支持 Android 或 iOS App', icon: 'none' }); + // #endif + // #ifdef APP-PLUS + if (!await ensurePermission('camera', { feature: '实人认证', purpose: '采集您本人面部画面完成身份核验' })) return; const aliyunVerify = uni.requireNativePlugin('AP-FaceDetectModule'); + if (!aliyunVerify?.verify) return uni.showToast({ title: '当前安装包未包含实人认证能力,请更新完整安装包', icon: 'none' }); aliyunVerify.verify( { certifyId, @@ -49,6 +57,7 @@ const verify = (certifyId: string, authId: string) => { uni.showToast({ title: '系统错误', icon: 'none' }); break; case 1003: + void handlePermissionFailure('camera', response); RecentApi.cancelFaceVerify({ authId }); uni.showToast({ title: '验证中断', icon: 'none' }); break; @@ -63,6 +72,7 @@ const verify = (certifyId: string, authId: string) => { } }, ); + // #endif }; export default verify; diff --git a/src/pages/profile/face-verify/boy-verify.vue b/src/pages/profile/face-verify/boy-verify.vue index 51b4739b..713d1ac3 100644 --- a/src/pages/profile/face-verify/boy-verify.vue +++ b/src/pages/profile/face-verify/boy-verify.vue @@ -35,6 +35,7 @@ + + diff --git a/src/pages/wechat/pages/inviter/__tests__/inviter.test.mjs b/src/pages/wechat/pages/inviter/__tests__/inviter.test.mjs new file mode 100644 index 00000000..7492aca8 --- /dev/null +++ b/src/pages/wechat/pages/inviter/__tests__/inviter.test.mjs @@ -0,0 +1,353 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import vm from 'node:vm'; +import ts from 'typescript'; +import * as vue from 'vue'; +import { parse, compileTemplate } from 'vue/compiler-sfc'; + +const filename = new URL('../index.vue', import.meta.url); +const source = await readFile(filename, 'utf8'); +const { descriptor } = parse(source); +const transpile = (source) => ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS }, +}).outputText; +function loadModule(source, imports = {}) { + const context = { + exports: {}, + URLSearchParams, + require: (name) => { + if (Object.hasOwn(imports, name)) return imports[name]; + throw new Error(`Unexpected import ${name}`); + }, + }; + vm.runInNewContext(transpile(source), context); + return context.exports; +} +const hookModule = { exports: {} }; +vm.runInNewContext(transpile(await readFile(new URL('../../../../../sheep/hooks/use-scroll.ts', import.meta.url), 'utf8')), { + exports: hookModule.exports, + require: (name) => { + if (name === 'vue') return vue; + throw new Error(`Unexpected import ${name}`); + }, +}); +const deferred = () => { + let resolve; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +}; +const page = (list, total = list.length) => ({ code: 0, data: { list, total } }); +const summary = () => ({ code: 0, data: { firstBrokerageUserCount: 43, secondBrokerageUserCount: 13 } }); +function fixture(overrides = {}) { + const calls = { pages: [], summaries: 0 }; + const lifecycle = {}; + const api = { + getBrokerageUserChildSummaryPage: async () => page([]), + getBrokerageUserSummary: async () => summary(), + ...overrides, + }; + const fetchPage = api.getBrokerageUserChildSummaryPage; + api.getBrokerageUserChildSummaryPage = (query) => { calls.pages.push({ ...query }); return fetchPage(query); }; + const fetchSummary = api.getBrokerageUserSummary; + api.getBrokerageUserSummary = () => { calls.summaries += 1; return fetchSummary(); }; + const context = { + exports: {}, + require: (name) => { + if (name === 'vue') return { ...vue, onBeforeUnmount: (fn) => { lifecycle.unmounted = fn; } }; + if (name === '@dcloudio/uni-app') return { + onShow: (fn) => { lifecycle.shown = fn; }, + onHide: (fn) => { lifecycle.hidden = fn; }, + }; + if (name === '@/sheep/hooks/use-scroll') return { default: hookModule.exports.default }; + if (name === '@/sheep/api/trade/brokerage') return { default: api }; + throw new Error(`Unexpected import ${name}`); + }, + }; + const scope = vue.effectScope(); + scope.run(() => vm.runInNewContext(transpile(descriptor.scriptSetup.content) + + '\nglobalThis.state = { state, activeTab, listData, listLoadMore, error, retry, handleSearch, tabsChange, refreshSummary, refreshTeam, incomeUnavailable, sortOptions, toggleSort, resetSort, handleSortKeydown };', context)); + return { ...context, api, calls, lifecycle, stop: () => { lifecycle.unmounted(); scope.stop(); } }; +} + +test('team template compiles and displays separate contribution and personal income summaries', () => { + assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename: filename.pathname, id: 'team' }).errors, []); + assert.match(source, /贡献按当前会员实际收到的佣金统计/); + assert.match(source, /本人订单佣金单数/); + assert.match(source, /member\.brokerageOrderCount \?\? '—'/); + assert.doesNotMatch(source, /member\.orderCount/); + assert.match(source, /formatBrokeragePrice\(member\.contributionSummary\?\.settledPrice\)/); + assert.match(source, /formatBrokeragePrice\(member\.contributionSummary\?\.pendingPrice\)/); + assert.match(source, /formatBrokeragePrice\(member\.brokerageSummary\?\.settledPrice\)/); + assert.match(source, /formatBrokeragePrice\(member\.brokerageSummary\?\.pendingPrice\)/); + assert.doesNotMatch(source, /member\.brokeragePrice/); + assert.match(source, /@search="handleSearch" @change="handleSearch"/); +}); + +test('page show and pull refresh fetch list and team counts, including successful zero counts', async () => { + const h = fixture(); + await h.lifecycle.shown(); + assert.equal(h.calls.pages.length, 1); + assert.equal(h.calls.summaries, 1); + assert.equal(h.state.state.summary.firstBrokerageUserCount, 43); + let completed = false; + h.api.getBrokerageUserSummary = async () => ({ code: 0, data: { firstBrokerageUserCount: 0, secondBrokerageUserCount: 0 } }); + await h.state.refreshTeam(() => { completed = true; }); + assert.equal(completed, true); + assert.equal(h.state.state.summary.firstBrokerageUserCount, 0); + assert.equal(h.state.state.summary.secondBrokerageUserCount, 0); + h.stop(); +}); + +test('search and clear use the event value immediately and reset pagination', async () => { + const h = fixture(); + await h.lifecycle.shown(); + await h.state.handleSearch({ value: '会员' }); + assert.equal(h.calls.pages.at(-1).nickname, '会员'); + assert.equal(h.calls.pages.at(-1).pageNo, 1); + await h.state.handleSearch({ value: '' }); + assert.equal(h.calls.pages.at(-1).nickname, ''); + assert.equal(h.state.state.queryParams.nickname, ''); + h.stop(); +}); + +test('fast level changes preserve the selected level when older responses arrive last', async () => { + const first = deferred(); + const h = fixture({ getBrokerageUserChildSummaryPage: ({ level }) => level === 1 + ? first.promise : Promise.resolve(page([{ id: 2, level: 2 }])) }); + const shown = h.lifecycle.shown(); + await h.state.tabsChange({ index: 1 }); + first.resolve(page([{ id: 1, level: 1 }])); + await shown; + assert.equal(h.state.activeTab.value, 1); + assert.equal(h.state.listData.value[0].level, 2); + assert.equal(h.calls.pages.at(-1).level, 2); + h.stop(); +}); + +test('summary errors expose an error without inventing zero counts and allow retry', async () => { + const h = fixture({ getBrokerageUserSummary: async () => ({ code: 500, data: null }) }); + await h.lifecycle.shown(); + assert.equal(h.state.state.summary, null); + assert.match(h.state.state.summaryError, /加载失败/); + h.api.getBrokerageUserSummary = async () => summary(); + await h.state.refreshSummary(); + assert.equal(h.state.state.summaryError, ''); + assert.equal(h.state.state.summary.secondBrokerageUserCount, 13); + h.stop(); +}); + +test('late summary and list responses are ignored after page unmount', async () => { + const list = deferred(); + const counts = deferred(); + const h = fixture({ getBrokerageUserChildSummaryPage: () => list.promise, getBrokerageUserSummary: () => counts.promise }); + const shown = h.lifecycle.shown(); + h.stop(); + list.resolve(page([{ id: 1 }])); + counts.resolve(summary()); + await shown; + assert.equal(h.state.state.summary, null); + assert.equal(h.state.listData.value.length, 0); +}); + +test('missing upgraded income fields produce an upgrade notice while successful zero summaries remain valid', async () => { + const h = fixture({ getBrokerageUserChildSummaryPage: async ({ nickname }) => nickname === 'upgraded' + ? page([{ + id: 1, + brokerageSummary: { settledPrice: '900719925474099301', pendingPrice: 0 }, + contributionSummary: { settledPrice: 0, pendingPrice: '0' }, + }]) + : page([{ id: 1, brokeragePrice: 12345 }]), + }); + await h.lifecycle.shown(); + assert.equal(h.state.incomeUnavailable.value, true); + assert.match(source, /收益统计暂不可用,请更新服务后重试/); + await h.state.handleSearch({ value: 'upgraded' }); + assert.equal(h.state.incomeUnavailable.value, false); + assert.equal(h.state.listData.value[0].contributionSummary.settledPrice, 0); + assert.equal(h.state.listData.value[0].brokerageSummary.settledPrice, '900719925474099301'); + h.stop(); +}); + +test('hiding clears team counts and a same-filter return fetches a fresh account context', async () => { + const oldList = deferred(); + const oldSummary = deferred(); + let listRequests = 0; + let summaryRequests = 0; + const h = fixture({ + getBrokerageUserChildSummaryPage: () => ++listRequests === 1 + ? oldList.promise : Promise.resolve(page([{ id: 'current-account' }])), + getBrokerageUserSummary: () => ++summaryRequests === 1 ? oldSummary.promise : Promise.resolve({ + code: 0, data: { firstBrokerageUserCount: 1, secondBrokerageUserCount: 0 }, + }), + }); + const previous = h.lifecycle.shown(); + h.state.state.summary = { firstBrokerageUserCount: 43, secondBrokerageUserCount: 13 }; + h.lifecycle.hidden(); + assert.equal(h.state.state.summary, null); + await h.lifecycle.shown(); + oldList.resolve(page([{ id: 'previous-account' }])); + oldSummary.resolve(summary()); + await previous; + assert.equal(h.calls.pages.length, 2); + assert.equal(h.state.listData.value[0].id, 'current-account'); + assert.equal(h.state.state.summary.firstBrokerageUserCount, 1); + assert.equal(h.state.state.summary.secondBrokerageUserCount, 0); + h.stop(); +}); + +test('sorting controls use native buttons with direction feedback and preserve initial join-time order', async () => { + const h = fixture(); + await h.lifecycle.shown(); + assert.equal(h.calls.pages[0]['sortingField.field'], undefined); + assert.equal(h.calls.pages[0]['sortingField.order'], undefined); + assert.ok(h.state.sortOptions.value.every((option) => !option.active && option.order === undefined)); + assert.match(source, /role="group" aria-label="团队金额排序"/); + assert.match(source, /]*v-for="option in sortOptions"/); + assert.match(source, /:aria-pressed="option.active"/); + assert.match(source, /:aria-label="option.ariaLabel"/); + assert.match(source, /option.order === 'asc'/); + assert.match(source, /option.order === 'desc'/); + assert.match(source, /aria-hidden="true"/); + assert.match(source, /min-height: 30px/); + assert.match(source, /border-radius: 0 !important/); + assert.doesNotMatch(source, /text-decoration: underline/); + assert.match(source, /&:focus-visible/); + assert.match(source, /恢复默认加入时间排序/); + const sortingButtons = Array.from(descriptor.template.content.matchAll(/]*>/g), (match) => match[0]); + assert.equal(sortingButtons.length, 2); + assert.ok(sortingButtons.every((button) => button.includes('role="button"') && button.includes('tabindex="0"'))); + assert.ok(sortingButtons.every((button) => button.includes('@keydown="handleSortKeydown'))); + assert.match(source, /:deep\(\.uni-scroll-view\)\s*\{\s*scrollbar-width: none/); + assert.match(source, /:deep\(\.uni-scroll-view::-webkit-scrollbar\)/); + assert.doesNotMatch(source, /\.sort\(/); + h.stop(); +}); + +test('same-field taps toggle descending and ascending, changing fields starts descending, and default clears both', async () => { + const h = fixture(); + await h.lifecycle.shown(); + await h.state.toggleSort('contributionPrice'); + assert.equal(h.calls.pages.at(-1)['sortingField.field'], 'contributionPrice'); + assert.equal(h.calls.pages.at(-1)['sortingField.order'], 'desc'); + assert.equal(h.state.sortOptions.value[0].active, true); + assert.equal(h.state.sortOptions.value[0].order, 'desc'); + assert.match(h.state.sortOptions.value[0].ariaLabel, /当前从高到低,点击切换从低到高/); + await h.state.toggleSort('contributionPrice'); + assert.equal(h.calls.pages.at(-1)['sortingField.order'], 'asc'); + assert.match(h.state.sortOptions.value[0].ariaLabel, /当前从低到高,点击切换从高到低/); + await h.state.toggleSort('incomePrice'); + assert.equal(h.calls.pages.at(-1)['sortingField.field'], 'incomePrice'); + assert.equal(h.calls.pages.at(-1)['sortingField.order'], 'desc'); + assert.equal(h.state.sortOptions.value[0].active, false); + assert.equal(h.state.sortOptions.value[0].order, undefined); + assert.equal(h.state.sortOptions.value[1].active, true); + await h.state.toggleSort('incomePrice'); + assert.equal(h.calls.pages.at(-1)['sortingField.order'], 'asc'); + await h.state.resetSort(); + assert.equal(h.calls.pages.at(-1)['sortingField.field'], undefined); + assert.equal(h.calls.pages.at(-1)['sortingField.order'], undefined); + assert.ok(h.calls.pages.every((query) => query.pageNo === 1)); + h.stop(); +}); + +test('sorting resets pagination while search and level changes retain the selected sort and backend record order', async () => { + const h = fixture({ getBrokerageUserChildSummaryPage: async ({ pageNo }) => page( + pageNo === 1 ? [{ id: 30 }, { id: 10 }] : [{ id: 20 }], 3, + ) }); + await h.lifecycle.shown(); + await h.state.listLoadMore(); + assert.equal(h.calls.pages.at(-1).pageNo, 2); + await h.state.handleSearch({ value: '会员 & 测试' }); + await h.state.tabsChange({ index: 1 }); + await h.state.toggleSort('contributionPrice'); + assert.equal(h.calls.pages.at(-1).pageNo, 1); + assert.equal(h.calls.pages.at(-1).level, 2); + assert.equal(h.calls.pages.at(-1).nickname, '会员 & 测试'); + await h.state.listLoadMore(); + assert.equal(h.calls.pages.at(-1).pageNo, 2); + assert.equal(h.calls.pages.at(-1)['sortingField.order'], 'desc'); + assert.deepEqual(Array.from(h.state.listData.value, (member) => member.id), [30, 10, 20]); + await h.state.handleSearch({ value: '' }); + assert.equal(h.calls.pages.at(-1).pageNo, 1); + assert.equal(h.calls.pages.at(-1)['sortingField.field'], 'contributionPrice'); + assert.equal(h.calls.pages.at(-1)['sortingField.order'], 'desc'); + await h.state.tabsChange({ index: 0 }); + assert.equal(h.calls.pages.at(-1).level, 1); + assert.equal(h.calls.pages.at(-1)['sortingField.order'], 'desc'); + h.stop(); +}); + +test('late descending responses cannot replace a newer ascending selection', async () => { + const descending = deferred(); + const h = fixture({ getBrokerageUserChildSummaryPage: (query) => query['sortingField.order'] === 'desc' + ? descending.promise : Promise.resolve(page([{ id: 1, direction: query['sortingField.order'] }])), + }); + await h.lifecycle.shown(); + const old = h.state.toggleSort('incomePrice'); + await h.state.toggleSort('incomePrice'); + descending.resolve(page([{ id: 9, direction: 'desc' }])); + await old; + assert.equal(h.state.listData.value[0].direction, 'asc'); + assert.equal(h.state.sortOptions.value[1].order, 'asc'); + assert.equal(h.calls.pages.at(-1).pageNo, 1); + h.stop(); +}); + +test('real request encoding preserves dotted sorting keys, filters, and default omission without mutating callers', async () => { + const utils = loadModule(await readFile(new URL('../../../../../../node_modules/luch-request/src/lib/utils.js', import.meta.url), 'utf8')); + const buildURL = loadModule(await readFile(new URL('../../../../../../node_modules/luch-request/src/lib/helpers/buildURL.js', import.meta.url), 'utf8'), { + './../utils': utils, + }).default; + let config; + const api = loadModule(await readFile(new URL('../../../../../sheep/api/trade/brokerage.js', import.meta.url), 'utf8'), { + '@/sheep/request': { default: async (options) => { config = options; return page([]); } }, + }).default; + const filters = Object.freeze({ + pageNo: 2, pageSize: 10, level: 2, nickname: '会员 & + / ? # = 测试', + 'sortingField.field': 'contributionPrice', 'sortingField.order': 'asc', + }); + await api.getBrokerageUserChildSummaryPage(filters); + assert.notEqual(config.params, filters); + const url = new URL(buildURL(config.url, config.params), 'http://localhost'); + assert.equal(url.pathname, '/trade/brokerage-user/child-summary-page'); + assert.equal(url.searchParams.get('sortingField.field'), 'contributionPrice'); + assert.equal(url.searchParams.get('sortingField.order'), 'asc'); + assert.equal(url.searchParams.get('nickname'), filters.nickname); + assert.equal(url.searchParams.get('level'), '2'); + assert.equal(url.searchParams.get('pageNo'), '2'); + await api.getBrokerageUserChildSummaryPage({ 'sortingField.field': undefined, 'sortingField.order': undefined }); + const defaultUrl = new URL(buildURL(config.url, config.params), 'http://localhost'); + assert.equal(defaultUrl.searchParams.has('sortingField.field'), false); + assert.equal(defaultUrl.searchParams.has('sortingField.order'), false); +}); + +test('Enter and Space activate sorting once, prevent browser defaults, and ignore held-key repeats', async () => { + const h = fixture(); + await h.lifecycle.shown(); + const keyboardEvent = (key, repeat = false) => Object.assign(new Event('keydown', { cancelable: true }), { key, repeat }); + const enter = keyboardEvent('Enter'); + await h.state.handleSortKeydown(enter, 'contributionPrice'); + assert.equal(enter.defaultPrevented, true); + assert.equal(h.calls.pages.length, 2); + assert.equal(h.calls.pages.at(-1)['sortingField.order'], 'desc'); + const repeat = keyboardEvent('Enter', true); + await h.state.handleSortKeydown(repeat, 'contributionPrice'); + assert.equal(repeat.defaultPrevented, true); + assert.equal(h.calls.pages.length, 2); + const space = keyboardEvent(' '); + await h.state.handleSortKeydown(space, 'contributionPrice'); + assert.equal(space.defaultPrevented, true); + assert.equal(h.calls.pages.length, 3); + assert.equal(h.calls.pages.at(-1)['sortingField.order'], 'asc'); + const tab = keyboardEvent('Tab'); + await h.state.handleSortKeydown(tab, 'incomePrice'); + assert.equal(tab.defaultPrevented, false); + assert.equal(h.calls.pages.length, 3); + const restore = keyboardEvent('Enter'); + await h.state.handleSortKeydown(restore); + assert.equal(restore.defaultPrevented, true); + assert.equal(h.calls.pages.length, 4); + assert.equal(h.calls.pages.at(-1)['sortingField.field'], undefined); + h.stop(); +}); diff --git a/src/pages/wechat/pages/inviter/index.vue b/src/pages/wechat/pages/inviter/index.vue index 04f6fb6c..54c5be11 100644 --- a/src/pages/wechat/pages/inviter/index.vue +++ b/src/pages/wechat/pages/inviter/index.vue @@ -1,116 +1,345 @@ - - + diff --git a/src/sheep/api/member/app-version.ts b/src/sheep/api/member/app-version.ts new file mode 100644 index 00000000..73a096d0 --- /dev/null +++ b/src/sheep/api/member/app-version.ts @@ -0,0 +1,51 @@ +import request from '@/sheep/request'; + +export interface AppVersionQuery { + appId: string; + platform: 'android' | 'ios' | 'harmony'; + nativeVersionCode: number; + resourceVersionCode: number; + packageName?: string; +} + +export interface AppVersionRelease { + id: number; + packageId: number; + appId: string; + platform: AppVersionQuery['platform']; + platforms?: AppVersionQuery['platform'][]; + packageType: 'apk' | 'wgt'; + versionCode: number; + versionName: string; + nativeVersionCode?: number; + resourceVersionCode: number; + packageName?: string; + minNativeVersionCode?: number; + maxNativeVersionCode?: number; + forceUpdate: boolean; + updateContent: string; + downloadUrl: string; + fileName: string; + fileSize: number; + sha256: string; + md5: string; +} + +const custom = { showLoading: false, showError: false }; + +export default { + check: (params: AppVersionQuery) => request({ + url: '/member/app-version/check', + method: 'GET', + params, + sslVerify: true, + custom, + }), + validate: (params: AppVersionQuery & { id: number }) => request({ + url: '/member/app-version/validate', + method: 'GET', + params, + sslVerify: true, + custom, + }), +}; diff --git a/src/sheep/api/member/push.js b/src/sheep/api/member/push.js new file mode 100644 index 00000000..0bc803cc --- /dev/null +++ b/src/sheep/api/member/push.js @@ -0,0 +1,23 @@ +import request from '@/sheep/request'; + +/** 当前设备推送接口使用调用时的令牌,隔离退出或换账号后才完成的旧请求。 */ +const deviceRequest = (url, data, token) => request({ + url, + method: 'POST', + data, + header: { Authorization: token }, + custom: { + isToken: false, + skipAuthRefresh: true, + showLoading: false, + showError: false, + }, +}); + +/** 复用会员服务现有设备绑定契约,会员身份始终由服务端令牌确定。 */ +export default { + /** 绑定当前安装实例到已登录会员,允许同设备重新登录后覆盖旧归属。 */ + bind: (data, token) => deviceRequest('/member/push/bind', data, token), + /** 仅解除调用时会员拥有的当前安装实例。 */ + unbind: (registrationId, token) => deviceRequest('/member/push/unbind', { registrationId }, token), +}; diff --git a/src/sheep/api/trade/brokerage.js b/src/sheep/api/trade/brokerage.js index f4ff6fe9..a7b2a82b 100644 --- a/src/sheep/api/trade/brokerage.js +++ b/src/sheep/api/trade/brokerage.js @@ -27,16 +27,11 @@ const BrokerageApi = { }); }, // 获得分销记录分页 - getBrokerageRecordPage: (params) => { - if (params.status === undefined) { - delete params.status; - } - const queryString = Object.keys(params) - .map((key) => encodeURIComponent(key) + '=' + params[key]) - .join('&'); + getBrokerageRecordPage: (params = {}) => { return request({ - url: `/trade/brokerage-record/page?${queryString}`, + url: '/trade/brokerage-record/page', method: 'GET', + params: { ...params }, }); }, // 创建分销提现 @@ -102,11 +97,11 @@ const BrokerageApi = { }); }, // 获得下级分销统计分页 - getBrokerageUserChildSummaryPage: (params) => { + getBrokerageUserChildSummaryPage: (params = {}) => { return request({ url: '/trade/brokerage-user/child-summary-page', method: 'GET', - params, + params: { ...params }, }); }, // 获取分销码和分销海报 diff --git a/src/sheep/components/s-search-block/s-search-block.vue b/src/sheep/components/s-search-block/s-search-block.vue index 0e7bff78..be3b41d6 100644 --- a/src/sheep/components/s-search-block/s-search-block.vue +++ b/src/sheep/components/s-search-block/s-search-block.vue @@ -86,6 +86,7 @@ import { computed, reactive } from 'vue'; import sheep from '@/sheep'; + import { prepareMediaSelection, handlePermissionFailure } from '@/sheep/util/permission'; // 组件数据 const state = reactive({ @@ -166,15 +167,32 @@ /** * 扫一扫功能 */ - function onScan() { + async function onScan() { + const sources = await prepareMediaSelection(['camera', 'album'], 'image', { + feature: '识别二维码', purpose: '使用相机或您主动选择的图片识别二维码与条码', + }); + if (!sources) return; + // #ifdef APP-PLUS + if (sources[0] === 'album') { + uni.chooseImage({ + count: 1, sourceType: ['album'], + fail: (error) => { void handlePermissionFailure('album-read', error); }, + success: (result) => plus.barcode.scan(result.tempFilePaths[0], + (_type, content) => showScanResult(content), + () => uni.showToast({ title: '图片中未识别到二维码或条码', icon: 'none' })), + }); + return; + } + // #endif uni.scanCode({ - onlyFromCamera: false, + onlyFromCamera: sources.length === 1, sound: 'default', scanType: ['qrCode', 'barCode'], success: (res) => { showScanResult(res.result); }, fail: (err) => { + void handlePermissionFailure('camera', err); console.error(err); uni.showToast({ title: err.errMsg === 'scanCode:fail cancel' ? '操作已取消' : '扫码失败', diff --git a/src/sheep/components/s-share-modal/canvas-poster/index.vue b/src/sheep/components/s-share-modal/canvas-poster/index.vue index aa4e4581..824d5085 100644 --- a/src/sheep/components/s-share-modal/canvas-poster/index.vue +++ b/src/sheep/components/s-share-modal/canvas-poster/index.vue @@ -33,6 +33,7 @@ */ import { reactive, ref, unref } from 'vue'; import sheep from '@/sheep'; +import { saveImageToPhotosAlbum } from '@/sheep/util/file-tool'; import { getPosterData } from '@/sheep/components/s-share-modal/canvas-poster/poster'; const props = defineProps({ @@ -80,16 +81,12 @@ const onSavePoster = () => { } // 非H5 保存到相册 - uni.saveImageToPhotosAlbum({ - filePath: painterImageUrl.value, - success: (res) => { - onClosePoster(); - sheep.$helper.toast('保存成功'); - }, - fail: (err) => { - sheep.$helper.toast('保存失败'); - console.log('图片保存失败:', err); - }, + saveImageToPhotosAlbum(painterImageUrl.value, () => { + onClosePoster(); + sheep.$helper.toast('保存成功'); + }, (err) => { + sheep.$helper.toast('保存失败'); + console.log('图片保存失败:', err); }); }; diff --git a/src/sheep/components/s-uploader/choose-and-upload-file.js b/src/sheep/components/s-uploader/choose-and-upload-file.js index bc17d02c..1b343418 100644 --- a/src/sheep/components/s-uploader/choose-and-upload-file.js +++ b/src/sheep/components/s-uploader/choose-and-upload-file.js @@ -1,25 +1,31 @@ 'use strict'; import FileApi from '@/sheep/api/infra/file'; +import { prepareMediaSelection, handlePermissionFailure } from '@/sheep/util/permission'; const ERR_MSG_FAIL = 'chooseAndUploadFile:fail'; -function chooseImage(opts) { +async function chooseImage(opts) { const { count, sizeType = ['original', 'compressed'], sourceType = ['album', 'camera'], extension, } = opts; + const sources = await prepareMediaSelection(sourceType, 'image', { + feature: '上传图片', purpose: '选择您主动提交的头像、动态或其他业务图片', + }); + if (!sources) throw { errMsg: ERR_MSG_FAIL + ' cancel' }; return new Promise((resolve, reject) => { uni.chooseImage({ count, sizeType, - sourceType, + sourceType: sources, extension, success(res) { resolve(normalizeChooseAndUploadFileRes(res, 'image')); }, fail(res) { + void handlePermissionFailure(sources[0] === 'camera' ? 'camera' : 'album-read', res); reject({ errMsg: res.errMsg.replace('chooseImage:fail', ERR_MSG_FAIL), }); @@ -28,7 +34,7 @@ function chooseImage(opts) { }); } -function chooseVideo(opts) { +async function chooseVideo(opts) { const { count, camera, @@ -37,13 +43,17 @@ function chooseVideo(opts) { sourceType = ['album', 'camera'], extension, } = opts; + const sources = await prepareMediaSelection(sourceType, 'video', { + feature: '上传视频', purpose: '选择您主动发布的视频动态', + }); + if (!sources) throw { errMsg: ERR_MSG_FAIL + ' cancel' }; return new Promise((resolve, reject) => { // 微信小程序的 chooseMedia 会直接返回视频缩略图。 // #ifdef MP-WEIXIN uni.chooseMedia({ count, mediaType: ['video'], - sourceType, + sourceType: sources, sizeType: compressed ? ['compressed'] : ['original'], maxDuration, camera, @@ -70,6 +80,7 @@ function chooseVideo(opts) { ); }, fail(res) { + void handlePermissionFailure('camera', res); reject({ errMsg: res.errMsg.replace('chooseMedia:fail', ERR_MSG_FAIL), }); @@ -82,7 +93,7 @@ function chooseVideo(opts) { camera, compressed, maxDuration, - sourceType, + sourceType: sources, extension, success(res) { const { tempFilePath, thumbTempFilePath, duration, size, height, width } = res; @@ -111,6 +122,7 @@ function chooseVideo(opts) { ); }, fail(res) { + void handlePermissionFailure(sources[0] === 'camera' ? 'camera' : 'album-read', res); reject({ errMsg: res.errMsg.replace('chooseVideo:fail', ERR_MSG_FAIL), }); diff --git a/src/sheep/components/scroll-list/scroll-list.vue b/src/sheep/components/scroll-list/scroll-list.vue index c5734d1f..af2b4243 100644 --- a/src/sheep/components/scroll-list/scroll-list.vue +++ b/src/sheep/components/scroll-list/scroll-list.vue @@ -28,7 +28,7 @@ const emit = defineEmits<{ const refresherTriggered = ref(false); const loadMoreContent = reactive({ - contentdown: '正在加载...', + contentdown: '上拉加载更多', contentrefresh: '正在加载...', contentnomore: '没有更多数据了', }); diff --git a/src/sheep/hooks/__tests__/use-scroll.test.mjs b/src/sheep/hooks/__tests__/use-scroll.test.mjs new file mode 100644 index 00000000..838b4b03 --- /dev/null +++ b/src/sheep/hooks/__tests__/use-scroll.test.mjs @@ -0,0 +1,240 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import vm from 'node:vm'; +import ts from 'typescript'; +import * as vue from 'vue'; + +const source = await readFile(new URL('../use-scroll.ts', import.meta.url), 'utf8'); +const script = ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS }, +}).outputText; +const module = { exports: {} }; +vm.runInNewContext(script, { + exports: module.exports, + require: (name) => { + if (name === 'vue') return vue; + throw new Error(`Unexpected import ${name}`); + }, +}); +const useScroll = module.exports.default; +const page = (list, total = list.length) => ({ code: 0, data: { list, total } }); +const deferred = () => { + let resolve; + let reject; + const promise = new Promise((done, fail) => { resolve = done; reject = fail; }); + return { promise, resolve, reject }; +}; +function fixture(fetch, initial = {}) { + const calls = []; + const params = vue.reactive(initial); + const scope = vue.effectScope(); + const scroll = scope.run(() => useScroll((query) => { + calls.push({ ...query }); + return fetch(query); + }, params)); + return { scroll, params, calls, stop: () => scope.stop() }; +} + +test('refresh callback contract remains compatible and non-function event payloads are accepted', async () => { + const h = fixture(async () => page([], 0)); + let completed = 0; + await h.scroll.refreshList(() => { completed += 1; }); + await h.scroll.refreshList({ value: 'search' }); + assert.equal(completed, 1); + assert.equal(h.scroll.error.value, ''); + assert.equal(h.scroll.loadMoreStatus.value, 'noMore'); + h.stop(); +}); + +test('pagination advances only on success, blocks duplicate loads, and retries the failed page', async () => { + const secondPage = deferred(); + let secondAttempts = 0; + const h = fixture(({ pageNo }) => { + if (pageNo === 1) return Promise.resolve(page([{ id: 1 }], 3)); + if (pageNo === 2 && ++secondAttempts === 1) return secondPage.promise; + return Promise.resolve(page([{ id: pageNo }], 3)); + }); + await h.scroll.refreshList(); + const loading = h.scroll.listLoadMore(); + assert.equal(h.scroll.loadMoreStatus.value, 'loading'); + const duplicate = h.scroll.listLoadMore(); + assert.equal(h.calls.length, 2); + assert.equal(duplicate, loading); + secondPage.reject(new Error('offline')); + await loading; + assert.match(h.scroll.error.value, /重试/); + assert.equal(h.scroll.listData.value.length, 1); + await h.scroll.retry(); + await h.scroll.listLoadMore(); + await h.scroll.listLoadMore(); + assert.deepEqual(h.calls.map((query) => query.pageNo), [1, 2, 2, 3]); + assert.deepEqual(Array.from(h.scroll.listData.value, (row) => row.id), [1, 2, 3]); + assert.equal(h.scroll.loadMoreStatus.value, 'noMore'); + h.stop(); +}); + +test('explicit refresh starts a fresh request while loading another page remains blocked', async () => { + const first = deferred(); + const second = deferred(); + let requests = 0; + const h = fixture(() => ++requests === 1 ? first.promise : second.promise); + let completed = 0; + const loading = h.scroll.refreshList(() => { completed += 1; }); + const latest = h.scroll.refreshList(() => { completed += 1; }); + const loadMore = h.scroll.listLoadMore(); + assert.equal(h.calls.length, 2); + assert.equal(h.scroll.isRefreshing.value, true); + first.resolve(page([{ id: 1 }], 2)); + await loading; + assert.equal(h.scroll.listData.value.length, 0); + assert.equal(h.scroll.isRefreshing.value, true); + second.resolve(page([{ id: 2 }], 2)); + await Promise.all([latest, loadMore]); + assert.equal(completed, 2); + assert.equal(h.scroll.listData.value.length, 1); + assert.equal(h.scroll.listData.value[0].id, 2); + assert.equal(h.scroll.isRefreshing.value, false); + h.stop(); +}); + +test('filter changes and clears discard old refresh and load-more responses', async () => { + const oldMore = deferred(); + const oldFilter = deferred(); + const h = fixture(({ level, nickname, pageNo }) => { + if (level === 1 && pageNo === 2) return oldMore.promise; + if (nickname === 'old') return oldFilter.promise; + return Promise.resolve(page([{ level, nickname }], 2)); + }, { level: 1, nickname: '' }); + await h.scroll.refreshList(); + const more = h.scroll.listLoadMore(); + h.params.level = 2; + h.params.nickname = 'old'; + const filter = h.scroll.refreshList(); + h.params.nickname = ''; + await h.scroll.refreshList(); + oldFilter.resolve(page([{ level: 2, nickname: 'old' }], 1)); + oldMore.resolve(page([{ level: 1, nickname: '' }], 100)); + await Promise.all([more, filter]); + assert.equal(h.scroll.listData.value.length, 1); + assert.equal(h.scroll.listData.value[0].level, 2); + assert.equal(h.scroll.listData.value[0].nickname, ''); + assert.deepEqual(h.calls.map((query) => query.pageNo), [1, 2, 1, 1]); + h.stop(); +}); + +test('failed and malformed responses preserve prior data and retry refresh without advancing pages', async () => { + let response = page([{ id: 1 }], 2); + const h = fixture(async () => response); + await h.scroll.refreshList(); + response = { code: 500, data: null }; + let completed = false; + await h.scroll.refreshList(() => { completed = true; }); + assert.equal(completed, true); + assert.equal(h.scroll.listData.value[0].id, 1); + assert.match(h.scroll.error.value, /重试/); + response = { code: 0, data: { list: [], total: null } }; + await h.scroll.retry(); + assert.match(h.scroll.error.value, /重试/); + response = page([], 0); + await h.scroll.retry(); + assert.deepEqual(h.calls.map((query) => query.pageNo), [1, 1, 1, 1]); + assert.equal(h.scroll.listData.value.length, 0); + assert.equal(h.scroll.error.value, ''); + assert.equal(h.scroll.loadMoreStatus.value, 'noMore'); + h.stop(); +}); + +test('unmount ignores late responses and prevents new requests', async () => { + const pending = deferred(); + const h = fixture(() => pending.promise); + const loading = h.scroll.refreshList(); + h.stop(); + pending.resolve(page([{ id: 1 }], 1)); + await loading; + await h.scroll.refreshList(); + await h.scroll.listLoadMore(); + assert.equal(h.scroll.listData.value.length, 0); + assert.equal(h.calls.length, 1); +}); + +test('unwrapped success envelopes remain supported', async () => { + const h = fixture(async () => ({ data: { list: [{ id: 1 }], total: '1' } })); + await h.scroll.refreshList(); + assert.equal(h.scroll.error.value, ''); + assert.equal(h.scroll.listData.value.length, 1); + assert.equal(h.scroll.loadMoreStatus.value, 'noMore'); + h.stop(); +}); + +test('invalidation starts a fresh same-query request and ignores the previous account response', async () => { + const previous = deferred(); + const current = deferred(); + let requests = 0; + const h = fixture(() => ++requests === 1 ? previous.promise : current.promise, { level: 1 }); + const oldLoading = h.scroll.refreshList(); + h.scroll.invalidate(); + const newLoading = h.scroll.refreshList(); + assert.equal(h.calls.length, 2); + assert.equal(h.calls[0].pageNo, 1); + assert.equal(h.calls[1].pageNo, 1); + previous.resolve(page([{ id: 'previous-account' }], 1)); + await oldLoading; + assert.equal(h.scroll.listData.value.length, 0); + assert.equal(h.scroll.isRefreshing.value, true); + current.resolve(page([{ id: 'current-account' }], 1)); + await newLoading; + assert.equal(h.scroll.listData.value[0].id, 'current-account'); + assert.equal(h.scroll.isRefreshing.value, false); + h.stop(); +}); + +test('legacy successful empty PageResult responses end pagination with no loading error', async () => { + for (const data of [{ list: null, total: null }, {}]) { + const h = fixture(async () => ({ code: 0, data })); + await h.scroll.refreshList(); + assert.equal(h.scroll.listData.value.length, 0); + assert.equal(h.scroll.error.value, ''); + assert.equal(h.scroll.loadMoreStatus.value, 'noMore'); + await h.scroll.listLoadMore(); + assert.equal(h.calls.length, 1); + h.stop(); + } +}); + +test('incomplete page fields, invalid payloads, and failed empty responses still expose errors', async () => { + for (const response of [ + { code: 0, data: { list: [], total: null } }, + { code: 0, data: { list: null, total: 0 } }, + { code: 0, data: null }, + { code: 0, data: [] }, + { code: 500, data: { list: null, total: null } }, + ]) { + const h = fixture(async () => response); + await h.scroll.refreshList(); + assert.match(h.scroll.error.value, /重试/); + assert.equal(h.scroll.loadMoreStatus.value, 'more'); + h.stop(); + } +}); + +test('refresh after a successful deletion reads new data instead of reusing a pre-deletion refresh', async () => { + const oldPull = deferred(); + let hasItem = true; + let requests = 0; + const h = fixture(() => { + requests += 1; + if (requests === 2) return oldPull.promise; + return Promise.resolve(page(hasItem ? [{ id: 1 }] : [])); + }); + await h.scroll.refreshList(); + const pull = h.scroll.refreshList(); + hasItem = false; + await h.scroll.refreshList(); + oldPull.resolve(page([{ id: 1 }])); + await pull; + assert.equal(h.calls.length, 3); + assert.equal(h.scroll.listData.value.length, 0); + assert.equal(h.scroll.loadMoreStatus.value, 'noMore'); + h.stop(); +}); diff --git a/src/sheep/hooks/use-brokerage.ts b/src/sheep/hooks/use-brokerage.ts new file mode 100644 index 00000000..426f4ced --- /dev/null +++ b/src/sheep/hooks/use-brokerage.ts @@ -0,0 +1,10 @@ +/** 将整数分精确显示为元,兼容后端 Long 字符串。 */ +export function formatBrokeragePrice(price?: number | string | null): string { + if (price === undefined || price === null) return '—'; + const value = String(price); + if (!/^-?\d+$/.test(value)) return '—'; + const negative = value.startsWith('-'); + const digits = (negative ? value.slice(1) : value).replace(/^0+(?=\d)/, '').padStart(3, '0'); + const sign = negative && /[1-9]/.test(digits) ? '-' : ''; + return `${sign}${digits.slice(0, -2)}.${digits.slice(-2)}`; +} diff --git a/src/sheep/hooks/use-scroll.ts b/src/sheep/hooks/use-scroll.ts index e420596f..38c23a33 100644 --- a/src/sheep/hooks/use-scroll.ts +++ b/src/sheep/hooks/use-scroll.ts @@ -1,60 +1,115 @@ -import { reactive, ref } from 'vue'; +import { getCurrentScope, onScopeDispose, ref } from 'vue'; export default function useScroll(fetch: (...arg: any) => Promise, params: any = {}) { const listData = ref([]); const loadMoreStatus = ref<'more' | 'loading' | 'noMore'>('more'); - const pageParams = reactive({ - pageNo: 1, - pageSize: 10, - }); - const total = ref(0); const isRefreshing = ref(false); + const error = ref(''); + let total = 0; + let nextPage = 1; + let queryKey: string | undefined; + let requestVersion = 0; + let disposed = false; + let retryRefresh = true; + let activeRequest: { + promise: Promise; + } | undefined; - const loadData = async (isRefresh: boolean = false) => { + const loadData = (isRefresh: boolean): Promise => { + if (disposed) return Promise.resolve(); + const filters = { ...params }; + const key = JSON.stringify(filters); + if (activeRequest && !isRefresh) { + return activeRequest.promise; + } + if (queryKey !== key) { + listData.value = []; + total = 0; + nextPage = 1; + queryKey = key; + } + const pageNo = isRefresh ? 1 : nextPage; + const query = { pageNo, pageSize: 10, ...filters }; + const version = ++requestVersion; + isRefreshing.value = isRefresh; + loadMoreStatus.value = 'loading'; + error.value = ''; + retryRefresh = isRefresh; + const request = { promise: Promise.resolve() }; + activeRequest = request; + request.promise = (async () => { + try { + const response = await fetch(query); + if (disposed || version !== requestVersion) return; + const page = response?.data; + // 部分既有接口用无参 PageResult 返回 list、total 均为空的成功空页。 + const emptyPage = page != null && typeof page === 'object' && !Array.isArray(page) && + page.list == null && page.total == null; + const rows = emptyPage ? [] : page?.list; + const count = emptyPage ? 0 : Number(page?.total ?? NaN); + if ((response?.code !== undefined && response.code !== 0) || + !Array.isArray(rows) || !Number.isFinite(count) || count < 0) { + throw new Error('Invalid page response'); + } + if (isRefresh) { + listData.value = rows; + } else { + listData.value.push(...rows); + } + total = count; + nextPage = pageNo + 1; + } catch { + if (!disposed && version === requestVersion) error.value = '加载失败,请重试'; + } finally { + if (!disposed && version === requestVersion) { + activeRequest = undefined; + isRefreshing.value = false; + loadMoreStatus.value = error.value || listData.value.length < total ? 'more' : 'noMore'; + } + } + })(); + return request.promise; + }; + + const refreshList = async (done?: unknown) => { try { - // if (pageParams.pageNo === 1) { - // uni.showLoading({ title: '加载中...' }); - // } - if (isRefresh) { - isRefreshing.value = true; - pageParams.pageNo = 1; - const { data = [] } = await fetch({ ...pageParams, ...params }); - listData.value = data?.list ?? []; - total.value = data?.total || 0; - } else { - const { data = [] } = await fetch({ ...pageParams, ...params }); - listData.value.push(...(data?.list ?? [])); - total.value = data?.total || 0; - } - pageParams.pageNo = pageParams.pageNo + 1; - - if (listData.value?.length >= total.value) { - loadMoreStatus.value = 'noMore'; - } else { - loadMoreStatus.value = 'more'; - } - } catch (error) { - console.error('加载数据失败:', error); + await loadData(true); } finally { - isRefreshing.value = false; - // uni.hideLoading(); + if (typeof done === 'function') done(); } }; - const refreshList = async (done: () => void = () => {}) => { - await loadData(true); - done(); + const listLoadMore = () => { + if (activeRequest) return activeRequest.promise; + if (queryKey !== JSON.stringify(params)) return loadData(true); + if (error.value) return loadData(retryRefresh); + if (loadMoreStatus.value === 'noMore') return Promise.resolve(); + return loadData(false); }; - const listLoadMore = () => { - loadData(false); + const retry = () => loadData(retryRefresh); + + const invalidate = () => { + requestVersion += 1; + activeRequest = undefined; + queryKey = undefined; }; + if (getCurrentScope()) { + onScopeDispose(() => { + disposed = true; + invalidate(); + }); + } + return { isRefreshing, listData, loadMoreStatus, refreshList, listLoadMore, + error, + retry, + invalidate, }; } diff --git a/src/sheep/platform/__tests__/app-update-runtime.test.mjs b/src/sheep/platform/__tests__/app-update-runtime.test.mjs new file mode 100644 index 00000000..6f599d6c --- /dev/null +++ b/src/sheep/platform/__tests__/app-update-runtime.test.mjs @@ -0,0 +1,94 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import vm from 'node:vm'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); + +async function fixture(platform = 'android') { + const calls = { downloads: [], removed: [], installs: [] }; + const plus = { + runtime: { + appid: '__UNI__0A84F60', versionCode: '113', version: '1.1.3', + getProperty: (_id, callback) => callback({ versionCode: '120', version: '1.2.0' }), + install: (...args) => calls.installs.push(args), + }, + android: { runtimeMainActivity: () => ({ getPackageName: () => 'com.test.friend' }) }, + downloader: { + createDownload: (_url, options, complete) => { + const task = { addEventListener: () => {}, start: () => {}, abort: () => {} }; + calls.downloads.push({ filePath: options.filename, complete, task }); + return task; + }, + }, + io: { + getFileInfo: (options) => options.success({ size: 2048, digest: 'abc' }), + resolveLocalFileSystemURL: (path, success) => success({ remove: (done) => { calls.removed.push(path); done(); } }), + }, + }; + const source = await readFile(new URL('../app-update-runtime.ts', import.meta.url), 'utf8'); + if (platform === 'ios') delete plus.android; + const compiled = ts.transpileModule(source, { compilerOptions: { + module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, + } }).outputText; + const context = vm.createContext({ + exports: {}, plus, setTimeout, clearTimeout, + uni: { getDeviceInfo: () => ({ platform }) }, + require: (name) => { + assert.equal(name, '@/manifest.json'); + return { appid: '__UNI__0A84F60', versionCode: 120, versionName: '1.2.0' }; + }, + }); + vm.runInContext(compiled, context); + return { runtime: context.exports.default, calls }; +} + +test('the native adapter reports installed APK and WGT resource versions independently', async () => { + const { runtime } = await fixture(); + const version = await runtime.readVersion(); + assert.equal(version.nativeVersionCode, 113); + assert.equal(version.resourceVersionCode, 120); + assert.equal(version.packageName, 'com.test.friend'); +}); + +test('one WGT package follows the same resource installation flow on Android and iOS', async () => { + for (const platform of ['android', 'ios']) { + const { runtime, calls } = await fixture(platform); + const version = await runtime.readVersion(); + assert.equal(version.platform, platform); + assert.equal(version.resourceVersionCode, 120); + const installing = runtime.installWgt('_doc/shared-update.wgt'); + assert.equal(calls.installs[0][0], '_doc/shared-update.wgt'); + assert.equal(calls.installs[0][1].force, false); + calls.installs[0][2](); + await installing; + } +}); + +test('cancelled attempts remove their own file without sharing a retry file name', async () => { + const { runtime, calls } = await fixture(); + const release = { packageId: 10, packageType: 'wgt', md5: 'abc', fileSize: 2048, downloadUrl: 'https://example.com/update.wgt' }; + const first = runtime.download(release, () => {}); + void first.promise.catch(() => {}); + first.cancel(); + const second = runtime.download(release, () => {}); + assert.notEqual(calls.downloads[0].filePath, calls.downloads[1].filePath); + assert.deepEqual(calls.removed, [calls.downloads[0].filePath]); + calls.downloads[1].complete({ filename: calls.downloads[1].filePath }, 200); + assert.equal(await second.promise, calls.downloads[1].filePath); +}); + +test('the native adapter rejects and removes a mismatching update package', async () => { + const { runtime, calls } = await fixture(); + await assert.rejects(runtime.verifyFile('_doc/corrupt.wgt', { fileSize: 2048, md5: 'different' }), /校验失败/); + assert.deepEqual(calls.removed, ['_doc/corrupt.wgt']); +}); + +test('opening the Android installer does not depend on an APK success callback', async () => { + const { runtime, calls } = await fixture(); + runtime.installApk('_doc/new.apk', () => {}); + assert.equal(calls.installs.length, 1); + assert.equal(calls.installs[0][2], undefined); +}); diff --git a/src/sheep/platform/__tests__/app-update.test.mjs b/src/sheep/platform/__tests__/app-update.test.mjs new file mode 100644 index 00000000..85f9b8fd --- /dev/null +++ b/src/sheep/platform/__tests__/app-update.test.mjs @@ -0,0 +1,284 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import vm from 'node:vm'; + +const release = { + id: 12, packageId: 8, appId: '__UNI__0A84F60', platform: 'android', packageType: 'wgt', + versionName: '1.1.4', versionCode: 114, resourceVersionCode: 114, + forceUpdate: false, fileSize: 2048, md5: 'abcd', downloadUrl: 'https://files.example.com/114.wgt', +}; + +function deferred() { + let resolve, reject; + const promise = new Promise((success, failure) => { resolve = success; reject = failure; }); + return { promise, resolve, reject }; +} + +async function fixture(overrides = {}) { + const calls = { check: 0, validate: 0, download: 0, installWgt: 0, installApk: 0, cancelled: 0, toasts: [], pages: [], removed: [] }; + const installed = { appId: release.appId, platform: 'android', nativeVersionCode: 113, resourceVersionCode: 113 }; + const runtime = { + isSupported: () => true, + readVersion: async () => installed, + download: () => { + calls.download++; + return { promise: Promise.resolve('_doc/update.wgt'), cancel: () => calls.cancelled++ }; + }, + verifyFile: async () => {}, + installWgt: async () => { calls.installWgt++; }, + installApk: () => { calls.installApk++; }, + restart: () => {}, + removeFile: async (path) => { calls.removed.push(path); }, + ...overrides.runtime, + }; + const api = { + check: async () => { calls.check++; return { code: 0, data: { ...release } }; }, + validate: async () => { calls.validate++; return { code: 0, data: { ...release } }; }, + ...overrides.api, + }; + const guards = new Map(); + const uni = { + showToast: ({ title }) => calls.toasts.push(title), + showModal: async () => ({ confirm: true }), + navigateTo: ({ url, complete }) => { calls.pages.push(url); complete?.(); }, + redirectTo: () => {}, + addInterceptor: (method, handler) => guards.set(method, handler), + }; + const source = (await readFile(new URL('../app-update.js', import.meta.url), 'utf8')) + .replace(/^import .+;\n/gm, '') + .replace('export function formatFileSize', 'function formatFileSize') + .replace('export default new AppUpdateManager();', 'globalThis.manager = new AppUpdateManager();'); + const context = vm.createContext({ + reactive: (value) => value, + runtime, uni, console, + getCurrentPages: () => [{ route: 'pages/tabbar/profile' }], + getNetworkStatus: overrides.network || (async () => ({ connected: true, networkType: 'wifi' })), + ensureAndroidInstallPermission: overrides.permission || (async () => true), + canInstallApk: overrides.canInstall || (() => true), + }); + vm.runInContext(source, context); + const manager = context.manager; + manager.configure(api); + manager.started = true; + return { manager, calls, api, runtime, guards }; +} + +test('concurrent checks share one request and successful automatic checks are throttled', async () => { + const { manager, calls } = await fixture(); + await Promise.all([manager.check(), manager.check()]); + await manager.check(); + assert.equal(calls.check, 1); + await manager.check({ silent: false, force: true }); + assert.equal(calls.check, 2); +}); + +test('a late gray release response is discarded after identity changes', async () => { + const pending = deferred(); + const { manager } = await fixture({ api: { check: () => pending.promise } }); + const check = manager.check(); + await Promise.resolve(); + manager.identityChanged(); + pending.resolve({ code: 0, data: { ...release, forceUpdate: true } }); + await check; + assert.equal(manager.state.release, null); + assert.equal(manager.state.blocking, false); +}); + +test('normal WGT update validates twice, verifies and waits for restart', async () => { + const { manager, calls } = await fixture(); + await manager.check(); + await Promise.all([manager.update(), manager.update()]); + assert.equal(calls.download, 1); + assert.equal(calls.validate, 2); + assert.equal(calls.installWgt, 1); + assert.equal(manager.state.phase, 'ready'); +}); + +test('starting an update waits for a pending release refresh and installs its current result', async () => { + const pending = deferred(); + const currentRelease = { ...release, id: 13, packageId: 9 }; + const { manager, calls, api } = await fixture(); + await manager.check(); + api.check = () => pending.promise; + api.validate = async ({ id }) => { + calls.validate++; + assert.equal(id, currentRelease.id); + return { code: 0, data: currentRelease }; + }; + const refresh = manager.check({ force: true }); + const update = manager.update(); + assert.equal(calls.validate, 0); + pending.resolve({ code: 0, data: currentRelease }); + await refresh; + await update; + assert.equal(calls.validate, 2); + assert.equal(calls.installWgt, 1); +}); + +test('withdrawn versions stop before downloading', async () => { + const { manager, calls } = await fixture({ api: { validate: async () => ({ code: 0, data: null }) } }); + await manager.check(); + await manager.update(); + assert.equal(calls.download, 0); + assert.equal(manager.state.release, null); + assert.equal(manager.state.blocking, false); +}); + +test('offline recheck releases a cached mandatory gate', async () => { + const { manager, api } = await fixture(); + api.check = async () => ({ code: 0, data: { ...release, forceUpdate: true } }); + await manager.check(); + assert.equal(manager.state.blocking, true); + api.check = async () => false; + await manager.check({ force: true }); + assert.equal(manager.state.blocking, false); + assert.match(manager.state.error, /检查更新失败/); +}); + +test('transport failure during validation releases the stale mandatory gate', async () => { + const { manager, api } = await fixture(); + api.check = async () => ({ code: 0, data: { ...release, forceUpdate: true } }); + api.validate = async () => { throw new Error('网络不可用'); }; + await manager.check(); + await manager.update(); + assert.equal(manager.state.blocking, false); + assert.equal(manager.state.phase, 'error'); +}); + +test('manual offline checks show the recovery page with settings and retry actions', async () => { + const { manager, calls } = await fixture({ network: async () => ({ connected: false, networkType: 'none' }) }); + await manager.check({ silent: false, force: true }); + assert.equal(manager.state.release, null); + assert.match(manager.state.error, /网络未连接/); + assert.equal(calls.pages[0], '/pages/profile/app-update'); +}); + +test('APK install returns after opening the installer without waiting for a success callback', async () => { + const apk = { ...release, packageType: 'apk', nativeVersionCode: 114 }; + const { manager, calls } = await fixture({ api: { + check: async () => ({ code: 0, data: apk }), + validate: async () => ({ code: 0, data: apk }), + } }); + await manager.check(); + await manager.update(); + assert.equal(calls.installApk, 1); + assert.equal(manager.state.phase, 'installer-open'); +}); + +test('denied install permission retains the downloaded APK for the settings return', async () => { + let permitted = false; + const apk = { ...release, packageType: 'apk' }; + const { manager, calls } = await fixture({ + api: { check: async () => ({ code: 0, data: apk }), validate: async () => ({ code: 0, data: apk }) }, + permission: async () => permitted, + canInstall: () => permitted, + }); + await manager.check(); + await manager.update(); + assert.equal(manager.state.phase, 'permission'); + assert.ok(manager.filePath); + permitted = true; + await manager.foreground(); + assert.equal(calls.download, 1); + assert.equal(calls.installApk, 1); +}); + +test('identity change cancels a download and ignores its late completion', async () => { + const pending = deferred(); + let cancelled = false; + const { manager, calls } = await fixture({ runtime: { + download: () => ({ promise: pending.promise, cancel: () => { cancelled = true; } }), + } }); + await manager.check(); + const update = manager.update(); + while (manager.state.phase !== 'downloading') await Promise.resolve(); + manager.identityChanged(); + pending.resolve('_doc/old-account.wgt'); + await update; + assert.equal(cancelled, true); + assert.equal(calls.installWgt, 0); + assert.equal(manager.state.release, null); + assert.equal(manager.state.phase, 'idle'); +}); + +test('leaving while validation is pending cancels the normal update action', async () => { + const pending = deferred(); + const { manager, calls } = await fixture({ api: { validate: () => pending.promise } }); + await manager.check(); + const update = manager.update(); + await Promise.resolve(); + assert.equal(manager.dismiss(), true); + pending.resolve({ code: 0, data: release }); + await update; + assert.equal(calls.download, 0); +}); + +test('corrupt files never reach installation and can be downloaded again', async () => { + const { manager, calls } = await fixture({ runtime: { + verifyFile: async () => { throw new Error('更新包校验失败'); }, + } }); + await manager.check(); + await manager.update(); + assert.equal(calls.installWgt, 0); + assert.equal(manager.filePath, ''); + assert.equal(manager.state.phase, 'error'); +}); + +test('leaving during verification preserves the file and permits a new update attempt', async () => { + const pending = deferred(); + let verification = 0; + const { manager, calls } = await fixture({ runtime: { + verifyFile: () => ++verification === 1 ? pending.promise : Promise.resolve(), + } }); + await manager.check(); + const oldAttempt = manager.update(); + while (manager.state.phase !== 'verifying') await Promise.resolve(); + assert.equal(manager.dismiss(), true); + assert.equal(manager.state.phase, 'downloaded'); + await manager.update(); + pending.resolve(); + await oldAttempt; + assert.equal(calls.download, 1); + assert.equal(calls.installWgt, 1); + assert.equal(manager.state.phase, 'ready'); +}); + +test('mandatory update blocks business navigation and allows its own page', async () => { + const { manager, guards } = await fixture(); + manager.installNavigationGuard(); + manager.state.blocking = true; + assert.equal(guards.get('navigateTo').invoke({ url: '/pages/profile/app-update' }), true); + assert.equal(guards.get('navigateBack').invoke({}), false); + assert.equal(manager.dismiss(), false); +}); + +test('an identity change during native WGT installation preserves the restart result', async () => { + const pending = deferred(); + const { manager, calls } = await fixture({ runtime: { installWgt: () => pending.promise } }); + await manager.check(); + const update = manager.update(); + while (manager.state.phase !== 'installing') await Promise.resolve(); + manager.identityChanged(); + assert.equal(calls.removed.length, 0); + assert.equal(manager.state.phase, 'installing'); + pending.resolve(); + await update; + assert.equal(manager.state.phase, 'ready'); + assert.equal(manager.state.blocking, false); + assert.equal(manager.state.release.forceUpdate, false); +}); + +test('a failed native WGT install after logout clears the old candidate', async () => { + const pending = deferred(); + const { manager } = await fixture({ runtime: { installWgt: () => pending.promise } }); + await manager.check(); + const update = manager.update(); + while (manager.state.phase !== 'installing') await Promise.resolve(); + manager.identityChanged(); + pending.reject(new Error('native install failed')); + await update; + assert.equal(manager.state.phase, 'idle'); + assert.equal(manager.state.release, null); + assert.equal(manager.filePath, ''); +}); diff --git a/src/sheep/platform/app-update-runtime.ts b/src/sheep/platform/app-update-runtime.ts new file mode 100644 index 00000000..4b47fc91 --- /dev/null +++ b/src/sheep/platform/app-update-runtime.ts @@ -0,0 +1,205 @@ +import { appid, versionCode, versionName } from '@/manifest.json'; +import type { AppVersionQuery, AppVersionRelease } from '@/sheep/api/member/app-version'; + +export interface InstalledVersion extends AppVersionQuery { + nativeVersionName: string; + resourceVersionName: string; +} + +let downloadSequence = 0; + +function positiveCode(value: unknown): number { + const result = Number(value); + if (!Number.isSafeInteger(result) || result <= 0) { + throw new Error('当前安装包缺少有效版本信息,请安装完整应用后重试'); + } + return result; +} + +function supportsHarmonyUpdate(): boolean { + const api = uni as any; + return typeof api.installWgt === 'function' && typeof api.restart === 'function' + && typeof api.getFileInfo === 'function'; +} + +function isSupported(): boolean { + // #ifdef APP-PLUS + if (typeof plus !== 'undefined' && ['android', 'ios'].includes(uni.getDeviceInfo().platform)) { + return true; + } + // #endif + // #ifdef APP-HARMONY + return supportsHarmonyUpdate(); + // #endif + return false; +} + +async function readVersion(): Promise { + // #ifdef APP-PLUS + if (typeof plus !== 'undefined') { + const platform = uni.getDeviceInfo().platform; + if (platform !== 'android' && platform !== 'ios') throw new Error('当前系统暂不支持应用内升级'); + const resource = await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('读取应用版本超时,请重试')), 8000); + plus.runtime.getProperty(plus.runtime.appid!, (info) => { + clearTimeout(timer); + resolve(info); + }); + }); + return { + appId: plus.runtime.appid!, + platform, + nativeVersionCode: positiveCode(plus.runtime.versionCode), + resourceVersionCode: positiveCode(resource.versionCode), + nativeVersionName: plus.runtime.version || '', + resourceVersionName: resource.version || '', + packageName: platform === 'android' + ? plus.android.runtimeMainActivity().getPackageName() : undefined, + }; + } + // #endif + // #ifdef APP-HARMONY + if (supportsHarmonyUpdate()) { + const info = uni.getAppBaseInfo(); + return { + appId: info.appId || appid, + platform: 'harmony', + nativeVersionCode: positiveCode(info.appVersionCode), + resourceVersionCode: positiveCode(versionCode), + nativeVersionName: info.appVersion, + resourceVersionName: versionName, + }; + } + // #endif + throw new Error('当前运行环境暂不支持应用内升级'); +} + +function download(release: AppVersionRelease, onProgress: (downloaded: number, total: number) => void) { + // #ifdef APP-PLUS + if (typeof plus !== 'undefined') { + let task: PlusDownloaderDownload; + let rejectDownload: (error: Error) => void; + const attempt = `${Date.now()}-${++downloadSequence}`; + const filePath = `_doc/app-updates/${release.packageId}-${release.md5}-${attempt}.${release.packageType}`; + const promise = new Promise((resolve, reject) => { + rejectDownload = reject; + task = plus.downloader.createDownload(release.downloadUrl, { + filename: filePath, timeout: 60, retry: 1, + }, (result, status) => { + if (status === 200 && result.filename) resolve(result.filename); + else reject(new Error('更新包下载失败,请检查网络后重试')); + }); + task.addEventListener('statechanged', (result) => { + onProgress(result.downloadedSize || 0, result.totalSize || release.fileSize); + }); + task.start(); + }); + return { + promise, + cancel: () => { + task?.abort(); + rejectDownload?.(new Error('下载已取消')); + void removeFile(filePath); + }, + }; + } + // #endif + // #ifdef APP-HARMONY + let task: UniApp.DownloadTask; + const promise = new Promise((resolve, reject) => { + task = uni.downloadFile({ + url: release.downloadUrl, + timeout: 60000, + success: (result) => result.statusCode === 200 + ? resolve(result.tempFilePath) : reject(new Error('更新包下载失败,请重试')), + fail: () => reject(new Error('下载已取消或网络不可用')), + }); + task.onProgressUpdate((result) => onProgress(result.totalBytesWritten, result.totalBytesExpectedToWrite)); + }); + return { promise, cancel: () => task?.abort() }; + // #endif + throw new Error('当前系统暂不支持下载更新'); +} + +async function verifyFile(filePath: string, release: AppVersionRelease): Promise { + const info = await new Promise<{ size: number; digest?: string }>((resolve, reject) => { + const options = { filePath, digestAlgorithm: 'md5', success: resolve, + fail: () => reject(new Error('无法读取更新包,请重新下载')) }; + // #ifdef APP-PLUS + if (typeof plus !== 'undefined') { + plus.io.getFileInfo(options); + return; + } + // #endif + uni.getFileInfo(options); + }); + if (info.size !== release.fileSize || !info.digest + || info.digest.toLowerCase() !== release.md5.toLowerCase()) { + await removeFile(filePath); + throw new Error('更新包校验失败,请重新下载'); + } +} + +function installWgt(filePath: string): Promise { + return new Promise((resolve, reject) => { + // #ifdef APP-PLUS + if (typeof plus !== 'undefined') { + plus.runtime.install(filePath, { force: false }, () => resolve(), + (error) => reject(new Error(error.message || '资源包安装失败,请重试'))); + return; + } + // #endif + // #ifdef APP-HARMONY + if (supportsHarmonyUpdate()) { + (uni as any).installWgt({ filePath, success: resolve, + fail: () => reject(new Error('资源包安装失败,请重试')) }); + return; + } + // #endif + reject(new Error('当前系统暂不支持安装资源包')); + }); +} + +function installApk(filePath: string, onError: (error: Error) => void): void { + // #ifdef APP-PLUS + if (typeof plus !== 'undefined' && uni.getDeviceInfo().platform === 'android') { + // Android APK 安装成功没有回调,恢复前台时重新读取原生版本。 + plus.runtime.install(filePath, {}, undefined, (error) => { + onError(new Error(error.message || '无法打开安装器,请重试')); + }); + return; + } + // #endif + throw new Error('当前系统暂不支持 APK 安装'); +} + +function restart(): void { + // #ifdef APP-PLUS + if (typeof plus !== 'undefined') { + plus.runtime.restart(); + return; + } + // #endif + // #ifdef APP-HARMONY + if (supportsHarmonyUpdate()) { + (uni as any).restart(); + return; + } + // #endif + throw new Error('请关闭应用后重新打开,以完成更新'); +} + +function removeFile(filePath: string): Promise { + if (!filePath) return Promise.resolve(); + // #ifdef APP-PLUS + if (typeof plus !== 'undefined') { + return new Promise((resolve) => { + plus.io.resolveLocalFileSystemURL(filePath, + (entry) => entry.remove(() => resolve(), () => resolve()), () => resolve()); + }); + } + // #endif + return new Promise((resolve) => uni.removeSavedFile({ filePath, complete: () => resolve() })); +} + +export default { isSupported, readVersion, download, verifyFile, installWgt, installApk, restart, removeFile }; diff --git a/src/sheep/platform/app-update.js b/src/sheep/platform/app-update.js new file mode 100644 index 00000000..1de194ac --- /dev/null +++ b/src/sheep/platform/app-update.js @@ -0,0 +1,350 @@ +import { reactive } from 'vue'; +import runtime from './app-update-runtime'; +import { getNetworkStatus, ensureAndroidInstallPermission, canInstallApk } from '@/sheep/util/permission'; + +const UPDATE_PAGE = '/pages/profile/app-update'; +const CHECK_INTERVAL = 30 * 60 * 1000; +const ACTIVE_PHASES = ['downloading', 'verifying', 'installing']; + +class AppUpdateManager { + state = reactive({ + release: null, + phase: 'idle', + progress: 0, + downloadedBytes: 0, + error: '', + blocking: false, + installedVersion: null, + }); + + api = null; + started = false; + revision = 0; + lastChecked = 0; + checkPromise = null; + actionPromise = null; + downloadTask = null; + filePath = ''; + pageOpening = false; + dismissedId = null; + identityChangedDuringInstall = false; + + configure(api) { + this.api = api; + } + + start() { + if (this.started || !runtime.isSupported()) return; + this.initialize(); + void this.check({ force: true }); + } + + initialize() { + this.started = true; + this.installNavigationGuard(); + uni.onNetworkStatusChange?.(({ isConnected }) => { + if (isConnected) { + void this.check({ force: true }); + } else if (!['installing', 'ready'].includes(this.state.phase)) { + this.state.blocking = false; + this.state.error = '网络未连接,请联网后重试'; + } + }); + } + + identityChanged() { + this.revision++; + this.lastChecked = 0; + this.dismissedId = null; + this.checkPromise = null; + if (this.state.phase === 'installing') { + // 原生安装开始后无法取消,保留文件与完成回调,避免登出导致资源已写入却丢失重启入口。 + this.identityChangedDuringInstall = true; + this.state.blocking = false; + return; + } + this.cancelTask(); + this.actionPromise = null; + this.clearRelease(); + } + + authenticated() { + if (this.started) void this.check({ force: true }); + } + + clearRelease() { + this.state.release = null; + this.state.blocking = false; + this.state.phase = 'idle'; + this.state.progress = 0; + this.state.error = ''; + } + + cancelTask() { + this.downloadTask?.cancel(); + this.downloadTask = null; + if (this.filePath) void runtime.removeFile(this.filePath); + this.filePath = ''; + } + + installNavigationGuard() { + for (const method of ['navigateTo', 'redirectTo', 'reLaunch', 'switchTab', 'navigateBack']) { + uni.addInterceptor(method, { + invoke: (options) => { + if ((this.state.blocking || this.state.phase === 'installing') + && !options?.url?.startsWith(UPDATE_PAGE)) { + this.present(); + return false; + } + return true; + }, + }); + } + } + + present(showEmpty = false) { + if ((!this.state.release && !showEmpty) || this.pageOpening) return; + const pages = getCurrentPages(); + if (pages[pages.length - 1]?.route === UPDATE_PAGE.slice(1)) return; + if (!pages.length) return; + this.pageOpening = true; + uni.navigateTo({ + url: UPDATE_PAGE, + fail: () => uni.redirectTo({ url: UPDATE_PAGE }), + complete: () => { this.pageOpening = false; }, + }); + } + + async check({ silent = true, force = false } = {}) { + if (!runtime.isSupported()) { + if (!silent) uni.showToast({ title: '当前运行环境暂不支持应用内升级', icon: 'none' }); + return null; + } + if (!this.api) return null; + if (!this.started) this.initialize(); + if (this.actionPromise || ACTIVE_PHASES.includes(this.state.phase) || this.state.phase === 'ready') { + if (!silent || this.state.blocking) this.present(); + return this.state.release; + } + if (!force && silent && Date.now() - this.lastChecked < CHECK_INTERVAL) { + if (this.state.blocking) this.present(); + return this.state.release; + } + if (!this.checkPromise) { + const revision = this.revision; + const pending = this.performCheck(revision); + this.checkPromise = pending; + pending.finally(() => { + if (this.checkPromise === pending) this.checkPromise = null; + }); + } + const release = await this.checkPromise; + if (release && (!silent || this.state.blocking || this.dismissedId !== release.id)) this.present(); + if (!silent && !release) { + if (this.state.error) this.present(true); + else uni.showToast({ title: '当前已是最新版本', icon: 'none' }); + } + return release; + } + + async performCheck(revision) { + try { + const network = await getNetworkStatus(); + if (!network.connected) throw new Error('网络未连接,请联网后重试'); + const installed = await runtime.readVersion(); + const response = await this.api.check(installed); + if (revision !== this.revision) return null; + if (!response || response.code !== 0) throw new Error(response?.msg || '检查更新失败,请稍后重试'); + this.lastChecked = Date.now(); + this.state.installedVersion = installed; + this.state.error = ''; + const release = response.data; + if (!release) { + this.cancelTask(); + this.clearRelease(); + return null; + } + if (this.state.release?.packageId !== release.packageId) this.cancelTask(); + this.state.release = release; + this.state.blocking = release.forceUpdate; + this.state.phase = this.filePath ? 'downloaded' : 'available'; + return release; + } catch (error) { + if (revision !== this.revision) return null; + // 断网和历史缓存不会持续阻塞业务,重试时再次验证强制更新状态。 + this.state.blocking = false; + this.state.error = error?.message || '检查更新失败,请稍后重试'; + return null; + } + } + + async validate(revision) { + const release = this.state.release; + if (!release || revision !== this.revision) return false; + const installed = await runtime.readVersion(); + let response; + try { + response = await this.api.validate({ ...installed, id: release.id }); + } catch (error) { + if (revision === this.revision) this.state.blocking = false; + throw error; + } + if (revision !== this.revision) return false; + if (!response || response.code !== 0) { + this.state.blocking = false; + throw new Error(response?.msg || '无法确认版本状态,请联网后重试'); + } + if (!response.data || response.data.packageId !== release.packageId) { + this.cancelTask(); + this.clearRelease(); + uni.showToast({ title: '该版本发布状态已变化,请重新检查更新', icon: 'none' }); + return false; + } + this.state.release = response.data; + this.state.blocking = response.data.forceUpdate; + return true; + } + + runAction(action) { + if (this.actionPromise) return this.actionPromise; + const revision = this.revision; + const pending = action(revision).catch((error) => { + if (revision !== this.revision) return; + this.state.phase = 'error'; + this.state.error = error?.message || '更新失败,请重试'; + }).finally(() => { + if (this.actionPromise === pending) this.actionPromise = null; + }); + this.actionPromise = pending; + return pending; + } + + update() { + if (this.checkPromise) return this.checkPromise.then(() => this.update()); + return this.runAction(async (revision) => { + this.state.error = ''; + if (!await this.validate(revision)) return; + if (!this.filePath) { + const network = await getNetworkStatus(); + if (revision !== this.revision) return; + if (!network.connected) { + this.state.blocking = false; + throw new Error('网络未连接,请联网后重试'); + } + if (network.networkType !== 'wifi') { + const { confirm } = await uni.showModal({ + title: '使用移动网络下载', + content: `更新包大小 ${formatFileSize(this.state.release.fileSize)},是否继续下载?`, + confirmText: '继续下载', + }); + if (!confirm || revision !== this.revision) return; + } + this.state.phase = 'downloading'; + this.state.progress = 0; + this.state.downloadedBytes = 0; + this.downloadTask = runtime.download(this.state.release, (downloaded, total) => { + if (revision !== this.revision) return; + this.state.downloadedBytes = downloaded; + this.state.progress = total > 0 ? Math.min(100, Math.floor(downloaded * 100 / total)) : 0; + }); + const path = await this.downloadTask.promise; + if (revision !== this.revision) { + void runtime.removeFile(path); + return; + } + this.downloadTask = null; + this.filePath = path; + } + await this.install(revision); + }); + } + + async install(revision) { + if (!await this.validate(revision)) return; + this.state.phase = 'verifying'; + const verifiedPath = this.filePath; + try { + await runtime.verifyFile(verifiedPath, this.state.release); + } catch (error) { + if (revision === this.revision && this.filePath === verifiedPath) this.filePath = ''; + throw error; + } + if (revision !== this.revision) return; + const release = this.state.release; + if (release.packageType === 'apk') { + this.state.phase = 'permission'; + if (!await ensureAndroidInstallPermission() || revision !== this.revision) return; + this.state.phase = 'installer-open'; + runtime.installApk(this.filePath, (error) => { + if (revision !== this.revision) return; + this.state.phase = 'error'; + this.state.error = error.message; + }); + } else { + this.state.phase = 'installing'; + try { + await runtime.installWgt(this.filePath); + } catch (error) { + if (this.identityChangedDuringInstall) { + this.identityChangedDuringInstall = false; + this.cancelTask(); + this.clearRelease(); + } + throw error; + } + if (revision !== this.revision && !this.identityChangedDuringInstall) return; + if (this.identityChangedDuringInstall) { + this.identityChangedDuringInstall = false; + this.state.release = { ...release, forceUpdate: false }; + this.state.blocking = false; + } + this.state.phase = 'ready'; + this.state.progress = 100; + } + } + + async foreground() { + if (!this.started) return; + if (this.actionPromise) return; + if (this.state.phase === 'permission' && this.filePath && canInstallApk()) { + await this.update(); + return; + } + if (this.state.phase === 'installer-open') { + await this.check({ force: true }); + return; + } + void this.check(); + } + + dismiss() { + if (this.state.blocking || this.state.phase === 'installing') return false; + this.revision++; + this.checkPromise = null; + this.actionPromise = null; + this.dismissedId = this.state.release?.id || null; + this.downloadTask?.cancel(); + this.downloadTask = null; + if (['downloading', 'verifying'].includes(this.state.phase)) { + this.state.phase = this.filePath ? 'downloaded' : 'available'; + } + return true; + } + + restart() { + try { + runtime.restart(); + } catch (error) { + this.state.error = error.message; + } + } +} + +export function formatFileSize(value) { + if (!value) return '0 KB'; + return value >= 1024 * 1024 + ? `${(value / 1024 / 1024).toFixed(1)} MB` + : `${Math.max(1, Math.ceil(value / 1024))} KB`; +} + +export default new AppUpdateManager(); diff --git a/src/sheep/platform/index.js b/src/sheep/platform/index.js index 934f70eb..ea6b257a 100644 --- a/src/sheep/platform/index.js +++ b/src/sheep/platform/index.js @@ -18,6 +18,7 @@ import wechat from './provider/wechat/index.js'; import apple from './provider/apple'; import share from './share'; import Pay from './pay'; +import appUpdates from './app-update'; const device = uni.getWindowInfo(); const initialStatusBarHeight = getStatusBarHeight(); @@ -55,6 +56,11 @@ if (os === 'ios') { } // #endif +// #ifdef APP-HARMONY +name = 'App'; +platform = 'openPlatform'; +// #endif + // #ifdef MP-WEIXIN name = 'WechatMiniProgram'; platform = 'miniProgram'; @@ -97,13 +103,12 @@ const payH5 = (payment, orderType, orderSN) => { * @param {Boolean} silence - 静默检查 */ const checkUpdate = (silence = false) => { - let canUpdate; // #ifdef MP-WEIXIN useProvider().checkUpdate(silence); // #endif - // #ifdef APP-PLUS - // TODO: 热更新 + // #ifdef APP-PLUS || APP-HARMONY + return appUpdates.check({ silent: silence, force: !silence }); // #endif }; diff --git a/src/sheep/request/index.js b/src/sheep/request/index.js index 19926f52..48b8dd55 100644 --- a/src/sheep/request/index.js +++ b/src/sheep/request/index.js @@ -115,7 +115,8 @@ http.interceptors.response.use( (response) => { // 约定:如果是 /auth/ 下的 URL 地址,并且返回了 accessToken 说明是登录相关的接口,则自动设置登陆令牌 if (response.config.url.indexOf('/member/auth/') >= 0 && response.data?.data?.accessToken) { - $store('user').setToken(response.data.data.accessToken, response.data.data.refreshToken); + $store('user').setToken(response.data.data.accessToken, response.data.data.refreshToken, + response.config.url.includes('/member/auth/refresh-token')); if (response.data.data.chatToken) { chatTokenManager.saveTokenInfo(response.data.data.chatToken); } @@ -127,7 +128,7 @@ http.interceptors.response.use( // 自定义处理【error 错误提示】:如果需要显示错误提示,则显示错误提示 if (response.data.code !== 0) { // 特殊:如果 401 错误码,则跳转到登录页 or 刷新令牌 - if (response.data.code === 401) { + if (response.data.code === 401 && !response.config.custom.skipAuthRefresh) { return refreshToken(response.config); } // 特殊:处理分销用户绑定失败的提示 diff --git a/src/sheep/store/__tests__/user-session.test.mjs b/src/sheep/store/__tests__/user-session.test.mjs new file mode 100644 index 00000000..b65fadf3 --- /dev/null +++ b/src/sheep/store/__tests__/user-session.test.mjs @@ -0,0 +1,61 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import vm from 'node:vm'; + +function deferred() { + let resolve; + const promise = new Promise((complete) => { resolve = complete; }); + return { promise, resolve }; +} + +async function fixture() { + const storage = new Map([['token', 'account-a']]); + const requests = []; + const boundUsers = []; + const source = (await readFile(new URL('../user.js', import.meta.url), 'utf8')) + .replace(/^import .+;\n/gm, '') + .replace('export default user;', 'globalThis.userDefinition = user;'); + const context = vm.createContext({ + defineStore: (definition) => definition, + clone: (value) => ({ ...value }), cloneDeep: (value) => JSON.parse(JSON.stringify(value)), + uni: { getStorageSync: (key) => storage.get(key), setStorageSync: (key, value) => storage.set(key, value), + removeStorageSync: (key) => storage.delete(key), reLaunch: () => {} }, + UserApi: { getUserInfo: () => { const request = deferred(); requests.push(request); return request.promise; } }, + PayWalletApi: { getPayWallet: async () => ({ code: 0, data: { balance: 0 } }) }, + appUpdates: { identityChanged: () => {}, authenticated: () => {} }, + bindPushUser: (id) => boundUsers.push(id), unbindPushUser: async () => {}, + $share: { getShareInfo: () => {}, bindBrokerageUser: () => {} }, + app: () => ({ platform: { bind_mobile: false } }), + }); + vm.runInContext(source, context); + const definition = context.userDefinition; + const store = { ...definition.state(), ...definition.actions }; + return { store, requests, storage, boundUsers }; +} + +test('quick account changes fetch fresh membership data and discard old responses', async () => { + const { store, requests, boundUsers } = await fixture(); + store.userInfo = { id: 1, sex: 1 }; + store.lastUpdateTime = Date.now(); + const oldRequest = store.getInfo(); + store.setToken('account-b', 'refresh-b'); + assert.equal(requests.length, 2); + assert.equal(store.userInfo.id, undefined); + requests[1].resolve({ code: 0, data: { id: 2, sex: 1 } }); + for (let i = 0; i < 8; i++) await Promise.resolve(); + requests[0].resolve({ code: 0, data: { id: 1, sex: 1 } }); + await oldRequest; + assert.equal(store.userInfo.id, 2); + assert.deepEqual(boundUsers, [2]); +}); + +test('token refresh keeps the account generation of an in-flight membership request', async () => { + const { store, requests } = await fixture(); + const info = store.getInfo(); + store.lastUpdateTime = Date.now(); + store.setToken('account-a-refreshed', 'refresh-a', true); + requests[0].resolve({ code: 0, data: { id: 1, sex: 1 } }); + await info; + assert.equal(store.userInfo.id, 1); +}); diff --git a/src/sheep/store/user.js b/src/sheep/store/user.js index c1c0deb7..5d9794d3 100644 --- a/src/sheep/store/user.js +++ b/src/sheep/store/user.js @@ -10,6 +10,10 @@ import OrderApi from '@/sheep/api/trade/order'; import CouponApi from '@/sheep/api/promotion/coupon'; import chatTokenManager from '@/common/chat-token-manager'; import * as wsApi from '@/common/wssocket'; +import { bindPushUser, unbindPushUser } from '@/common/jpush'; +import appUpdates from '@/sheep/platform/app-update'; + +let loginRevision = 0; // 默认用户信息 const defaultUserInfo = { @@ -52,8 +56,9 @@ const user = defineStore({ actions: { // 获取用户信息 async getInfo() { + const revision = loginRevision; const { code, data } = await UserApi.getUserInfo(); - if (code !== 0) { + if (code !== 0 || revision !== loginRevision || !this.isLogin) { return; } this.userInfo = data; @@ -65,8 +70,9 @@ const user = defineStore({ // 获得用户钱包 async getWallet() { + const revision = loginRevision; const { code, data } = await PayWalletApi.getPayWallet(); - if (code !== 0) { + if (code !== 0 || revision !== loginRevision || !this.isLogin) { return; } this.userWallet = data; @@ -87,8 +93,20 @@ const user = defineStore({ }, // 设置 token - setToken(token = '', refreshToken = '') { + setToken(token = '', refreshToken = '', isRefresh = false) { + if (!isRefresh) { + loginRevision++; + this.lastUpdateTime = 0; + if (token && token !== uni.getStorageSync('token')) { + void unbindPushUser(); + this.userInfo = clone(defaultUserInfo); + this.userWallet = clone(defaultUserWallet); + } + appUpdates.identityChanged(); + } if (token === '') { + // 同时覆盖主站退出、401 与租户切换,旧请求自行携带调用时的令牌。 + void unbindPushUser(); this.isLogin = false; uni.removeStorageSync('token'); uni.removeStorageSync('refresh-token'); @@ -103,6 +121,7 @@ const user = defineStore({ // 更新用户相关信息 (手动限流,5 秒之内不刷新) async updateUserData() { + const revision = loginRevision; if (!this.isLogin) { this.resetUserData(); return; @@ -116,6 +135,7 @@ const user = defineStore({ // 获取最新信息 await this.getInfo(); + if (revision !== loginRevision || !this.isLogin) return; this.getWallet(); // this.getNumData(); // 获取订单、优惠券等其他资产信息 return this.userInfo; @@ -140,7 +160,12 @@ const user = defineStore({ // 登录后,加载各种信息 async loginAfter() { + const revision = loginRevision; await this.updateUserData(); + if (revision !== loginRevision || !this.isLogin) return; + // 使用主站业务会员 ID 绑定,IM 用户 ID 仅用于聊天路由。 + void bindPushUser(this.userInfo.id); + appUpdates.authenticated(); // 加载购物车 // cart().getList(); @@ -157,7 +182,8 @@ const user = defineStore({ }, // 登出系统 - async logout() { + async logout(isUnauthorized = false) { + await unbindPushUser({ clearPendingRoute: !isUnauthorized }); this.resetUserData(); return !this.isLogin; }, diff --git a/src/sheep/util/__tests__/permission.test.mjs b/src/sheep/util/__tests__/permission.test.mjs new file mode 100644 index 00000000..4642755a --- /dev/null +++ b/src/sheep/util/__tests__/permission.test.mjs @@ -0,0 +1,161 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import vm from 'node:vm'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); +const usage = { feature: '上传图片', purpose: '选择用户主动提交的图片' }; + +async function fixture(options = {}) { + const { platform = 'android', sdk = 29, target = 29, granted = [], + grantOnRequest = true, photoStatus = 4, installAllowed = false, network = 'wifi' } = options; + const grants = new Set(granted); + const calls = { modals: [], requests: [], settings: [] }; + function Intent(action) { + this.action = action; + this.setData = (value) => { this.data = value; }; + } + const activity = { + getPackageName: () => 'test.app', getApplicationInfo: () => ({ targetSdkVersion: target }), + checkSelfPermission: (key) => grants.has(key) ? 0 : -1, + startActivity: (intent) => calls.settings.push(intent.action), + getPackageManager: () => ({ canRequestPackageInstalls: () => installAllowed }), + }; + const plus = { + runtime: { openURL: (url) => calls.settings.push(url) }, + android: { + importClass: (name) => typeof name !== 'string' ? name + : name === 'android.os.Build$VERSION' ? { SDK_INT: sdk } + : name === 'android.content.Intent' ? Intent + : name === 'android.net.Uri' ? { parse: (value) => value } : {}, + getAttribute: (value, key) => value[key], runtimeMainActivity: () => activity, + requestPermissions: (keys, success) => { + calls.requests.push(keys); + if (grantOnRequest) keys.forEach((key) => grants.add(key)); + success({ granted: [...grants] }); + }, + }, + // 只提供状态读取 API,确保 iOS 首次申请不依赖 Native.js 的 ObjC block。 + ios: { import: () => ({ authorizationStatus: () => photoStatus, + authorizationStatusForMediaType: () => photoStatus }), invoke: () => photoStatus }, + }; + const uni = { + getDeviceInfo: () => ({ platform }), showToast: () => {}, + showModal: (value) => { calls.modals.push(value.title); value.success({ confirm: true }); }, + showActionSheet: (value) => value.success({ tapIndex: 0 }), + getNetworkType: (value) => value.success({ networkType: network }), + }; + const source = (await readFile(new URL('../permission.ts', import.meta.url), 'utf8')) + .replace(/^[ \t]*\/\/ #ifdef MP\r?\n[\s\S]*?^[ \t]*\/\/ #endif\r?\n/gm, ''); + const compiled = ts.transpileModule(source, { compilerOptions: { + module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, + } }).outputText; + const context = vm.createContext({ exports: {}, uni, plus: platform === 'web' ? undefined : plus }); + vm.runInContext(compiled, context); + return { api: context.exports, calls }; +} + +test('Android uses the full permission name and merges concurrent requests', async () => { + const { api, calls } = await fixture(); + assert.deepEqual(await Promise.all([api.ensurePermission('camera', usage), api.ensurePermission('camera', usage)]), [true, true]); + assert.equal(calls.requests.length, 1); + assert.equal(calls.requests[0][0], 'android.permission.CAMERA'); +}); + +test('Android 10+ album saving needs no broad storage permission', async () => { + const { api, calls } = await fixture(); + assert.equal(await api.ensurePermission('album-write', usage), true); + assert.equal(calls.modals.length, 0); + assert.equal(calls.requests.length, 0); +}); + +test('coarse location and Android 14 selected photos remain usable', async () => { + const { api } = await fixture({ sdk: 34, target: 34, granted: [ + 'android.permission.ACCESS_COARSE_LOCATION', 'android.permission.READ_MEDIA_VISUAL_USER_SELECTED', + ] }); + assert.equal(await api.ensurePermission('location', usage), true); + assert.equal(await api.ensurePermission('album-read', usage), true); +}); + +test('background refresh checks permission without presenting dialogs', async () => { + const { api, calls } = await fixture(); + assert.equal(await api.ensurePermission('location', usage, false), false); + assert.equal(calls.modals.length, 0); +}); + +test('Android rejection provides the application settings route', async () => { + const { api, calls } = await fixture({ grantOnRequest: false }); + assert.equal(await api.ensurePermission('camera', usage), false); + assert.equal(calls.settings[0], 'android.settings.APPLICATION_DETAILS_SETTINGS'); +}); + +test('iOS limited album access is accepted and first authorization is left to the native business API', async () => { + const limited = await fixture({ platform: 'ios', photoStatus: 4 }); + assert.equal(await limited.api.ensurePermission('album-read', usage), true); + assert.equal(limited.calls.modals.length, 0); + const undetermined = await fixture({ platform: 'ios', photoStatus: 0 }); + assert.equal(await undetermined.api.ensurePermission('camera', usage), true); + assert.equal(undetermined.calls.modals.length, 1); +}); + +test('consecutive iOS SDK calls share the same first-use purpose notice', async () => { + const { api, calls } = await fixture({ platform: 'ios', photoStatus: 0 }); + await api.ensurePermission('camera', usage); + await api.ensurePermission('camera', usage); + assert.equal(calls.modals.length, 1); +}); + +test('unknown-source installation uses its dedicated settings route', async () => { + const { api, calls } = await fixture(); + assert.equal(await api.ensureAndroidInstallPermission(), false); + assert.equal(calls.requests.length, 0); + assert.equal(calls.settings[0], 'android.settings.MANAGE_UNKNOWN_APP_SOURCES'); +}); + +test('H5 keeps its native source selector and reports offline state', async () => { + const { api, calls } = await fixture({ platform: 'web', network: 'none' }); + const sources = await api.prepareMediaSelection(['album', 'camera'], 'image', usage); + assert.equal(sources.length, 2); + assert.equal(calls.modals.length, 0); + assert.equal((await api.getNetworkStatus()).connected, false); +}); + +async function recorderFixture(authorized = true) { + const callbacks = {}; + const calls = { uploads: 0, stopListeners: 0, stopped: 0 }; + const recorder = { + onStart: (callback) => { callbacks.start = callback; }, + onStop: (callback) => { callbacks.stop = callback; calls.stopListeners++; }, + onError: (callback) => { callbacks.error = callback; }, + start: () => callbacks.start(), + stop: () => { calls.stopped++; callbacks.stop({ tempFilePath: '/voice.mp3' }); }, + }; + const source = (await readFile(new URL('../../../common/recorder-app.js', import.meta.url), 'utf8')) + .replace(/^import .+;\n/gm, '') + .replace('export { checkIsEnable, start, close, upload };', 'globalThis.api = { checkIsEnable, start, close, upload };'); + const context = vm.createContext({ + CHAT_ENV: { BASE_URL: 'https://example.com' }, hasPermission: () => authorized, + ensurePermission: async () => true, handlePermissionFailure: async () => {}, + uni: { getRecorderManager: () => recorder, getStorageSync: () => ({ accessToken: 'test' }), + uploadFile: (options) => { calls.uploads++; options.success({ data: '{"code":200,"data":"/voice.mp3"}' }); } }, + }); + vm.runInContext(source, context); + return { api: context.api, calls }; +} + +test('cancelled subsequent recordings never trigger historical uploads', async () => { + const { api, calls } = await recorderFixture(); + await api.start(); api.close(); await api.upload(); + await api.start(); api.close(); + assert.equal(calls.uploads, 1); + assert.equal(calls.stopListeners, 1); +}); + +test('first iOS microphone authorization stops recording and requires a fresh gesture', async () => { + const { api, calls } = await recorderFixture(false); + await assert.rejects(api.start(() => false), /recording-permission-ready/); + assert.equal(calls.stopped, 1); + assert.equal(calls.uploads, 0); +}); diff --git a/src/sheep/util/file-tool.ts b/src/sheep/util/file-tool.ts index 89174986..20c70da7 100644 --- a/src/sheep/util/file-tool.ts +++ b/src/sheep/util/file-tool.ts @@ -1,3 +1,5 @@ +import { ensurePermission, handlePermissionFailure } from './permission'; + /** * 保存图片到相册 * @param {string} filePath 图片地址 @@ -430,7 +432,7 @@ const downloadImageForBrowser = async (filePath: string) => { }; // #endif -export const saveImageToPhotosAlbum = ( +export const saveImageToPhotosAlbum = async ( filePath: string, successCallback?: (res: any) => void, errorCallback?: (error: any) => void, @@ -441,6 +443,11 @@ export const saveImageToPhotosAlbum = ( return; } + if (!await ensurePermission('album-write', { feature: '保存图片', purpose: '将您主动保存的海报或图片写入系统相册' })) { + errorCallback?.({ errMsg: 'saveImageToPhotosAlbum:fail auth deny' }); + return; + } + // #ifdef H5 const saveImageForH5 = async () => { try { @@ -459,6 +466,7 @@ export const saveImageToPhotosAlbum = ( } successCallback?.({ errMsg: 'saveImageToPhotosAlbum:ok' }); } catch (error) { + void handlePermissionFailure('album-write', error); errorCallback?.(error); } }; @@ -470,7 +478,10 @@ export const saveImageToPhotosAlbum = ( uni.saveImageToPhotosAlbum({ filePath, success: (res) => successCallback?.(res), - fail: (error) => errorCallback?.(error), + fail: (error) => { + void handlePermissionFailure('album-write', error); + errorCallback?.(error); + }, }); // #endif }; diff --git a/src/sheep/util/location.ts b/src/sheep/util/location.ts index f4c3a3d5..41736b43 100644 --- a/src/sheep/util/location.ts +++ b/src/sheep/util/location.ts @@ -1,3 +1,5 @@ +import { ensurePermission, handlePermissionFailure, type PermissionUsage } from './permission'; + export type LocationErrorCode = | 'permission-denied' | 'position-unavailable' @@ -23,6 +25,10 @@ export interface CurrentLocationOptions { geocode?: boolean; isHighAccuracy?: boolean; highAccuracyExpireTime?: number; + /** 自动刷新只查询已有授权,手动定位允许弹出授权说明。 */ + interactive?: boolean; + /** 定位权限用于当前业务的具体用途。 */ + permissionUsage?: PermissionUsage; } export class LocationError extends Error { @@ -55,10 +61,14 @@ const normalizeUniLocationError = (error: any) => { return new LocationError('unknown', message); }; -export const getCurrentLocation = ( +export const getCurrentLocation = async ( options: CurrentLocationOptions = {}, -): Promise => - new Promise((resolve, reject) => { +): Promise => { + const granted = await ensurePermission('location', options.permissionUsage || { + feature: '位置服务', purpose: '获取当前城市并展示附近用户或门店', + }, options.interactive !== false); + if (!granted) throw new LocationError('permission-denied'); + return new Promise((resolve, reject) => { uni.getLocation({ type: options.type || 'wgs84', geocode: options.geocode !== false, @@ -72,9 +82,13 @@ export const getCurrentLocation = ( address: result.address, }); }, - fail: (error: any) => reject(normalizeUniLocationError(error)), + fail: (error: any) => { + if (options.interactive !== false) void handlePermissionFailure('location', error); + reject(normalizeUniLocationError(error)); + }, }); }); +}; export const isLocationError = (error: unknown): error is LocationError => error instanceof LocationError; diff --git a/src/sheep/util/permission.ts b/src/sheep/util/permission.ts index 78413d97..9cbc18eb 100644 --- a/src/sheep/util/permission.ts +++ b/src/sheep/util/permission.ts @@ -1,164 +1,288 @@ -type PermissionId = 'location' | 'storage'; -/** - * 权限请求状态 - * - none: 未请求 - * - granted: 已授权 - * - denied: 已拒绝|受限 - * - prompt: 未授权 - */ +/** 业务实际使用的敏感能力,更新文件写入应用私有目录无需相册权限。 */ +export type PermissionId = 'camera' | 'microphone' | 'location' | 'album-read' | 'album-write'; +/** 用户完成授权操作后的统一结果。 */ type RequestStatus = 'granted' | 'denied' | 'prompt'; - -/** - * 跳转应用权限设置页面 - */ -export const gotoAppPermissionSetting = () => { - const system = uni.getSystemInfoSync(); - console.log('system:', system); - if (system.platform === 'ios') { - // @ts-ignore - const UIApplication = plus.ios.import('UIApplication'); - const application2 = UIApplication.sharedApplication(); - // @ts-ignore - const NSURL2 = plus.ios.import('NSURL'); - const setting2 = NSURL2.URLWithString('app-settings:'); - application2.openURL(setting2); - plus.ios.deleteObject(setting2); - plus.ios.deleteObject(NSURL2); - plus.ios.deleteObject(application2); - } - if (system.platform == 'android') { - const Intent = plus.android.importClass('android.content.Intent'); - const Settings = plus.android.importClass('android.provider.Settings'); - const Uri = plus.android.importClass('android.net.Uri'); - const mainActivity = plus.android.runtimeMainActivity(); - // @ts-ignore - const intent = new Intent(); - // @ts-ignore - intent.setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS); - // @ts-ignore - const uri = Uri.fromParts('package', mainActivity.getPackageName(), null); - intent.setData(uri); - // @ts-ignore - mainActivity.startActivity(intent); - } +/** 权限告知对应的业务功能和使用目的。 */ +export interface PermissionUsage { + feature: string; + purpose: string; + mediaType?: 'image' | 'video'; +} +/** uni 媒体选择器支持的资源来源。 */ +export type MediaSource = 'album' | 'camera'; +/** 同一权限的并发请求共用任务,防止重复系统弹框。 */ +const pendingPermissions = new Map>(); +/** iOS 系统尚未决定时记住本会话已说明的具体用途,连续 SDK 调用不会重复展示说明。 */ +const explainedIosPurposes = new Set(); +/** 不同权限的用途说明按顺序展示。 */ +let permissionQueue: Promise = Promise.resolve(); +/** 权限在界面中的名称。 */ +const permissionNames: Record = { + camera: '相机', microphone: '麦克风', location: '位置', + 'album-read': '相册读取', 'album-write': '保存到相册', }; -export const requestPermission = (permissionId: PermissionId): Promise => { - const system = uni.getSystemInfoSync(); - if (system.platform === 'android') { - return requestAndroid(permissionId); - } - if (system.platform === 'ios') { - return requestIos(permissionId); - } - return Promise.resolve('denied'); +/** 仅在真正存在 Android/iOS 原生桥时读取原生能力,浏览器和小程序不会访问 plus。 */ +const nativeBridge = (): any => (globalThis as any).plus; +/** 获取当前原生系统,H5 浏览器的 platform=ios 不表示具备原生能力。 */ +const nativePlatform = (): string => { + if (!nativeBridge()?.runtime) return ''; + const platform = uni.getDeviceInfo().platform; + return platform === 'android' || platform === 'ios' ? platform : ''; }; +/** 显示由用户决定的确认框,失败时视为取消。 */ +const confirm = (title: string, content: string, confirmText = '继续'): Promise => + new Promise((resolve) => uni.showModal({ + title, content, confirmText, cancelText: '取消', + success: (result) => resolve(Boolean(result.confirm)), fail: () => resolve(false), + })); -/******************************** ios相关 ********************************/ +/** 打开当前应用的系统授权设置页,只有用户主动选择后才调用。 */ +export const openAppPermissionSettings = (): Promise => new Promise((resolve) => { + const bridge = nativeBridge(); + if (nativePlatform() === 'ios') { + bridge.runtime.openURL('app-settings:', () => resolve(false)); + resolve(true); + return; + } + if (nativePlatform() === 'android') { + try { + const Intent = bridge.android.importClass('android.content.Intent'); + const Uri = bridge.android.importClass('android.net.Uri'); + const activity = bridge.android.runtimeMainActivity(); + bridge.android.importClass(activity); + const intent = new Intent('android.settings.APPLICATION_DETAILS_SETTINGS'); + intent.setData(Uri.parse(`package:${activity.getPackageName()}`)); + activity.startActivity(intent); + resolve(true); + } catch { resolve(false); } + return; + } + // #ifdef MP + uni.openSetting({ success: () => resolve(true), fail: () => resolve(false) }); + return; + // #endif + // #ifndef MP + resolve(false); + // #endif +}); +/** 兼容现有权限工具的设置页入口。 */ +export const gotoAppPermissionSetting = openAppPermissionSettings; -const requestIos = (permissionId: PermissionId): Promise => { - return new Promise((resolve, reject) => { - switch (permissionId) { - case 'location': - resolve(iosLocation()); - break; - case 'storage': - resolve(iosStorage()); - break; - default: - resolve('denied'); - break; - } - }); +/** 权限拒绝后保留明确的设置入口,返回后由原业务重新读取状态。 */ +const showDeniedGuide = async (permissionId: PermissionId): Promise => { + if (await confirm(`${permissionNames[permissionId]}权限未开启`, + `请在系统设置中允许${permissionNames[permissionId]},返回应用后可重新操作。`, '去设置')) { + await openAppPermissionSettings(); + } }; - -/** - * 请求ios定位权限 - * @returns 权限请求状态 - */ -const iosLocation = (): RequestStatus => { - let result: RequestStatus = 'denied'; - // @ts-ignore - const cllocationManger = plus.ios.import('CLLocationManager'); - const enable = cllocationManger.locationServicesEnabled(); - const status = cllocationManger.authorizationStatus(); - if (!enable) { - result = 'denied'; - } else if (status === 0) { - result = 'prompt'; - } else if (status === 3 || status === 4) { - result = 'granted'; +/** 根据系统与应用 target SDK 计算实际需申请的 Android 权限。 */ +const androidPermissionKeys = (permissionId: PermissionId, usage: PermissionUsage): string[] => { + const bridge = nativeBridge(); + const Version = bridge.android.importClass('android.os.Build$VERSION'); + const sdk = Number(Version.SDK_INT); + const activity = bridge.android.runtimeMainActivity(); + bridge.android.importClass(activity); + const info = activity.getApplicationInfo(); + const targetSdk = Number(bridge.android.getAttribute(info, 'targetSdkVersion')); + if (permissionId === 'camera') return ['android.permission.CAMERA']; + if (permissionId === 'microphone') return ['android.permission.RECORD_AUDIO']; + if (permissionId === 'location') return ['android.permission.ACCESS_COARSE_LOCATION', 'android.permission.ACCESS_FINE_LOCATION']; + // Android 10+ 保存到公共相册由 MediaStore 完成,应用私有下载同样无需广泛存储授权。 + if (permissionId === 'album-write') return sdk >= 29 ? [] : ['android.permission.WRITE_EXTERNAL_STORAGE']; + if (sdk >= 33 && targetSdk >= 33) { + const key = usage.mediaType === 'video' ? 'READ_MEDIA_VIDEO' : 'READ_MEDIA_IMAGES'; + const keys = [`android.permission.${key}`]; + if (sdk >= 34 && targetSdk >= 34) keys.push('android.permission.READ_MEDIA_VISUAL_USER_SELECTED'); + return keys; + } + return ['android.permission.READ_EXTERNAL_STORAGE']; +}; +/** 位置允许模糊定位,Android 14 用户选择的部分照片同样视为可用。 */ +const androidGranted = (permissionId: PermissionId, keys: string[]): boolean => { + const activity = nativeBridge().android.runtimeMainActivity(); + nativeBridge().android.importClass(activity); + const granted = keys.map((key) => activity.checkSelfPermission(key) === 0); + return permissionId === 'location' || keys.includes('android.permission.READ_MEDIA_VISUAL_USER_SELECTED') + ? granted.some(Boolean) : granted.every(Boolean); +}; +/** 查询 iOS 权限,照片的 limited 状态(4)允许使用用户选定的照片。 */ +const iosStatus = (permissionId: PermissionId): RequestStatus => { + const ios = nativeBridge().ios; + let status: number; + if (permissionId === 'camera') status = ios.import('AVCaptureDevice').authorizationStatusForMediaType('vide'); + else if (permissionId === 'microphone') { + const session = ios.import('AVAudioSession').sharedInstance(); + ios.importClass(session); + status = Number(session.recordPermission()); + ios.deleteObject(session); + return status === 1735552628 ? 'granted' : status === 1970168948 ? 'prompt' : 'denied'; + } else if (permissionId === 'location') { + const manager = ios.import('CLLocationManager'); + if (!manager.locationServicesEnabled()) return 'denied'; + status = Number(manager.authorizationStatus()); + return status === 3 || status === 4 ? 'granted' : status === 0 ? 'prompt' : 'denied'; } else { - result = 'denied'; + const library = ios.import('PHPhotoLibrary'); + try { + const result = ios.invoke(library, 'authorizationStatusForAccessLevel:', permissionId === 'album-write' ? 1 : 2); + status = result == null ? Number(library.authorizationStatus()) : Number(result); + } catch { status = Number(library.authorizationStatus()); } + return status === 3 || status === 4 ? 'granted' : status === 0 ? 'prompt' : 'denied'; } - plus.ios.deleteObject(cllocationManger); - return result; + return status === 3 ? 'granted' : status === 0 ? 'prompt' : 'denied'; }; - -/** - * 请求ios存储权限 - * @returns 权限请求状态 - */ -const iosStorage = (): RequestStatus => { - let result: RequestStatus = 'denied'; - // @ts-ignore - const NSPhotoLibraryUsageDescription = plus.ios.import('NSPhotoLibraryUsageDescription'); - const status = NSPhotoLibraryUsageDescription.authorizationStatus(); - if (status === 0) { - result = 'prompt'; - } else if (status === 3 || status === 4) { - result = 'granted'; - } else { - result = 'denied'; +/** 小程序使用平台 scope,媒体读取由系统选择器取得用户主动选择的文件。 */ +const ensureMiniProgramPermission = (permissionId: PermissionId, interactive: boolean): Promise => { + // #ifdef MP + const scopes: Partial> = { + camera: 'scope.camera', microphone: 'scope.record', location: 'scope.userLocation', 'album-write': 'scope.writePhotosAlbum', + }; + const scope = scopes[permissionId]; + if (!scope) return Promise.resolve(true); + return new Promise((resolve) => uni.getSetting({ + success: async ({ authSetting }) => { + if ((authSetting as any)[scope]) return resolve(true); + if (!interactive) return resolve(false); + uni.authorize({ scope, success: () => resolve(true), fail: async () => { + await showDeniedGuide(permissionId); + resolve(false); + } }); + }, fail: () => resolve(false), + })); + // #endif + // #ifndef MP + return Promise.resolve(true); + // #endif +}; +/** 执行权限流程,后台刷新只查询,用户主动操作才弹用途说明和设置引导。 */ +const ensurePermissionNow = async (permissionId: PermissionId, usage: PermissionUsage, interactive: boolean): Promise => { + const platform = nativePlatform(); + if (!platform) return ensureMiniProgramPermission(permissionId, interactive); + try { + const keys = platform === 'android' ? androidPermissionKeys(permissionId, usage) : []; + const status = platform === 'android' ? (androidGranted(permissionId, keys) ? 'granted' : 'prompt') : iosStatus(permissionId); + if (status === 'granted') return true; + if (!interactive) return false; + if (status === 'denied') { + await showDeniedGuide(permissionId); + return false; + } + const purposeKey = `${permissionId}:${usage.feature}:${usage.purpose}`; + if (platform === 'ios' && explainedIosPurposes.has(purposeKey)) return true; + if (!await confirm(`${permissionNames[permissionId]}使用说明`, `${usage.feature}需要${permissionNames[permissionId]},用于${usage.purpose}。`)) return false; + if (platform === 'ios') explainedIosPurposes.add(purposeKey); + const granted = platform === 'android' ? await new Promise((resolve) => { + nativeBridge().android.requestPermissions(keys, () => resolve(androidGranted(permissionId, keys)), () => resolve(false)); + }) : true; + // iOS 尚未决定时由后续 uni 选择器、录音、定位或 SDK 原生能力触发系统授权。 + // Native.js 的 ObjC block 参数存在兼容限制,不能据其回调参数判断用户授权结果。 + if (!granted) await showDeniedGuide(permissionId); + return granted; + } catch { + if (interactive) uni.showToast({ title: `${permissionNames[permissionId]}暂时不可用,请在系统设置中检查授权`, icon: 'none' }); + return false; } - plus.ios.deleteObject(NSPhotoLibraryUsageDescription); - return result; }; - -/******************************** andorid相关 ********************************/ - -/** - * 请求android权限 - */ -const requestAndroid = (permissionID: PermissionId): Promise => { - return new Promise((resolve, reject) => { - let androidPermissionKey = ''; - switch (permissionID) { - case 'location': - androidPermissionKey = 'android.permission.ACCESS_FINE_LOCATION'; - break; - case 'storage': - androidPermissionKey = 'android.permission.WRITE_EXTERNAL_STORAGE'; - break; - } - if (!androidPermissionKey) { - resolve('denied'); - } - plus.android.requestPermissions( - [permissionID], - function (resultObj) { - let result: RequestStatus = 'denied'; - for (let i = 0; i < resultObj.granted.length; i++) { - const grantedPermission = resultObj.granted[i]; - console.log('已获取的权限:'); - result = 'granted'; - } - for (let i = 0; i < resultObj.deniedPresent.length; i++) { - const deniedPresentPermission = resultObj.deniedPresent[i]; - console.log('拒绝本次申请的权限:'); - result = 'denied'; - } - for (let i = 0; i < resultObj.deniedAlways.length; i++) { - const deniedAlwaysPermission = resultObj.deniedAlways[i]; - console.log('永久拒绝申请的权限:'); - result = 'denied'; - } - resolve(result); - }, - function (error) { - console.error(`|请求android权限|${permissionID}:`, error); - resolve('denied'); - }, - ); +/** 只读检查原生授权,录音首次授权完成后等待新的长按手势,避免系统弹框吞掉松手事件。 */ +export const hasPermission = (permissionId: PermissionId, usage: PermissionUsage = { feature: '', purpose: '' }): boolean => { + try { + const platform = nativePlatform(); + if (platform === 'android') return androidGranted(permissionId, androidPermissionKeys(permissionId, usage)); + if (platform === 'ios') return iosStatus(permissionId) === 'granted'; + } catch { return false; } + return true; +}; +/** 合并并发权限流程。true 允许继续业务原生调用,首次 iOS 的实际授权结果由业务 API 回调确认。 */ +export const ensurePermission = (permissionId: PermissionId, usage: PermissionUsage, interactive = true): Promise => { + const key = `${permissionId}:${usage.mediaType || ''}:${interactive}`; + const existing = pendingPermissions.get(key); + if (existing) return existing; + const task = permissionQueue.then(() => ensurePermissionNow(permissionId, usage, interactive)); + permissionQueue = task.catch(() => undefined); + pendingPermissions.set(key, task); + void task.finally(() => pendingPermissions.delete(key)); + return task; +}; +/** 原生业务 API 实际被系统拒绝后提供设置入口,取消选择和普通业务错误维持原有反馈。 */ +export const handlePermissionFailure = async (permissionId: PermissionId, error: unknown): Promise => { + const detail = error as any; + const message = String(detail?.errMsg || detail?.message || ''); + let denied = /permission|authorize|auth deny|denied|拒绝|权限/i.test(message); + try { + if (nativePlatform() === 'ios') denied = denied || iosStatus(permissionId) === 'denied'; + } catch { /* 原生状态查询失败时保留业务错误。 */ } + if (denied && nativePlatform()) await showDeniedGuide(permissionId); + // #ifdef MP + if (denied && !nativePlatform()) await showDeniedGuide(permissionId); + // #endif +}; +/** 保留旧 location/storage 调用签名,所有申请统一经过用途说明。 */ +export const requestPermission = async (permissionId: 'location' | 'storage', usage?: PermissionUsage): Promise => { + const id = permissionId === 'storage' ? 'album-read' : 'location'; + const allowed = await ensurePermission(id, usage || { + feature: permissionId === 'storage' ? '选择图片' : '当前位置', + purpose: permissionId === 'storage' ? '选择您主动上传的图片' : '提供附近用户和城市服务', }); + if (!allowed) return 'denied'; + return nativePlatform() === 'ios' ? iosStatus(id) : 'granted'; +}; + +/** 先选择真实来源,再申请实际需要的权限,视频拍摄同时检查麦克风。 */ +export const chooseMediaSource = async (sources: MediaSource[], mediaType: 'image' | 'video', usage: PermissionUsage): Promise => { + let source = sources[0]; + // H5 与小程序由其原生选择器展示资源来源,避免破坏浏览器文件选择的用户手势。 + if (!nativePlatform()) return source || 'album'; + if (sources.length > 1) { + source = await new Promise((resolve) => uni.showActionSheet({ + itemList: sources.map((item) => item === 'camera' ? (mediaType === 'video' ? '拍摄视频' : '拍照') : '从相册选择'), + success: ({ tapIndex }) => resolve(sources[tapIndex]), fail: () => resolve(undefined as any), + })); + } + if (!source || !await ensurePermission(source === 'camera' ? 'camera' : 'album-read', { ...usage, mediaType })) return null; + if (source === 'camera' && mediaType === 'video' && !await ensurePermission('microphone', { + feature: usage.feature, purpose: '录制您拍摄视频中的声音', + })) return null; + return source; +}; +/** 准备媒体选择参数,浏览器和小程序保留原生来源选择及其用户手势。 */ +export const prepareMediaSelection = async (sources: MediaSource[], mediaType: 'image' | 'video', usage: PermissionUsage): Promise => { + if (!nativePlatform()) return sources; + const source = await chooseMediaSource(sources, mediaType, usage); + return source ? [source] : null; +}; +/** 查询联网状态,网络失败时交由具体业务显示反馈。 */ +export const getNetworkStatus = (): Promise<{ connected: boolean; networkType: string }> => new Promise((resolve) => + uni.getNetworkType({ success: ({ networkType }) => resolve({ connected: networkType !== 'none', networkType }), + fail: () => resolve({ connected: false, networkType: 'unknown' }) })); +/** Android 8+ 未知来源安装是设置权限,不能通过 requestPermissions 申请。 */ +export const canInstallApk = (): boolean => { + if (nativePlatform() !== 'android') return false; + try { + const bridge = nativeBridge(); + const Version = bridge.android.importClass('android.os.Build$VERSION'); + if (Number(Version.SDK_INT) < 26) return true; + const activity = bridge.android.runtimeMainActivity(); + bridge.android.importClass(activity); + const manager = activity.getPackageManager(); + bridge.android.importClass(manager); + return Boolean(manager.canRequestPackageInstalls()); + } catch { return false; } +}; +/** 按需引导允许安装当前应用下载的 APK,返回前台后由更新服务复查并续装。 */ +export const ensureAndroidInstallPermission = async (): Promise => { + if (canInstallApk()) return true; + if (nativePlatform() !== 'android') return false; + if (!await confirm('允许安装应用更新', '安装新版本需要允许本应用安装未知来源应用。请开启后返回,继续安装已下载的更新包。', '去设置')) return false; + try { + const bridge = nativeBridge(); + const Intent = bridge.android.importClass('android.content.Intent'); + const Uri = bridge.android.importClass('android.net.Uri'); + const activity = bridge.android.runtimeMainActivity(); + bridge.android.importClass(activity); + const intent = new Intent('android.settings.MANAGE_UNKNOWN_APP_SOURCES'); + intent.setData(Uri.parse(`package:${activity.getPackageName()}`)); + activity.startActivity(intent); + } catch { await openAppPermissionSettings(); } + return false; }; diff --git a/src/uni_modules/jg-jpush-u/SOURCE.md b/src/uni_modules/jg-jpush-u/SOURCE.md new file mode 100644 index 00000000..96cc3c51 --- /dev/null +++ b/src/uni_modules/jg-jpush-u/SOURCE.md @@ -0,0 +1,15 @@ +# 极光推送原生依赖 + +来源 https://github.com/jiguang-official/jpush-uniapp-plugin + +版本 1.3.2,固定提交 `16a5132c69bb035d818989e6656bfa77e922aee4`。 + +本项目保留 Android、iOS 实现、接口与所需原生资源。iOS 静态库跟随官方提交,Android 通过 Maven 引入 JPush 6.3.0。 + +本地适配将 APNs entitlement 设为与会员服务默认配置一致的 production,iOS 最低版本设为官方兼容范围 iOS 12,并增加 `clearNotificationAll` 用于退出时清理通知。开发证书联调需同时调整此 entitlement、`jpush-config.js` 和服务端 APNs 环境。 + +Android 通知点击在官方事件分发后通过系统启动 Intent 拉起应用,复用已有应用任务。JS 尚未初始化时沿用官方事件队列暂存点击,待 `setEventCallBack` 后回放。 + +原生应用配置放在与 package.json 同级的 AndroidManifest.xml、Info.plist 和 nativeResources 目录,供 HBuilderX 云打包直接收集。Android AppKey 和渠道在 nativeResources/android/manifestPlaceholders.json 配置,与 src/common/jpush-config.js 保持一致。 + +当前 UniApp Android 包名为 `com.zax.htys`,云打包时使用同一包名。极光应用需绑定此包名。 diff --git a/src/uni_modules/jg-jpush-u/package.json b/src/uni_modules/jg-jpush-u/package.json new file mode 100644 index 00000000..917cc312 --- /dev/null +++ b/src/uni_modules/jg-jpush-u/package.json @@ -0,0 +1,121 @@ +{ + "id": "jg-jpush-u", + "displayName": "jg-jpush-u", + "version": "1.3.2", + "description": "jg-jpush-u", + "keywords": [ + "jg-jpush-u" + ], + "repository": "https://github.com/jiguang-official/jpush-uniapp-plugin", + "engines": { + "HBuilderX": "^4.25", + "uni-app": "^4.75", + "uni-app-x": "^4.75" + }, + "dcloudext": { + "type": "uts", + "sale": { + "regular": { + "price": "0.00" + }, + "sourcecode": { + "price": "0.00" + } + }, + "contact": { + "qq": "" + }, + "declaration": { + "ads": "无", + "data": "说明文档地址:\nhttps://docs.jiguang.cn/jpush/client/jghgzy_a_i_h#sdk-申请系统权限的说明", + "permissions": "说明文档地址:\nhttps://docs.jiguang.cn/jpush/client/jghgzy_a_i_h#sdk-申请系统权限的说明" + }, + "npmurl": "", + "darkmode": "x", + "i18n": "x", + "widescreen": "x" + }, + "uni_modules": { + "dependencies": [], + "encrypt": [], + "platforms": { + "cloud": { + "tcb": "√", + "aliyun": "√", + "alipay": "√" + }, + "client": { + "uni-app": { + "vue": { + "vue2": "-", + "vue3": "-" + }, + "web": { + "safari": "-", + "chrome": "-" + }, + "app": { + "vue": "-", + "nvue": "-", + "android": { + "extVersion": "", + "minVersion": "21" + }, + "ios": { + "extVersion": "", + "minVersion": "12" + }, + "harmony": { + "extVersion": "", + "minVersion": "12" + } + }, + "mp": { + "weixin": "-", + "alipay": "-", + "toutiao": "-", + "baidu": "-", + "kuaishou": "-", + "jd": "-", + "harmony": "-", + "qq": "-", + "lark": "-", + "xhs": "-" + }, + "quickapp": { + "huawei": "-", + "union": "-" + } + }, + "uni-app-x": { + "web": { + "safari": "-", + "chrome": "-" + }, + "app": { + "android": { + "extVersion": "", + "minVersion": "21" + }, + "ios": { + "extVersion": "", + "minVersion": "12" + }, + "harmony": { + "extVersion": "", + "minVersion": "12" + } + }, + "mp": { + "weixin": "-" + } + }, + "App": { + "app-android": "u", + "app-harmony": "u", + "app-ios": "u" + } + } + } + } +} diff --git a/src/uni_modules/jg-jpush-u/utssdk/app-android/AndroidManifest.xml b/src/uni_modules/jg-jpush-u/utssdk/app-android/AndroidManifest.xml new file mode 100644 index 00000000..477371a8 --- /dev/null +++ b/src/uni_modules/jg-jpush-u/utssdk/app-android/AndroidManifest.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/uni_modules/jg-jpush-u/utssdk/app-android/PushMessageReceiver.uts b/src/uni_modules/jg-jpush-u/utssdk/app-android/PushMessageReceiver.uts new file mode 100644 index 00000000..64670997 --- /dev/null +++ b/src/uni_modules/jg-jpush-u/utssdk/app-android/PushMessageReceiver.uts @@ -0,0 +1,472 @@ +/** + * PushMessageReceiver.uts + * 极光推送消息接收器,继承自JPushMessageReceiver + */ + +import Context from 'android.content.Context'; +import Intent from 'android.content.Intent'; +import Log from 'android.util.Log'; +import Bundle from 'android.os.Bundle'; +import ArrayList from 'java.util.ArrayList'; + +import CmdMessage from 'cn.jpush.android.api.CmdMessage'; +import CustomMessage from 'cn.jpush.android.api.CustomMessage'; +import JPushMessage from 'cn.jpush.android.api.JPushMessage'; +import NotificationMessage from 'cn.jpush.android.api.NotificationMessage'; +import VoipDataMessage from 'cn.jpush.android.api.VoipDataMessage'; +import JPushMessageReceiver from 'cn.jpush.android.service.JPushMessageReceiver'; +import JSONObject from 'org.json.JSONObject'; + +// 导入事件回调管理器 +import { eventCallbackManager } from './index.uts'; +// 导入 EventCallBack 类型 +import type { EventCallBack } from '../interface.uts'; + +/** + * 推送消息接收器类 + * 继承自极光推送的JPushMessageReceiver + */ +export class PushMessageReceiver extends JPushMessageReceiver { + private static readonly TAG: string = "U-PushMessageReceiver"; + + constructor() { + super(); + Log.e(PushMessageReceiver.TAG, "PushMessageReceiver constructor called"); + } + + /** + * 将JPushMessage转换为JSON字符串 + */ + private convertJPushMessageToJson(jPushMessage: JPushMessage): string { + try { + const jsonObject = new JSONObject(); + jsonObject.put("alias", jPushMessage.getAlias()); + jsonObject.put("tags", jPushMessage.getTags()); + jsonObject.put("pros", jPushMessage.getPros()); + jsonObject.put("checkTag", jPushMessage.getCheckTag()); + jsonObject.put("errorCode", jPushMessage.getErrorCode()); + jsonObject.put("tagCheckStateResult", jPushMessage.getTagCheckStateResult()); + jsonObject.put("isTagCheckOperator", jPushMessage.isTagCheckOperator()); + jsonObject.put("sequence", jPushMessage.getSequence()); + jsonObject.put("mobileNumber", jPushMessage.getMobileNumber()); + jsonObject.put("protoType", jPushMessage.getProtoType()); + jsonObject.put("action", jPushMessage.getAction()); + return jsonObject.toString(); + } catch (e) { + Log.e(PushMessageReceiver.TAG, "convertJPushMessageToJson error: " + e.toString()); + return "{}"; + } + } + + /** + * 将CustomMessage转换为JSON字符串 + */ + private convertCustomMessageToJson(customMessage: CustomMessage): string { + try { + const jsonObject = new JSONObject(); + jsonObject.put("messageId", customMessage.messageId); + jsonObject.put("extra", customMessage.extra); + jsonObject.put("message", customMessage.message); + jsonObject.put("contentType", customMessage.contentType); + jsonObject.put("title", customMessage.title); + jsonObject.put("senderId", customMessage.senderId); + jsonObject.put("appId", customMessage.appId); + jsonObject.put("platform", customMessage.platform); + return jsonObject.toString(); + } catch (e) { + Log.e(PushMessageReceiver.TAG, "convertCustomMessageToJson error: " + e.toString()); + return "{}"; + } + } + + /** + * 将NotificationMessage转换为JSON字符串 + */ + private convertNotificationMessageToJson(message: NotificationMessage): string { + try { + const jsonObject = new JSONObject(); + jsonObject.put("appkey", message.appkey); + jsonObject.put("msgId", message.msgId); + jsonObject.put("notificationContent", message.notificationContent); + jsonObject.put("notificationAlertType", message.notificationAlertType); + jsonObject.put("notificationTitle", message.notificationTitle); + jsonObject.put("notificationSmallIcon", message.notificationSmallIcon); + jsonObject.put("notificationLargeIcon", message.notificationLargeIcon); + jsonObject.put("notificationExtras", message.notificationExtras); + jsonObject.put("notificationStyle", message.notificationStyle); + jsonObject.put("notificationBuilderId", message.notificationBuilderId); + jsonObject.put("notificationBigText", message.notificationBigText); + jsonObject.put("notificationBigPicPath", message.notificationBigPicPath); + jsonObject.put("notificationInbox", message.notificationInbox); + jsonObject.put("notificationPriority", message.notificationPriority); + jsonObject.put("notificationImportance", message.notificationImportance); + jsonObject.put("notificationCategory", message.notificationCategory); + jsonObject.put("notificationId", message.notificationId); + jsonObject.put("developerArg0", message.developerArg0); + jsonObject.put("platform", message.platform); + jsonObject.put("appId", message.appId); + jsonObject.put("notificationType", message.notificationType); + jsonObject.put("notificationChannelId", message.notificationChannelId); + jsonObject.put("displayForeground", message.displayForeground); + jsonObject.put("_webPagePath", message._webPagePath); + jsonObject.put("isRichPush", message.isRichPush); + jsonObject.put("richType", message.richType); + jsonObject.put("deeplink", message.deeplink); + jsonObject.put("failedAction", message.failedAction); + jsonObject.put("failedLink", message.failedLink); + jsonObject.put("targetPkgName", message.targetPkgName); + jsonObject.put("sspWxAppId", message.sspWxAppId); + jsonObject.put("sspWmOriginId", message.sspWmOriginId); + jsonObject.put("sspWmType", message.sspWmType); + jsonObject.put("isWmDeepLink", message.isWmDeepLink); + jsonObject.put("inAppMsgType", message.inAppMsgType); + jsonObject.put("inAppMsgShowType", message.inAppMsgShowType); + jsonObject.put("inAppMsgShowPos", message.inAppMsgShowPos); + jsonObject.put("inAppMsgTitle", message.inAppMsgTitle); + jsonObject.put("inAppMsgContentBody", message.inAppMsgContentBody); + jsonObject.put("inAppType", message.inAppType); + jsonObject.put("inAppShowTarget", message.inAppShowTarget); + jsonObject.put("inAppClickAction", message.inAppClickAction); + jsonObject.put("inAppExtras", message.inAppExtras); + return jsonObject.toString(); + } catch (e) { + Log.e(PushMessageReceiver.TAG, "convertNotificationMessageToJson error: " + e.toString()); + return "{}"; + } + } + + /** + * 将CmdMessage转换为JSON字符串 + */ + private convertCmdMessageToJson(cmdMessage: CmdMessage): string { + try { + const jsonObject = new JSONObject(); + jsonObject.put("cmd", cmdMessage.cmd); + jsonObject.put("errorCode", cmdMessage.errorCode); + jsonObject.put("msg", cmdMessage.msg); + if (cmdMessage.cmd == 2013) { + const typedExtra = SubscriptionResultCodec.fromBundle(cmdMessage.extra); + jsonObject.put("extra", typedExtra); + jsonObject.put("commandData", typedExtra); + if (typedExtra.has("channels")) jsonObject.put("channels", typedExtra.get("channels")); + } else if (cmdMessage.cmd == 2012) { + // 小米订阅消息需要向 UTS 层透传结构化的 channel 操作结果 + jsonObject.put("extra", this.convertBundleToJson(cmdMessage.extra)); + } else { + // 保持历史命令的 extra 序列化行为,避免改变既有回调语义 + jsonObject.put("extra", cmdMessage.extra); + } + return jsonObject.toString(); + } catch (e) { + Log.e(PushMessageReceiver.TAG, "convertCmdMessageToJson error: " + e.toString()); + return "{}"; + } + } + + /** + * 将Bundle转换为JSONObject + */ + private convertBundleToJson(bundle: Bundle | null): JSONObject { + try { + const jsonObject = new JSONObject(); + if (bundle != null) { + // 使用 ArrayList 来收集 Bundle 的键,避免 iterator() 歧义 + const keySet = bundle.keySet(); + const keyList = new ArrayList(keySet); + // 在 Kotlin 中 size 是属性,不是方法,所以直接访问 + const size: Int = keyList.size; + for (let i: Int = 0; i < size; i++) { + const key = keyList.get(i); + jsonObject.put(key, bundle.get(key)); + } + } + return jsonObject; + } catch (e) { + Log.e(PushMessageReceiver.TAG, "convertBundleToJson error: " + e.toString()); + return new JSONObject(); + } + } + + /** + * 触发事件回调的通用方法 + */ + private triggerEventCallback(eventName: string, eventData: string): void { + const event: EventCallBack = { + eventName: eventName, + eventData: eventData + }; + eventCallbackManager.triggerCallBack(event); + } + + /** + * 收到自定义消息 + */ + override onMessage(context: Context, customMessage: CustomMessage): void { + super.onMessage(context, customMessage); + Log.e(PushMessageReceiver.TAG, "[onMessage] "); + + // 触发自定义消息回调 + this.triggerEventCallback('onCustomMessage', this.convertCustomMessageToJson(customMessage)); + } + + /** + * 属性操作结果回调 + */ + override onPropertyOperatorResult(context: Context, jPushMessage: JPushMessage): void { + super.onPropertyOperatorResult(context, jPushMessage); + Log.e(PushMessageReceiver.TAG, "[onPropertyOperatorResult] "); + + // 触发属性操作结果回调 + this.triggerEventCallback('onPropertyOperatorResult', this.convertJPushMessageToJson(jPushMessage)); + } + + /** + * 通知点击回调 + */ + override onNotifyMessageOpened(context: Context, message: NotificationMessage): void { + Log.e(PushMessageReceiver.TAG, "[onNotifyMessageOpened] "); + super.onNotifyMessageOpened(context, message); + + // 触发通知点击回调 + this.triggerEventCallback('onClickMessage', this.convertNotificationMessageToJson(message)); + + // 官方接收器只分发事件。先缓存点击,再通过系统入口恢复现有任务或冷启动应用。 + const launchIntent = context.getPackageManager().getLaunchIntentForPackage(context.getPackageName()); + if (launchIntent != null) { + launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED); + context.startActivity(launchIntent); + } + } + + /** + * 通知展示回调 + */ + override onNotifyMessageArrived(context: Context, message: NotificationMessage): void { + Log.e(PushMessageReceiver.TAG, "[onNotifyMessageArrived] "); + super.onNotifyMessageArrived(context, message); + + // 触发通知展示回调 + this.triggerEventCallback('onNotifyMessageArrived', this.convertNotificationMessageToJson(message)); + } + + /** + * 通知被清除回调 + */ + override onNotifyMessageDismiss(context: Context, message: NotificationMessage): void { + super.onNotifyMessageDismiss(context, message); + Log.e(PushMessageReceiver.TAG, "[onNotifyMessageDismiss] "); + + // 触发通知被清除回调 + this.triggerEventCallback('onNotifyMessageDismiss', this.convertNotificationMessageToJson(message)); + } + + /** + * 注册ID回调 + */ + override onRegister(context: Context, registrationId: string): void { + super.onRegister(context, registrationId); + Log.e(PushMessageReceiver.TAG, "[onRegister] " + registrationId); + + // 触发注册回调 + this.triggerEventCallback('onRegister', registrationId); + } + + /** + * 连接状态回调 + */ + override onConnected(context: Context, isConnected: boolean): void { + super.onConnected(context, isConnected); + Log.e(PushMessageReceiver.TAG, "[onConnected] " + isConnected); + + // 触发连接状态回调 + this.triggerEventCallback('onConnected', isConnected.toString()); + } + + /** + * 命令结果回调 + */ + override onCommandResult(context: Context, cmdMessage: CmdMessage): void { + super.onCommandResult(context, cmdMessage); + Log.e(PushMessageReceiver.TAG, "[onCommandResult] "); + + // 触发命令结果回调 + this.triggerEventCallback('onCommandResult', this.convertCmdMessageToJson(cmdMessage)); + } + + /** + * 标签操作结果回调 + */ + override onTagOperatorResult(context: Context, jPushMessage: JPushMessage): void { + Log.e(PushMessageReceiver.TAG, "[onTagOperatorResult] "); + // TagAliasOperatorHelper.getInstance().onTagOperatorResult(context, jPushMessage); + super.onTagOperatorResult(context, jPushMessage); + + // 触发标签操作结果回调 + this.triggerEventCallback('onTagOperatorResult', this.convertJPushMessageToJson(jPushMessage)); + } + + /** + * 检查标签操作结果回调 + */ + override onCheckTagOperatorResult(context: Context, jPushMessage: JPushMessage): void { + Log.e(PushMessageReceiver.TAG, "[onCheckTagOperatorResult] "); + // TagAliasOperatorHelper.getInstance().onCheckTagOperatorResult(context, jPushMessage); + super.onCheckTagOperatorResult(context, jPushMessage); + + // 触发检查标签操作结果回调 + this.triggerEventCallback('onTagOperatorResult', this.convertJPushMessageToJson(jPushMessage)); + } + + /** + * 别名操作结果回调 + */ + override onAliasOperatorResult(context: Context, jPushMessage: JPushMessage): void { + Log.e(PushMessageReceiver.TAG, "[onAliasOperatorResult] "); + // TagAliasOperatorHelper.getInstance().onAliasOperatorResult(context, jPushMessage); + super.onAliasOperatorResult(context, jPushMessage); + + // 触发别名操作结果回调 + this.triggerEventCallback('onAliasOperatorResult', this.convertJPushMessageToJson(jPushMessage)); + } + + /** + * 手机号操作结果回调 + */ + override onMobileNumberOperatorResult(context: Context, jPushMessage: JPushMessage): void { + super.onMobileNumberOperatorResult(context, jPushMessage); + Log.e(PushMessageReceiver.TAG, "[onMobileNumberOperatorResult] "); + // TagAliasOperatorHelper.getInstance().onMobileNumberOperatorResult(context, jPushMessage); + + // 触发手机号操作结果回调 + this.triggerEventCallback('onMobileNumberOperatorResult', this.convertJPushMessageToJson(jPushMessage)); + } + + /** + * 通知设置检查回调 + */ + override onNotificationSettingsCheck(context: Context, isOn: boolean, source: Int): void { + super.onNotificationSettingsCheck(context, isOn, source); + Log.e(PushMessageReceiver.TAG, "[onNotificationSettingsCheck] isOn:" + isOn + ",source:" + source); + + // 触发通知设置检查回调 + try { + const jsonObject = new JSONObject(); + jsonObject.put("isOn", isOn); + jsonObject.put("source", source); + this.triggerEventCallback('onNotificationSettingsCheck', jsonObject.toString()); + } catch (e) { + Log.e(PushMessageReceiver.TAG, "onNotificationSettingsCheck JSON error: " + e.toString()); + this.triggerEventCallback('onNotificationSettingsCheck', '{"isOn":false,"source":0}'); + } + } + + /** + * 是否需要显示通知 + */ + override isNeedShowNotification(context: Context, notificationMessage: NotificationMessage, processName: string): boolean { + return false; + } + + /** + * 通知未显示回调 + */ + override onNotifyMessageUnShow(context: Context, message: NotificationMessage): void { + super.onNotifyMessageUnShow(context, message); + Log.e(PushMessageReceiver.TAG, "[onNotifyMessageUnShow] message:"); + + // 触发通知未显示回调 + this.triggerEventCallback('onNotifyMessageUnShow', this.convertNotificationMessageToJson(message)); + } + + /** + * 应用内消息展示回调 + */ + override onInAppMessageShow(context: Context, message: NotificationMessage): void { + super.onInAppMessageShow(context, message); + Log.e(PushMessageReceiver.TAG, "[onInAppMessageShow], "); + + // 触发应用内消息展示回调 + this.triggerEventCallback('onInAppMessageShow', this.convertNotificationMessageToJson(message)); + } + + /** + * 应用内消息点击回调 + */ + override onInAppMessageClick(context: Context, message: NotificationMessage): void { + super.onInAppMessageClick(context, message); + Log.e(PushMessageReceiver.TAG, "[onInAppMessageClick], "); + + // 触发应用内消息点击回调 + this.triggerEventCallback('onInAppMessageClick', this.convertNotificationMessageToJson(message)); + } + + /** + * 拉取地理围栏列表的回调 + */ + override onGeofenceReceived(context: Context, geofences: string): void { + super.onGeofenceReceived(context, geofences); + Log.e(PushMessageReceiver.TAG, "onGeofenceReceived: " + geofences); + + // 触发地理围栏列表回调 + this.triggerEventCallback('onGeofenceReceived', geofences); + } + + /** + * 多操作按钮点击回调 + */ + override onMultiActionClicked(context: Context, intent: Intent): void { + super.onMultiActionClicked(context, intent); + Log.e(PushMessageReceiver.TAG, "[onMultiActionClicked] 用户点击了通知栏按钮"); + + // 触发多操作按钮点击回调 + try { + const jsonObject = new JSONObject(); + jsonObject.put("action", intent.getAction()); + jsonObject.put("extras", this.convertBundleToJson(intent.getExtras())); + this.triggerEventCallback('onMultiActionClicked', jsonObject.toString()); + } catch (e) { + Log.e(PushMessageReceiver.TAG, "onMultiActionClicked JSON error: " + e.toString()); + this.triggerEventCallback('onMultiActionClicked', '{"action":"","extras":{}}'); + } + } + + /** + * 触发地理围栏回调 + */ + override onGeofenceRegion(context: Context, geofence: string, longitude: Double, latitude: Double): void { + super.onGeofenceRegion(context, geofence, longitude, latitude); + Log.e(PushMessageReceiver.TAG, "onGeofenceRegion: " + geofence); + + // 触发地理围栏回调 + try { + const jsonObject = new JSONObject(); + jsonObject.put("geofence", geofence); + jsonObject.put("longitude", longitude); + jsonObject.put("latitude", latitude); + this.triggerEventCallback('onGeofenceRegion', jsonObject.toString()); + } catch (e) { + Log.e(PushMessageReceiver.TAG, "onGeofenceRegion JSON error: " + e.toString()); + this.triggerEventCallback('onGeofenceRegion', '{"geofence":"","longitude":0,"latitude":0}'); + } + } + + /** + * VOIP 透传消息回调(小米/荣耀/OPPO/VIVO 厂商通道) + */ + override onVoipMessage(context: Context, voipDataMessage: VoipDataMessage): void { + super.onVoipMessage(context, voipDataMessage); + Log.e(PushMessageReceiver.TAG, "[onVoipMessage] "); + + try { + const jsonObject = new JSONObject(); + jsonObject.put("messageId", voipDataMessage.getMessageId()); + jsonObject.put("extraData", voipDataMessage.getExtraData()); + jsonObject.put("platform", voipDataMessage.getPlatform()); + jsonObject.put("voipSupportStatus", voipDataMessage.getVoipSupportStatus()); + this.triggerEventCallback('onVoipMessage', jsonObject.toString()); + } catch (e) { + Log.e(PushMessageReceiver.TAG, "onVoipMessage JSON error: " + e.toString()); + this.triggerEventCallback('onVoipMessage', '{"messageId":"","extraData":"","platform":0}'); + } + } +} diff --git a/src/uni_modules/jg-jpush-u/utssdk/app-android/PushService.uts b/src/uni_modules/jg-jpush-u/utssdk/app-android/PushService.uts new file mode 100644 index 00000000..84680b25 --- /dev/null +++ b/src/uni_modules/jg-jpush-u/utssdk/app-android/PushService.uts @@ -0,0 +1,17 @@ +/** + * PushService.uts + * 极光推送服务类,继承自JCommonService + */ + +import JCommonService from 'cn.jpush.android.service.JCommonService'; + +/** + * 推送服务类 + * 继承自极光推送的JCommonService + */ +export class PushService extends JCommonService { + constructor() { + super(); + console.log("U-PushService", "constructor called"); + } +} \ No newline at end of file diff --git a/src/uni_modules/jg-jpush-u/utssdk/app-android/SubscriptionResultCodec.java b/src/uni_modules/jg-jpush-u/utssdk/app-android/SubscriptionResultCodec.java new file mode 100644 index 00000000..fbca75f1 --- /dev/null +++ b/src/uni_modules/jg-jpush-u/utssdk/app-android/SubscriptionResultCodec.java @@ -0,0 +1,40 @@ +package uts.sdk.modules.jgJpushU; + +import android.os.Bundle; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import java.lang.reflect.Array; +import java.util.List; +import java.util.Map; + +/** Preserve SDK types for cmd2013 only; legacy command payloads remain unchanged. */ +public final class SubscriptionResultCodec { + private SubscriptionResultCodec() {} + public static JSONObject fromBundle(Bundle bundle) throws JSONException { return bundle == null ? new JSONObject() : (JSONObject) encode(bundle, 0); } + private static Object encode(Object value, int depth) throws JSONException { + if (value == null || depth > 16) return JSONObject.NULL; + if (value instanceof Bundle) { + JSONObject result = new JSONObject(); + for (String key : ((Bundle) value).keySet()) { + if (!key.startsWith("intent_")) result.put(key, encode(((Bundle) value).get(key), depth + 1)); + } + return result; + } + if (value instanceof Map) { + JSONObject result = new JSONObject(); + for (Map.Entry entry : ((Map) value).entrySet()) { + if (entry.getKey() instanceof String && !((String) entry.getKey()).startsWith("intent_")) + result.put((String) entry.getKey(), encode(entry.getValue(), depth + 1)); + } + return result; + } + if (value instanceof List || value.getClass().isArray()) { + JSONArray result = new JSONArray(); + int size = value instanceof List ? ((List) value).size() : Array.getLength(value); + for (int i = 0; i < size; i++) result.put(encode(value instanceof List ? ((List) value).get(i) : Array.get(value, i), depth + 1)); + return result; + } + return value instanceof String || value instanceof Boolean || value instanceof Number ? value : JSONObject.NULL; + } +} diff --git a/src/uni_modules/jg-jpush-u/utssdk/app-android/config.json b/src/uni_modules/jg-jpush-u/utssdk/app-android/config.json new file mode 100644 index 00000000..ebea25a7 --- /dev/null +++ b/src/uni_modules/jg-jpush-u/utssdk/app-android/config.json @@ -0,0 +1,12 @@ +{ + "dependencies": [ + "cn.jiguang.sdk:jpush:6.3.0" + ], + "minSdkVersion": "21", + "project": { + "repositories": [ + "google()", + "mavenCentral()" + ] + } +} diff --git a/src/uni_modules/jg-jpush-u/utssdk/app-android/index.uts b/src/uni_modules/jg-jpush-u/utssdk/app-android/index.uts new file mode 100644 index 00000000..56c6b1bc --- /dev/null +++ b/src/uni_modules/jg-jpush-u/utssdk/app-android/index.uts @@ -0,0 +1,419 @@ +import JPushInterface from 'cn.jpush.android.api.JPushInterface' +import JCoreInterface from 'cn.jiguang.api.JCoreInterface' +import JPushConfig from 'cn.jpush.android.data.JPushConfig' +import ArrayList from 'java.util.ArrayList' + +export * from './PushMessageReceiver.uts' +export { PushService } from "./PushService.uts" + + +/* 引入 interface.uts 文件中定义的变量 */ +import { EventCallBackParams, EventCallBack } from '../interface.uts'; + +const TAG = "JPUSH-uni-" + +// 事件回调管理类 +class EventCallbackManager { + private callBack : EventCallBackParams | null = null + private cachedEvents : Array = [] + + setEventCallBack(param : EventCallBackParams) : void { + console.log(TAG, "setEventCallBack") + this.callBack = param + + // 如果有缓存的事件,立即回调 + if (this.cachedEvents.length > 0) { + console.log(TAG, "处理缓存事件,数量:", this.cachedEvents.length) + for (let i = 0; i < this.cachedEvents.length; i++) { + this.triggerCallBack(this.cachedEvents[i]) + } + this.cachedEvents = [] + } + } + + triggerCallBack(event : EventCallBack) : void { + const callBack = this.callBack + if (callBack !== null) { + console.log(TAG, "触发回调") + const callback = callBack.callback + if (callback !== null) { + callback(event) + } else { + console.log(TAG, "未设置回调函数") + } + } else { + console.log(TAG, "回调未设置,缓存事件") + this.cachedEvents.push(event) + } + } + + getCallBack() : EventCallBackParams | null { + return this.callBack + } + + hasCallBack() : boolean { + const callBack = this.callBack + return callBack !== null && callBack.callback !== null + } +} + +// 全局事件回调管理器实例 +export const eventCallbackManager = new EventCallbackManager() + +export const init = function (appKey? : string) { + JPushInterface.setNotificationCallBackEnable(UTSAndroid.getAppContext(),true) + if (appKey !== null) { + console.log(TAG, "init with explicit appKey") + // 创建JPushConfig并设置appKey + const config = new JPushConfig() + config.setjAppKey(appKey) + JPushInterface.init(UTSAndroid.getAppContext(), config) + } else { + console.log(TAG, "init without appKey") + JPushInterface.init(UTSAndroid.getAppContext()) + } +} + +export const setDebug = function (debug : boolean) { + console.log(TAG, "setDebug", debug) + JPushInterface.setDebugMode(debug) +} + +export const resumePush = function () { + console.log(TAG, "resumePush") + JPushInterface.resumePush(UTSAndroid.getAppContext()) +} + +export const stopPush = function () { + console.log(TAG, "stopPush") + JPushInterface.stopPush(UTSAndroid.getAppContext()) +} + +export const isPushStopped = function () : boolean { + console.log(TAG, "isPushStopped") + return JPushInterface.isPushStopped(UTSAndroid.getAppContext()) +} + +export const getPushStatus = function () { + console.log(TAG, "getPushStatus") + return JPushInterface.getPushStatus(UTSAndroid.getAppContext()) +} + + +export const setChannel = function (channel : string) { + console.log(TAG, "setChannel", channel) + JPushInterface.setChannel(UTSAndroid.getAppContext(), channel) +} + +export const getRegistrationId = function () : string { + console.log(TAG, "getRegistrationId") + return JPushInterface.getRegistrationID(UTSAndroid.getAppContext()) +} + + +export const setLatestNotificationNumber = function (maxNum : Int) { + console.log(TAG, "setLatestNotificationNumber", maxNum) + JPushInterface.setLatestNotificationNumber(UTSAndroid.getAppContext(), maxNum) +} + +export const clearNotificationAll = function () { + console.log(TAG, "clearNotificationAll") + JPushInterface.clearAllNotifications(UTSAndroid.getAppContext()) +} + +export const clearNotificationById = function (notificationId : Int) { + console.log(TAG, "clearNotificationById", notificationId) + JPushInterface.clearNotificationById(UTSAndroid.getAppContext(), notificationId) +} + +export const setTags = function (sequence : Int, tags : string[]) { + console.log(TAG, "setTags", sequence, tags) + // 将string[]转换为Set以适配Android SDK + const tagSet = new Set() + for (let i = 0; i < tags.length; i++) { + tagSet.add(tags[i]) + } + JPushInterface.setTags(UTSAndroid.getAppContext(), sequence, tagSet) +} + +export const addTags = function (sequence : Int, tags : string[]) { + console.log(TAG, "addTags", sequence, tags) + // 将string[]转换为Set以适配Android SDK + const tagSet = new Set() + for (let i = 0; i < tags.length; i++) { + tagSet.add(tags[i]) + } + JPushInterface.addTags(UTSAndroid.getAppContext(), sequence, tagSet) +} + +export const deleteTags = function (sequence : Int, tags : string[]) { + console.log(TAG, "deleteTags", sequence, tags) + // 将string[]转换为Set以适配Android SDK + const tagSet = new Set() + for (let i = 0; i < tags.length; i++) { + tagSet.add(tags[i]) + } + JPushInterface.deleteTags(UTSAndroid.getAppContext(), sequence, tagSet) +} + +export const cleanTags = function (sequence : Int) { + console.log(TAG, "cleanTags", sequence) + JPushInterface.cleanTags(UTSAndroid.getAppContext(), sequence) +} + +export const getAllTags = function (sequence : Int) { + console.log(TAG, "getAllTags", sequence) + JPushInterface.getAllTags(UTSAndroid.getAppContext(), sequence) +} + +export const checkTagBindState = function (sequence : Int, tag : string) { + console.log(TAG, "checkTagBindState", sequence, tag) + JPushInterface.checkTagBindState(UTSAndroid.getAppContext(), sequence, tag) +} + +export const setAlias = function (sequence : Int, alias : string) { + console.log(TAG, "setAlias", sequence, alias) + JPushInterface.setAlias(UTSAndroid.getAppContext(), sequence, alias) +} + +export const deleteAlias = function (sequence : Int) { + console.log(TAG, "deleteAlias", sequence) + JPushInterface.deleteAlias(UTSAndroid.getAppContext(), sequence) +} + +export const getAlias = function (sequence : Int) { + console.log(TAG, "getAlias", sequence) + JPushInterface.getAlias(UTSAndroid.getAppContext(), sequence) +} + +export const setMobileNumber = function (sequence : Int, mobileNumber : string) { + console.log(TAG, "setMobileNumber", sequence, mobileNumber) + JPushInterface.setMobileNumber(UTSAndroid.getAppContext(), sequence, mobileNumber) +} + +export const onResume = function () { + console.log(TAG, "onResume") + JPushInterface.onResume(UTSAndroid.getAppContext()) +} + +export const onPause = function () { + console.log(TAG, "onPause") + JPushInterface.onPause(UTSAndroid.getAppContext()) +} + +export const onFragmentResume = function (fragmentName : string) { + console.log(TAG, "onFragmentResume", fragmentName) + JPushInterface.onFragmentResume(UTSAndroid.getAppContext(), fragmentName) +} + +export const onFragmentPause = function (fragmentName : string) { + console.log(TAG, "onFragmentPause", fragmentName) + JPushInterface.onFragmentPause(UTSAndroid.getAppContext(), fragmentName) +} + +export const onKillProcess = function () { + console.log(TAG, "onKillProcess") + JPushInterface.onKillProcess(UTSAndroid.getAppContext()) +} + +export const requestPermission = function () { + console.log(TAG, "requestPermission") + const activity = UTSAndroid.getUniActivity() + if (activity != null) { + JPushInterface.requestPermission(activity) + } else { + console.log(TAG, "requestPermission: getUniActivity() is null, fallback to getAppContext()") + JPushInterface.requestPermission(UTSAndroid.getAppContext()) + } +} + +/** + * 申请必须权限(仅 Android,如 POST_NOTIFICATIONS,Android 13+) + * 建议在 Activity 可见时调用(如首屏 onReady),以便正确弹出系统权限框。 + */ +export const requestRequiredPermission = function () { + console.log(TAG, "requestRequiredPermission") + const activity = UTSAndroid.getUniActivity() + if (activity != null) { + JPushInterface.requestRequiredPermission(activity) + } else { + console.log(TAG, "requestRequiredPermission: no Activity, skip (call when Activity available)") + } +} + +export const isNotificationEnabled = function () : number { + console.log(TAG, "isNotificationEnabled") + return JPushInterface.isNotificationEnabled(UTSAndroid.getAppContext()) +} + +export const goToAppNotificationSettings = function () { + console.log(TAG, "goToAppNotificationSettings") + JPushInterface.goToAppNotificationSettings(UTSAndroid.getAppContext()) +} + +export const setBadgeNumber = function (curNum : Int) { + console.log(TAG, "setBadgeNumber", curNum) + JPushInterface.setBadgeNumber(UTSAndroid.getAppContext(), curNum) +} + +export const setSmartPushEnable = function (isEnable : boolean) { + console.log(TAG, "setSmartPushEnable", isEnable) + JPushInterface.setSmartPushEnable(UTSAndroid.getAppContext(), isEnable) +} + +export const setGeofenceEnable = function (isEnable : boolean) { + console.log(TAG, "setGeofenceEnable", isEnable) + JPushInterface.setGeofenceEnable(UTSAndroid.getAppContext(), isEnable) +} + +export const setDataInsightsEnable = function (isEnable : boolean) { + console.log(TAG, "setDataInsightsEnable", isEnable) + JPushInterface.setDataInsightsEnable(UTSAndroid.getAppContext(), isEnable) +} + +export const testCountryCode = function (code:string) { + JCoreInterface.testCountryCode(UTSAndroid.getAppContext(), code) +} + +export const setCountryCode = function (code:string) { + JCoreInterface.setCountryCode(UTSAndroid.getAppContext(), code) +} + +export const setKeepLongConnInBackground = function (keep : boolean) { + console.log(TAG, "setKeepLongConnInBackground", keep) + JPushInterface.setKeepLongConnInBackground(UTSAndroid.getAppContext(), keep) +} + +/** + * 请求订阅小米推送消息渠道。 + * 仅已集成并注册小米通道的小米设备支持,单次最多处理 3 个 channelId。 + * 调用结果通过 onCommandResult 回调返回,cmd 为 2012。 + */ +export const requestSubscribeChannel = function (channelIds : string[]) { + console.log(TAG, "requestSubscribeChannel", channelIds) + const channels = new ArrayList() + for (let i = 0; i < channelIds.length; i++) { + channels.add(channelIds[i]) + } + JPushInterface.requestSubscribeChannel(UTSAndroid.getAppContext(), channels) +} + +@UTSJS.keepAlive +export function setEventCallBack(param : EventCallBackParams) : void { + eventCallbackManager.setEventCallBack(param) +} + + +// New APIs: each native SDK completion owns only a request ID, never a page callback. +import JPushUPSManager from 'cn.jpush.android.ups.JPushUPSManager' +import UPSTurnCallBack from 'cn.jpush.android.ups.UPSTurnCallBack' +import TokenResult from 'cn.jpush.android.ups.TokenResult' +import Handler from 'android.os.Handler' +import Looper from 'android.os.Looper' +import Runnable from 'java.lang.Runnable' +import AtomicLong from 'java.util.concurrent.atomic.AtomicLong' +import { PushServiceResult, LiveActivityResult, LiveActivityPushTokenParams, LiveActivityPushToStartTokenParams } from '../interface.uts' + +type PendingPushReply = { generation: Long, resolve: (value: PushServiceResult) => void, reject: (error: Error) => void } +const pendingPushReplies = new Map() +const requestSequence = new AtomicLong(0) +const upgradeGeneration = new AtomicLong(0) +class MainUpgradeTask implements Runnable { + private action: (() => void) | null + constructor(action: () => void) { this.action = action } + override run(): void { const action = this.action; this.action = null; action?.() } +} +function upgradeOnMain(action: () => void): void { + new Handler(Looper.getMainLooper()).post(new MainUpgradeTask(action)) +} +function finishPushReply(id: Long, value: PushServiceResult | null, code: string | null): void { + upgradeOnMain(() => { + const pending = pendingPushReplies.get(id) + if (pending == null) return + pendingPushReplies.delete(id) + if (pending.generation != upgradeGeneration.get()) return + if (code != null) pending.reject(new Error(code)) + else if (value != null) pending.resolve(value) + }) +} +class UpgradePushCallback implements UPSTurnCallBack { + private request: Long + private enabled: boolean + constructor(request: Long, enabled: boolean) { this.request = request; this.enabled = enabled } + override onResult(result: TokenResult | null): void { + if (result == null) { finishPushReply(this.request, null, 'NATIVE_EXCEPTION'); return } + const value: PushServiceResult = { code: Number.from(result.getReturnCode()) } + if (this.enabled) value.dispatched = true + finishPushReply(this.request, value, null) + } +} +export function clearUpgradeCallbacks(): void { + upgradeGeneration.incrementAndGet() + upgradeOnMain(() => { + const current = upgradeGeneration.get() + const expired = new Array() + pendingPushReplies.forEach((reply, id) => { if (reply.generation != current) expired.push(id) }) + for (const id of expired) { pendingPushReplies.delete(id) } + }) +} +UTSAndroid.onAppActivityDestroy(() => { clearUpgradeCallbacks() }) +function changePushService(enabled: boolean): Promise { + const generation = upgradeGeneration.get() + return new Promise((resolve, reject) => { + upgradeOnMain(() => { + if (generation != upgradeGeneration.get()) { reject(new Error('BRIDGE_INVALIDATED')); return } + const context = UTSAndroid.getAppContext() + if (context == null) { reject(new Error('NO_CONTEXT')); return } + const id = requestSequence.incrementAndGet() + pendingPushReplies.set(id, { generation: generation, resolve: resolve, reject: reject }) + try { + const callback = new UpgradePushCallback(id, enabled) + if (enabled) JPushUPSManager.turnOnPush(context, callback) + else JPushUPSManager.turnOffPush(context, callback) + } catch (error) { finishPushReply(id, null, 'NATIVE_EXCEPTION') } + }) + }) +} +export function turnOffPush(): Promise { return changePushService(false) } +export function turnOnPush(): Promise { return changePushService(true) } +export function requestSubscribeChannels(channels: string[]): Promise { + if (channels.length < 1 || channels.length > 3) return Promise.reject(new Error('INVALID_ARGUMENT')) + const unique = new Set() + for (const value of channels) { + if (value.trim().length == 0 || unique.has(value)) return Promise.reject(new Error('INVALID_ARGUMENT')) + unique.add(value) + } + const generation = upgradeGeneration.get() + return new Promise((resolve,reject) => { + upgradeOnMain(() => { + if (generation != upgradeGeneration.get()) { reject(new Error('BRIDGE_INVALIDATED')); return } + const activity = UTSAndroid.getUniActivity() + if (activity == null || activity.isFinishing() || activity.isDestroyed() || !activity.hasWindowFocus()) { + reject(new Error('NO_ACTIVITY')); return + } + const list = new ArrayList() + for (const value of channels) list.add(value) + try { JPushInterface.requestSubscribeChannels(activity,list); resolve() } + catch (error) { reject(new Error('NATIVE_EXCEPTION')) } + }) + }) +} +export function initCrashHandler(): Promise { + return new Promise((resolve,reject) => { upgradeOnMain(() => { + try { JPushInterface.initCrashHandler(UTSAndroid.getAppContext()); resolve() } + catch (error) { reject(new Error('NATIVE_EXCEPTION')) } + }) }) +} +export function stopCrashHandler(): Promise { + return new Promise((resolve,reject) => { upgradeOnMain(() => { + try { JPushInterface.stopCrashHandler(UTSAndroid.getAppContext()); resolve() } + catch (error) { reject(new Error('NATIVE_EXCEPTION')) } + }) }) +} +export function getRegistrationIdAsync(): Promise { return Promise.resolve(getRegistrationId()) } +export function isPushStoppedAsync(): Promise { return Promise.resolve(isPushStopped()) } +export function setLiveActivityTokenReportEnabled(enable: boolean): Promise { return new Promise((resolve,reject) => { reject(new Error('UNSUPPORTED_PLATFORM')) }) } +export function registerLiveActivityPushToken(params: LiveActivityPushTokenParams): Promise { return new Promise((resolve,reject) => { reject(new Error('UNSUPPORTED_PLATFORM')) }) } +export function registerLiveActivityPushToStartToken(params: LiveActivityPushToStartTokenParams): Promise { return new Promise((resolve,reject) => { reject(new Error('UNSUPPORTED_PLATFORM')) }) } + +export function initAsync(): Promise { return new Promise((resolve,reject) => { reject(new Error('UNSUPPORTED_PLATFORM')) }) } diff --git a/src/uni_modules/jg-jpush-u/utssdk/app-ios/JPushUpgradeCodec.swift b/src/uni_modules/jg-jpush-u/utssdk/app-ios/JPushUpgradeCodec.swift new file mode 100644 index 00000000..bdbfcb12 --- /dev/null +++ b/src/uni_modules/jg-jpush-u/utssdk/app-ios/JPushUpgradeCodec.swift @@ -0,0 +1,21 @@ +import Foundation + +public class JPushUpgradeCodec { + private static let lifecycleLock = NSLock() + private static var lifecycle: Int64 = 0 + public static func generation() -> Int64 { lifecycleLock.lock(); defer { lifecycleLock.unlock() }; return lifecycle } + public static func invalidate() { lifecycleLock.lock(); lifecycle += 1; lifecycleLock.unlock() } + public static func validIdentifier(_ value: String) -> Bool { !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + public static func validSequence(_ number: NSNumber) -> Bool { let value = number.doubleValue; return value.isFinite && value.rounded(.towardZero) == value && value >= Double(Int32.min) && value <= Double(Int32.max) } + public static func validToken(_ value: String?) -> Bool { + guard let value = value else { return true } + return !value.isEmpty && value.utf8.count % 2 == 0 && value.utf8.allSatisfy { (48...57).contains($0) || (65...70).contains($0) || (97...102).contains($0) } + } + public static func decode(_ value: String?) -> Data? { + guard let value = value, validToken(value) else { return nil } + let bytes = Array(value.utf8) + func nibble(_ b: UInt8) -> UInt8 { b <= 57 ? b - 48 : b <= 70 ? b - 55 : b - 87 } + return Data(stride(from: 0, to: bytes.count, by: 2).map { nibble(bytes[$0]) * 16 + nibble(bytes[$0 + 1]) }) + } + public static func encode(_ value: Data?) -> String? { value.map { $0.map { String(format: "%02x", $0) }.joined() } } +} diff --git a/src/uni_modules/jg-jpush-u/utssdk/app-ios/Libs/JCore/JGCOREAPI.h b/src/uni_modules/jg-jpush-u/utssdk/app-ios/Libs/JCore/JGCOREAPI.h new file mode 100644 index 00000000..80cc87a5 --- /dev/null +++ b/src/uni_modules/jg-jpush-u/utssdk/app-ios/Libs/JCore/JGCOREAPI.h @@ -0,0 +1,19 @@ +// +// JGCOREService.h +// JCore +// +// Created by Shuni Huang on 2024/3/19. +// Copyright © 2024 jiguang. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface JGCOREAPI : NSObject + ++ (void)ci:(BOOL)enable; + +@end + +NS_ASSUME_NONNULL_END diff --git a/src/uni_modules/jg-jpush-u/utssdk/app-ios/Libs/JCore/JGInforCollectionAuth.h b/src/uni_modules/jg-jpush-u/utssdk/app-ios/Libs/JCore/JGInforCollectionAuth.h new file mode 100644 index 00000000..1a378b80 --- /dev/null +++ b/src/uni_modules/jg-jpush-u/utssdk/app-ios/Libs/JCore/JGInforCollectionAuth.h @@ -0,0 +1,27 @@ +// +// JGInforCollectionAuth.h +// JCore +// +// Created by 豆瓣 on 2021/10/27. +// Copyright © 2021 jiguang. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +/// 合规接口,是否授权极光采集一定的设备信息 +@interface JGInforCollectionAuthItems : NSObject +/// 是否授权,默认YES +@property(nonatomic,assign)BOOL isAuth; +@end + +@interface JGInforCollectionAuth : NSObject + +/// 设备信息采集授权接口(合规接口) +/// 请务必在调用初始化、功能性接口前调用此接口进行合规授权 +/// @param authBlock auth:YES 则极光认为您同意极光采集一定的设备信息 ++(void)JCollectionAuth:(void(^_Nullable)(JGInforCollectionAuthItems *authInfo))authBlock; +@end + +NS_ASSUME_NONNULL_END diff --git a/src/uni_modules/jg-jpush-u/utssdk/app-ios/Libs/JCore/libJCore.a b/src/uni_modules/jg-jpush-u/utssdk/app-ios/Libs/JCore/libJCore.a new file mode 100644 index 00000000..fc2389be Binary files /dev/null and b/src/uni_modules/jg-jpush-u/utssdk/app-ios/Libs/JCore/libJCore.a differ diff --git a/src/uni_modules/jg-jpush-u/utssdk/app-ios/Libs/JPush/JPUSHService.h b/src/uni_modules/jg-jpush-u/utssdk/app-ios/Libs/JPush/JPUSHService.h new file mode 100644 index 00000000..b880e2b6 --- /dev/null +++ b/src/uni_modules/jg-jpush-u/utssdk/app-ios/Libs/JPush/JPUSHService.h @@ -0,0 +1,1048 @@ +/* + * | | | | \ \ / / | | | | / _______| + * | |____| | \ \/ / | |____| | / / + * | |____| | \ / | |____| | | | _____ + * | | | | / \ | | | | | | |____ | + * | | | | / /\ \ | | | | \ \______| | + * | | | | /_/ \_\ | | | | \_________| + * + * Copyright (c) 2011 ~ 2017 Shenzhen HXHG. All rights reserved. + */ + +#define JPUSH_VERSION_NUMBER 6.3.0 + +#import + +NS_ASSUME_NONNULL_BEGIN + +@class CLRegion; +@class UILocalNotification; +@class CLLocation; +@class UNNotificationCategory; +@class UNNotificationSettings; +@class UNNotificationRequest; +@class UNNotification; +@class UIView; +@protocol JPUSHRegisterDelegate; +@protocol JPUSHGeofenceDelegate; +@protocol JPUSHNotiInMessageDelegate; +@protocol JPUSHInAppMessageDelegate; + +typedef void (^JPUSHTagsOperationCompletion)(NSInteger iResCode, NSSet *_Nullable iTags, NSInteger seq); +typedef void (^JPUSHTagValidOperationCompletion)(NSInteger iResCode, NSSet *_Nullable iTags, NSInteger seq, BOOL isBind); +typedef void (^JPUSHAliasOperationCompletion)(NSInteger iResCode, NSString *_Nullable iAlias, NSInteger seq); +typedef void (^JPUSHPropertiesOperationCompletion)(NSInteger iResCode, NSDictionary *_Nullable properties, NSInteger seq); +typedef void (^JPUSHLiveActivityTokenCompletion)(NSInteger iResCode, NSString *_Nullable iLiveActivityId, NSData * _Nullable pushToken, NSInteger seq); + +typedef NS_ENUM(NSInteger, JPUSHPushRegistrationErrorCode) { + JPUSHPushRegistrationErrorCodeSuccess = 0, + JPUSHPushRegistrationErrorCodeTimeout = 6002, + JPUSHPushRegistrationErrorCodeInternal = 6009, + JPUSHPushRegistrationErrorCodeSuperseded = 6010, + JPUSHPushRegistrationErrorCodeTokenUnbindFailed = 6011, + JPUSHPushRegistrationErrorCodeNotInitialized = 6012, +}; + +extern NSString *const kJPFNetworkIsConnectingNotification; // 正在连接中 +extern NSString *const kJPFNetworkDidSetupNotification; // 建立连接 +extern NSString *const kJPFNetworkDidCloseNotification; // 关闭连接 +extern NSString *const kJPFNetworkDidRegisterNotification; // 注册成功 +extern NSString *const kJPFNetworkFailedRegisterNotification; //注册失败 +extern NSString *const kJPFNetworkDidLoginNotification; // 登录成功 +extern NSString *const kJPFNetworkDidReceiveMessageNotification; // 收到消息(非APNS) +extern NSString *const kJPFServiceErrorNotification; // 错误提示 + +typedef NS_OPTIONS(NSUInteger, JPAuthorizationOptions) { + JPAuthorizationOptionNone = 0, // the application may not present any UI upon a notification being received + JPAuthorizationOptionBadge = (1 << 0), // the application may badge its icon upon a notification being received + JPAuthorizationOptionSound = (1 << 1), // the application may play a sound upon a notification being received + JPAuthorizationOptionAlert = (1 << 2), // the application may display an alert upon a notification being received + JPAuthorizationOptionCarPlay = (1 << 3), // The ability to display notifications in a CarPlay environment. + JPAuthorizationOptionCriticalAlert NS_AVAILABLE_IOS(12.0) = (1 << 4) , //The ability to play sounds for critical alerts. + JPAuthorizationOptionProvidesAppNotificationSettings NS_AVAILABLE_IOS(12.0) = (1 << 5) , //An option indicating the system should display a button for in-app notification settings. + JPAuthorizationOptionProvisional NS_AVAILABLE_IOS(12.0) = (1 << 6) , //The ability to post noninterrupting notifications provisionally to the Notification Center. + JPAuthorizationOptionAnnouncement NS_AVAILABLE_IOS(13.0) = (1 << 7) , //The ability for Siri to automatically read out messages over AirPods. +}; + +typedef NS_ENUM(NSUInteger, JPAuthorizationStatus) { + JPAuthorizationNotDetermined = 0, // The user has not yet made a choice regarding whether the application may post user notifications. + JPAuthorizationStatusDenied, // The application is not authorized to post user notifications. + JPAuthorizationStatusAuthorized, // The application is authorized to post user notifications. + JPAuthorizationStatusProvisional NS_AVAILABLE_IOS(12.0), // The application is authorized to post non-interruptive user notifications. +}; + +/*! + * 通知注册实体类 + */ +@interface JPUSHRegisterEntity : NSObject + +/*! + * 支持的类型 + * badge,sound,alert + */ +@property (nonatomic, assign) NSInteger types; +/*! + * 注入的类别 + * iOS10 UNNotificationCategory + * iOS8-iOS9 UIUserNotificationCategory + */ +@property (nonatomic, strong, nullable) NSSet *categories; +@end + +/*! + * 进行删除、查找推送实体类 + */ +@interface JPushNotificationIdentifier : NSObject + +@property (nonatomic, copy, nullable) NSArray * identifiers; // 推送的标识数组 +@property (nonatomic, copy, nullable) UILocalNotification * notificationObj NS_DEPRECATED_IOS(4_0, 10_0); // iOS10以下可以传UILocalNotification对象数据,iOS10以上无效 +@property (nonatomic, assign) BOOL delivered NS_AVAILABLE_IOS(10_0); // 在通知中心显示的或待推送的标志,默认为NO,YES表示在通知中心显示的,NO表示待推送的 +@property (nonatomic, copy, nullable) void (^findCompletionHandler)(NSArray * _Nullable results); // 用于查询回调,调用[findNotification:]方法前必须设置,results为返回相应对象数组,iOS10以下返回UILocalNotification对象数组;iOS10以上根据delivered传入值返回UNNotification或UNNotificationRequest对象数组(delivered传入YES,则返回UNNotification对象数组,否则返回UNNotificationRequest对象数组) + +@end + +/*! + * 推送通知声音实体类 + * iOS10以上有效 + */ +@interface JPushNotificationSound : NSObject +@property (nonatomic, copy, nullable) NSString *soundName; //普通通知铃声 +@property (nonatomic, copy, nullable) NSString *criticalSoundName NS_AVAILABLE_IOS(12.0); //警告通知铃声 +@property (nonatomic, assign) float criticalSoundVolume NS_AVAILABLE_IOS(12.0); //警告通知铃声音量,有效值在0~1之间,默认为1 +@end + + +/*! + * 推送内容实体类 + */ +@interface JPushNotificationContent : NSObject + +@property (nonatomic, copy) NSString *title; // 推送标题 +@property (nonatomic, copy) NSString *subtitle; // 推送副标题 +@property (nonatomic, copy) NSString *body; // 推送内容 +@property (nonatomic, copy, nullable) NSNumber *badge; // 角标的数字。如果不需要改变角标传@(-1) +@property (nonatomic, copy) NSString *action NS_DEPRECATED_IOS(8_0, 10_0); // 弹框的按钮显示的内容(IOS 8默认为"打开", 其他默认为"启动",iOS10以上无效) +@property (nonatomic, copy) NSString *categoryIdentifier; // 行为分类标识 +@property (nonatomic, copy) NSDictionary *userInfo; // 本地推送时可以设置userInfo来增加附加信息,远程推送时设置的payload推送内容作为此userInfo +@property (nonatomic, copy, nullable) NSString *sound; // 声音名称,不设置则为默认声音 +@property (nonatomic, copy, nullable) JPushNotificationSound *soundSetting NS_AVAILABLE_IOS(10.0); //推送声音实体 +@property (nonatomic, copy) NSArray *attachments NS_AVAILABLE_IOS(10_0); // 附件,iOS10以上有效,需要传入UNNotificationAttachment对象数组类型 +@property (nonatomic, copy) NSString *threadIdentifier NS_AVAILABLE_IOS(10_0); // 线程或与推送请求相关对话的标识,iOS10以上有效,可用来对推送进行分组 +@property (nonatomic, copy) NSString *launchImageName NS_AVAILABLE_IOS(10_0); // 启动图片名,iOS10以上有效,从推送启动时将会用到 +@property (nonatomic, copy) NSString *summaryArgument NS_AVAILABLE_IOS(12.0); //插入到通知摘要中的部分参数。iOS12以上有效。 +@property (nonatomic, assign) NSUInteger summaryArgumentCount NS_AVAILABLE_IOS(12.0); //插入到通知摘要中的项目数。iOS12以上有效。 +@property (nonatomic, copy, nullable) NSString *targetContentIdentifier NS_AVAILABLE_IOS(13.0); // An identifier for the content of the notification used by the system to customize the scene to be activated when tapping on a notification. +//iOS15以上的新增属性 interruptionLevel为枚举UNNotificationInterruptionLevel +// The interruption level determines the degree of interruption associated with the notification +@property (nonatomic, assign) NSUInteger interruptionLevel NS_AVAILABLE_IOS(15.0); +// Relevance score determines the sorting for the notification across app notifications. The expected range is between 0.0f and 1.0f. +@property (nonatomic, assign) double relevanceScore NS_AVAILABLE_IOS(15.0); +// iOS16以上的新增属性 +@property (nonatomic, copy, nullable) NSString * filterCriteria NS_AVAILABLE_IOS(16.0); // default nil + +@end + + +/*! + * 推送触发方式实体类 + * 注:dateComponents、timeInterval、region在iOS10以上可选择其中一个参数传入有效值,如果同时传入值会根据优先级I、II、III使其中一种触发方式生效,fireDate为iOS10以下根据时间触发时须传入的参数 + */ +@interface JPushNotificationTrigger : NSObject + +@property (nonatomic, assign) BOOL repeat; // 设置是否重复,默认为NO +@property (nonatomic, copy) NSDate *fireDate NS_DEPRECATED_IOS(2_0, 10_0); // 用来设置触发推送的时间,iOS10以上无效 +@property (nonatomic, copy) CLRegion *region NS_AVAILABLE_IOS(8_0); // 用来设置触发推送的位置,iOS8以上有效,iOS10以上优先级为I,应用需要有允许使用定位的授权 +@property (nonatomic, copy) NSDateComponents *dateComponents NS_AVAILABLE_IOS(10_0); // 用来设置触发推送的日期时间,iOS10以上有效,优先级为II +@property (nonatomic, assign) NSTimeInterval timeInterval NS_AVAILABLE_IOS(10_0); // 用来设置触发推送的时间,iOS10以上有效,优先级为III + +@end + +/*! + * 注册或更新推送实体类 + */ +@interface JPushNotificationRequest : NSObject + +@property (nonatomic, copy) NSString *requestIdentifier; // 推送请求标识 +@property (nonatomic, copy) JPushNotificationContent *content; // 设置推送的具体内容 +@property (nonatomic, copy) JPushNotificationTrigger *trigger; // 设置推送的触发方式 +@property (nonatomic, copy, nullable) void (^completionHandler)(id result); // 注册或更新推送成功回调,iOS10以上成功则result为UNNotificationRequest对象,失败则result为nil;iOS10以下成功result为UILocalNotification对象,失败则result为nil + +@end + + +/*! + * 应用内消息内容实体 + */ +@interface JPushInAppMessage : NSObject + +@property (nonatomic, copy) NSString *mesageId; // 消息id +@property (nonatomic, copy) NSString *title; // 标题 +@property (nonatomic, copy) NSString *content; // 内容 +@property (nonatomic, strong) NSArray *target; // 目标页面 +@property (nonatomic, copy) NSString *clickAction; // 跳转地址 +@property (nonatomic, strong) NSDictionary *extras; // 附加字段 + +@end + +@interface JPushCollectControl : NSObject + +/* ssid SSID信息。设置为NO,不采集SSID信息。默认为YES。 */ +@property (nonatomic, assign) BOOL ssid; +/* bssid BSSID信息。设置为NO,不采集BSSID信息。默认为YES。 */ +@property (nonatomic, assign) BOOL bssid; +/* cell 基站信息。设置为NO,不采集基站信息。默认为YES。*/ +@property (nonatomic, assign) BOOL cell; +/* gps 经纬度信息。设置为NO,不采集经纬度信息。默认为YES。 */ +@property (nonatomic, assign) BOOL gps; + +@end + +/*! + * JPush 核心头文件 + */ +@interface JPUSHService : NSObject + + +///---------------------------------------------------- +/// @name Setup 启动相关 +///---------------------------------------------------- + + +/*! + * @abstract 启动SDK + * + * @param launchingOption 启动参数. + * @param appKey 一个JPush 应用必须的,唯一的标识. 请参考 JPush 相关说明文档来获取这个标识. + * @param channel 发布渠道. 可选. + * @param isProduction 是否生产环境. 如果为开发状态,设置为 NO; 如果为生产状态,应改为 YES. + * App 证书环境取决于profile provision的配置,此处建议与证书环境保持一致. + * + * @discussion 提供SDK启动必须的参数, 来启动 SDK. + * 此接口必须在 App 启动时调用, 否则 JPush SDK 将无法正常工作. + */ ++ (void)setupWithOption:(nullable NSDictionary *)launchingOption + appKey:(NSString *)appKey + channel:(nullable NSString *)channel + apsForProduction:(BOOL)isProduction; + +/*! + * @abstract 启动SDK + * + * @param launchingOption 启动参数. + * @param appKey 一个JPush 应用必须的,唯一的标识. 请参考 JPush 相关说明文档来获取这个标识. + * @param channel 发布渠道. 可选. + * @param isProduction 是否生产环境. 如果为开发状态,设置为 NO; 如果为生产状态,应改为 YES. + * App 证书环境取决于profile provision的配置,此处建议与证书环境保持一致. + * @param advertisingId 广告标识符(IDFA) 如果不需要使用IDFA,传nil. + * + * @discussion 提供SDK启动必须的参数, 来启动 SDK. + * 此接口必须在 App 启动时调用, 否则 JPush SDK 将无法正常工作. + */ ++ (void)setupWithOption:(nullable NSDictionary *)launchingOption + appKey:(NSString *)appKey + channel:(nullable NSString *)channel + apsForProduction:(BOOL)isProduction + advertisingIdentifier:(nullable NSString *)advertisingId; + +/*! 恢复远程通知注册与 JPush 网络服务。可从任意线程调用。 */ ++ (void)turnOnPush; + +/*! 解绑 APNs Token 并暂停 JPush 网络服务;completion 固定在主线程异步回调。 */ ++ (void)turnOffPush:(nullable void (^)(NSInteger iResCode))completion; + + +///---------------------------------------------------- +/// @name APNs about 通知相关 +///---------------------------------------------------- + +/*! + * @abstract 注册要处理的远程通知类型 + * + * @param types 通知类型 + * @param categories 类别组 + * + */ ++ (void)registerForRemoteNotificationTypes:(NSUInteger)types + categories:(nullable NSSet *)categories; +/*! + * @abstract 新版本的注册方法(兼容iOS10) + * + * @param config 注册通知配置 + * @param delegate 代理 + * + */ ++ (void)registerForRemoteNotificationConfig:(JPUSHRegisterEntity *)config delegate:(nullable id)delegate; + + ++ (void)registerDeviceToken:(NSData *)deviceToken; + +/*! + * @abstract 控制 Live Activity Token 上报链路埋点。 + * + * @discussion 进程启动默认关闭,仅影响诊断埋点,不影响 Token 的绑定、解绑、 + * 重发、超时和业务回调。该开关与服务端下发的埋点开关相互独立:任一为开即上报, + * 两者都关才停止。通过本接口关闭且服务端开关也为关时,旧代际尚未交给 JCore 的埋点 + * 会被丢弃;服务端开关关闭后,尚未发出的埋点不再上报。 + */ ++ (void)setLiveActivityTokenReportEnabled:(BOOL)enabled; + +/*! + * @abstract 上报liveactivity的启动token + * + * @param activityAttributes 某liveActivity定义的属性类型 + * @param pushToStartToken 对应该liveactivity的pushToStartToken,如有更新,请及时调用该方法更新pushToStartToken + * @param completion 响应回调 + * @param seq 请求序列号 + */ ++ (void)registerLiveActivity:(NSString *)activityAttributes + pushToStartToken:(nullable NSData *)pushToStartToken + completion:(nullable JPUSHLiveActivityTokenCompletion)completion + seq:(NSInteger)seq; + +/*! + * @abstract 注册liveActivity并上报其pushToken + * 在pushToken有变化的时候同步调用该接口。 + * 在liveActivity结束的时候,请同步调用该接口,pushToken传nil + * + * @param liveActivityId 标识某一个liveActivity + * @param pushToken 对应该liveActivity的pushToken,如有更新,请及时调用该方法更新pushToken + * @param completion 响应回调 + * @param seq 请求序列号 + */ ++ (void)registerLiveActivity:(NSString *)liveActivityId + pushToken:(nullable NSData *)pushToken + completion:(nullable JPUSHLiveActivityTokenCompletion)completion + seq:(NSInteger)seq; + +/*! + * @abstract 处理收到的 APNs 消息 + */ ++ (void)handleRemoteNotification:(NSDictionary *)remoteInfo; + +/*! + * @abstract 向极光服务器提交Token + * + * @param voipToken 推送使用的Voip Token + */ ++ (void)registerVoipToken:(NSData *)voipToken; + + +/*! + * @abstract 处理收到的 Voip 消息 + * + * @param remoteInfo 下发的 Voip 内容 + */ ++ (void)handleVoipNotification:(NSDictionary *)remoteInfo; + + +/*! +* @abstract 检测通知授权状态 +* @param completion 授权结果通过status值返回,详见JPAuthorizationStatus +*/ ++ (void)requestNotificationAuthorization:(nullable void (^)(JPAuthorizationStatus status))completion; + +/*! +* @abstract 跳转至系统设置页面,iOS8及以上有效 +*/ ++ (void)openSettingsForNotification:(nullable void (^)(BOOL success))completionHandler NS_AVAILABLE_IOS(8_0); + +/*! + * Tags操作接口 + * 支持增加/覆盖/删除/清空/查询操作 + * 详情请参考文档:https://docs.jiguang.cn/jpush/client/iOS/ios_api/) + */ + +/** + 增加tags + + @param tags 需要增加的tags集合 + @param completion 响应回调 + @param seq 请求序列号 + */ ++ (void)addTags:(NSSet *)tags + completion:(nullable JPUSHTagsOperationCompletion)completion + seq:(NSInteger)seq; + +/** + 覆盖tags + 调用该接口会覆盖用户所有的tags + + @param tags 需要设置的tags集合 + @param completion 响应回调 + @param seq 请求序列号 + */ ++ (void)setTags:(NSSet *)tags + completion:(nullable JPUSHTagsOperationCompletion)completion + seq:(NSInteger)seq; + +/** + 删除指定tags + + @param tags 需要删除的tags集合 + @param completion 响应回调 + @param seq 请求序列号 + */ ++ (void)deleteTags:(NSSet *)tags + completion:(nullable JPUSHTagsOperationCompletion)completion + seq:(NSInteger)seq; + +/** + 清空所有tags + @param completion 响应回调 + @param seq 请求序列号 + */ ++ (void)cleanTags:(nullable JPUSHTagsOperationCompletion)completion + seq:(NSInteger)seq; + +/** + 查询全部tags + + @param completion 响应回调,请在回调中获取查询结果 + @param seq 请求序列号 + */ ++ (void)getAllTags:(nullable JPUSHTagsOperationCompletion)completion + seq:(NSInteger)seq; + +/** + 验证tag是否绑定 + + @param completion 响应回调,回调中查看是否绑定 + @param seq 请求序列号 + */ ++ (void)validTag:(NSString *)tag + completion:(nullable JPUSHTagValidOperationCompletion)completion + seq:(NSInteger)seq; + +/** + 设置Alias + + @param alias 需要设置的alias + @param completion 响应回调 + @param seq 请求序列号 + */ ++ (void)setAlias:(NSString *)alias + completion:(nullable JPUSHAliasOperationCompletion)completion + seq:(NSInteger)seq; + +/** + 删除alias + + @param completion 响应回调 + @param seq 请求序列号 + */ ++ (void)deleteAlias:(nullable JPUSHAliasOperationCompletion)completion + seq:(NSInteger)seq; + +/** + 查询当前alias + + @param completion 响应回调 + @param seq 请求序列号 + */ ++ (void)getAlias:(nullable JPUSHAliasOperationCompletion)completion + seq:(NSInteger)seq; + + +/*! + * @abstract 过滤掉无效的 tags + * + * @discussion 如果 tags 数量超过限制数量, 则返回靠前的有效的 tags. + * 建议设置 tags 前用此接口校验. SDK 内部也会基于此接口来做过滤. + */ ++ (NSSet *)filterValidTags:(NSSet *)tags; + + +/*! + * Property操作接口 + * 支持增加/删除/清空操作 + * 详情请参考文档:https://docs.jiguang.cn/jpush/client/iOS/ios_api/) + */ + +/** + 新增/更新用户属性 + + 如果某个用户属性之前已经存在了,则会更新;不存在,则会新增 + + @param properties 需要新增或者更新的的用户属性内容,类型为NSDictionary; + Key 为用户属性名称,类型必须是 NSString 类型;Value为用户属性值,只支持 NSString、NSNumber、NSDate类型,如果属性为BOOL类型,传值时请转成NSNumber类型 + @param completion 响应回调 + @param seq 请求序列号 + */ ++ (void)setProperties:(NSDictionary *)properties + completion:(nullable JPUSHPropertiesOperationCompletion)completion + seq:(NSInteger)seq; + + +/** + 删除指定属性 + + @param keys 需要删除的属性名称集合 + @param completion 响应回调 + @param seq 请求序列号 + */ ++ (void)deleteProperties:(NSSet *)keys + completion:(nullable JPUSHPropertiesOperationCompletion)completion + seq:(NSInteger)seq; + + +/** + 清空所有属性 + @param completion 响应回调 + @param seq 请求序列号 + */ ++ (void)cleanProperties:(nullable JPUSHPropertiesOperationCompletion)completion + seq:(NSInteger)seq; + + +/*! + * 应用内消息接口 + * 使用应用内消息需要配置以下两个接口。请在进入页面和离开页面的时候相应地配置。以下两个接口请配套调用。 + */ + +/** + 进入页面 + + 请与 + (void)pageLeave:(NSString *)pageName; 方法配套使用 + + @param pageName 页面名 + @discussion 使用应用内消息功能,需要配置pageEnterTo:和pageLeave:函数。 + */ ++ (void)pageEnterTo:(NSString *)pageName; + + +/** + 离开页面 + + 请与 + (void)pageEnterTo:(NSString *)pageName;方法配套使用 + + @param pageName 页面名 + @discussion 使用应用内消息功能,需要配置pageEnterTo:和pageLeave:函数。 + */ ++ (void)pageLeave:(NSString *)pageName; + + +/*! +* @abstract 设置应用内消息的代理 +* +* @discussion 遵守JPUSHInAppMessageDelegate的代理对象 +* +*/ ++ (void)setInAppMessageDelegate:(id)inAppMessageDelegate; + + +///---------------------------------------------------- +/// @name Stats 统计功能 +///---------------------------------------------------- + +/*! + * @abstract 开始记录页面停留 + * + * @param pageName 页面名称 + * @discussion JCore 1.1.8 版本后,如需统计页面流,请使用 JAnalytics + */ ++ (void)startLogPageView:(NSString *)pageName __attribute__((deprecated("JCore 1.1.8 版本已过期"))); + +/*! + * @abstract 停止记录页面停留 + * + * @param pageName 页面 + * @discussion JCore 1.1.8 版本后,如需统计页面流,请使用 JAnalytics + */ ++ (void)stopLogPageView:(NSString *)pageName __attribute__((deprecated("JCore 1.1.8 版本已过期"))); + +/*! + * @abstract 直接上报在页面的停留时间 + * + * @param pageName 页面 + * @param seconds 停留的秒数 + * @discussion JCore 1.1.8 版本后,如需统计页面流,请使用 JAnalytics + */ ++ (void)beginLogPageView:(NSString *)pageName duration:(int)seconds __attribute__((deprecated("JCore 1.1.8 版本已过期"))); + +/*! + * @abstract 开启Crash日志收集 + * + * @discussion 默认是关闭状态. + */ ++ (void)crashLogON; + +/*! + * @abstract 地理位置上报 + * + * @param latitude 纬度. + * @param longitude 经度. + * + */ ++ (void)setLatitude:(double)latitude longitude:(double)longitude; + +/*! + * @abstract 地理位置上报 + * + * @param location 直接传递 CLLocation * 型的地理信息 + * + * @discussion 需要链接 CoreLocation.framework 并且 #import + */ ++ (void)setLocation:(CLLocation *)location; + +/** + 设置地理围栏的最大个数 + 默认值为 10 ,iOS系统默认地理围栏最大个数为20 + @param count 个数 count + */ ++ (void)setGeofeneceMaxCount:(NSInteger)count; + +/** + 设置地理围栏'圈内'类型的检测周期 + 默认15分钟检测一次 + */ ++ (void)setGeofenecePeriodForInside:(NSInteger)seconds; + +/** + 注册地理围栏的代理 + + @param delegate 代理 + @param launchOptions app启动完成是收到的字段参数 + */ ++ (void)registerLbsGeofenceDelegate:(id)delegate withLaunchOptions:(nullable NSDictionary *)launchOptions; + +/** + 删除地理围栏 + + @param geofenceId 地理围栏id + */ ++ (void)removeGeofenceWithIdentifier:(NSString *)geofenceId; + +///---------------------------------------------------- +/// @name Local Notification 本地通知 +///---------------------------------------------------- +/*! + * @abstract 注册或更新推送 (支持iOS10,并兼容iOS10以下版本) + * + * JPush 2.1.9新接口 + * @param request JPushNotificationRequest类型,设置推送的属性,设置已有推送的request.requestIdentifier即更新已有的推送,否则为注册新推送,更新推送仅仅在iOS10以上有效,结果通过request.completionHandler返回 + * @discussion 旧的注册本地推送接口被废弃,使用此接口可以替换 + * + */ ++ (void)addNotification:(JPushNotificationRequest *)request; + +/*! + * @abstract 移除推送 (支持iOS10,并兼容iOS10以下版本) + * + * JPush 2.1.9新接口 + * @param identifier JPushNotificationIdentifier类型,iOS10以上identifier设置为nil,则移除所有在通知中心显示推送和待推送请求,也可以通过设置identifier.delivered和identifier.identifiers来移除相应在通知中心显示推送或待推送请求,identifier.identifiers如果设置为nil或空数组则移除相应标志下所有在通知中心显示推送或待推送请求;iOS10以下identifier设置为nil,则移除所有推送,identifier.delivered属性无效,另外可以通过identifier.notificationObj传入特定推送对象来移除此推送。 + * @discussion 旧的所有删除推送接口被废弃,使用此接口可以替换 + * + */ ++ (void)removeNotification:(nullable JPushNotificationIdentifier *)identifier; + +/*! + * @abstract 查找推送 (支持iOS10,并兼容iOS10以下版本) + * + * JPush 2.1.9新接口 + * @param identifier JPushNotificationIdentifier类型,iOS10以上可以通过设置identifier.delivered和identifier.identifiers来查找相应在通知中心显示推送或待推送请求,identifier.identifiers如果设置为nil或空数组则返回相应标志下所有在通知中心显示推送或待推送请求;iOS10以下identifier.delivered属性无效,identifier.identifiers如果设置nil或空数组则返回所有未触发的推送。须要设置identifier.findCompletionHandler回调才能得到查找结果,通过(NSArray *results)返回相应对象数组。 + * @discussion 旧的查找推送接口被废弃,使用此接口可以替换 + * + */ ++ (void)findNotification:(JPushNotificationIdentifier *)identifier; + +/*! + * @abstract 本地推送,最多支持64个 + * + * @param fireDate 本地推送触发的时间 + * @param alertBody 本地推送需要显示的内容 + * @param badge 角标的数字。如果不需要改变角标传-1 + * @param alertAction 弹框的按钮显示的内容(IOS 8默认为"打开", 其他默认为"启动") + * @param notificationKey 本地推送标示符 + * @param userInfo 自定义参数,可以用来标识推送和增加附加信息 + * @param soundName 自定义通知声音,设置为nil为默认声音 + * + * @discussion 最多支持 64 个定义,此方法被[addNotification:]方法取代 + */ ++ (UILocalNotification *)setLocalNotification:(NSDate *)fireDate + alertBody:(NSString *)alertBody + badge:(int)badge + alertAction:(NSString *)alertAction + identifierKey:(NSString *)notificationKey + userInfo:(NSDictionary *)userInfo + soundName:(NSString *)soundName __attribute__((deprecated("JPush 2.1.9 版本已过期"))); + +/*! + * @abstract 本地推送 (支持 iOS8 新参数) + * + * IOS8新参数 + * @param region 自定义参数 + * @param regionTriggersOnce 自定义参数 + * @param category 自定义参数 + * @discussion 此方法被[addNotification:]方法取代 + */ ++ (UILocalNotification *)setLocalNotification:(NSDate *)fireDate + alertBody:(NSString *)alertBody + badge:(int)badge + alertAction:(NSString *)alertAction + identifierKey:(NSString *)notificationKey + userInfo:(NSDictionary *)userInfo + soundName:(NSString *)soundName + region:(CLRegion *)region + regionTriggersOnce:(BOOL)regionTriggersOnce + category:(NSString *)category NS_AVAILABLE_IOS(8_0) __attribute__((deprecated("JPush 2.1.9 版本已过期"))); + +/*! + * @abstract 前台展示本地推送 + * + * @param notification 本地推送对象 + * @param notificationKey 需要前台显示的本地推送通知的标示符 + * + * @discussion 默认App在前台运行时不会进行弹窗,在程序接收通知调用此接口可实现指定的推送弹窗。--iOS10以下还可继续使用,iOS10以上在[UNUserNotificationCenterDelegate willPresentNotification:withCompletionHandler:]方法中调用completionHandler(UNNotificationPresentationOptionSound | UNNotificationPresentationOptionAlert);即可 + */ ++ (void)showLocalNotificationAtFront:(UILocalNotification *)notification + identifierKey:(NSString *)notificationKey __attribute__((deprecated("JPush 2.1.9 版本已过期"))); +/*! + * @abstract 删除本地推送定义 + * + * @param notificationKey 本地推送标示符 + * @discussion 此方法被[removeNotification:]方法取代 + */ ++ (void)deleteLocalNotificationWithIdentifierKey:(NSString *)notificationKey __attribute__((deprecated("JPush 2.1.9 版本已过期"))); + +/*! + * @abstract 删除本地推送定义 + * @discussion 此方法被[removeNotification:]方法取代 + */ ++ (void)deleteLocalNotification:(UILocalNotification *)localNotification __attribute__((deprecated("JPush 2.1.9 版本已过期"))); + +/*! + * @abstract 获取指定通知 + * + * @param notificationKey 本地推送标示符 + * @return 本地推送对象数组, [array count]为0时表示没找到 + * @discussion 此方法被[findNotification:]方法取代 + */ ++ (NSArray *)findLocalNotificationWithIdentifier:(NSString *)notificationKey __attribute__((deprecated("JPush 2.1.9 版本已过期"))); + +/*! + * @abstract 清除所有本地推送对象 + * @discussion 此方法被[removeNotification:]方法取代 + */ ++ (void)clearAllLocalNotifications __attribute__((deprecated("JPush 2.1.9 版本已过期"))); + + +///---------------------------------------------------- +/// @name Server badge 服务器端 badge 功能 +///---------------------------------------------------- + +/*! + * @abstract 设置角标(到服务器) + * + * @param value 新的值. 会覆盖服务器上保存的值(这个用户) + * + * @discussion 本接口不会改变应用本地的角标值. + * 本地仍须调用 UIApplication:setApplicationIconBadgeNumber 函数来设置脚标. + * + * 本接口用于配合 JPush 提供的服务器端角标功能. + * 该功能解决的问题是, 服务器端推送 APNs 时, 并不知道客户端原来已经存在的角标是多少, 指定一个固定的数字不太合理. + * + * JPush 服务器端脚标功能提供: + * + * - 通过本 API 把当前客户端(当前这个用户的) 的实际 badge 设置到服务器端保存起来; + * - 调用服务器端 API 发 APNs 时(通常这个调用是批量针对大量用户), + * 使用 "+1" 的语义, 来表达需要基于目标用户实际的 badge 值(保存的) +1 来下发通知时带上新的 badge 值; + */ ++ (BOOL)setBadge:(NSInteger)value; + +/*! + * @abstract 重置脚标(为0) + * + * @discussion 相当于 [setBadge:0] 的效果. + * 参考 [JPUSHService setBadge:] 说明来理解其作用. + */ ++ (void)resetBadge; + +///---------------------------------------------------- +/// @name Other Feature 其他功能 +///---------------------------------------------------- + +/*! + * @abstract 设置手机号码(到服务器) + * + * @param mobileNumber 手机号码. 会与用户信息一一对应。可为空,为空则清除号码 + * @param completion 响应回调。成功则error为空,失败则error带有错误码及错误信息 + * + * @discussion 设置手机号码后,可实现“推送不到短信到”的通知方式,提高推送达到率。结果信息通过completion异步返回,也可将completion设置为nil不处理结果信息。 + * + */ ++ (void)setMobileNumber:(NSString *)mobileNumber completion:(nullable void (^)(NSError *error))completion; + +///---------------------------------------------------- +/// @name Logs and others 日志与其他 +///---------------------------------------------------- + +/*! + * @abstract JPush标识此设备的 registrationID + * + * @discussion SDK注册成功后, 调用此接口获取到 registrationID 才能够获取到. + * + * JPush 支持根据 registrationID 来进行推送. + * 如果你需要此功能, 应该通过此接口获取到 registrationID 后, 上报到你自己的服务器端, 并保存下来. + * registrationIDCompletionHandler:是新增的获取registrationID的方法,需要在block中获取registrationID,resCode为返回码,模拟器调用此接口resCode返回1011,registrationID返回nil. + * 更多的理解请参考 JPush 的文档网站. + */ ++ (NSString *)registrationID; + ++ (void)registrationIDCompletionHandler:(void(^)(int resCode,NSString * _Nullable registrationID))completionHandler; + +/*! + * @abstract 打开日志级别到 Debug + * + * @discussion JMessage iOS 的日志系统参考 Android 设计了级别. + * 从低到高是: Verbose, Debug, Info, Warning, Error. + * 对日志级别的进一步理解, 请参考 Android 相关的说明. + * + * SDK 默认开启的日志级别为: Info. 只显示必要的信息, 不打印调试日志. + * + * 请在SDK启动后调用本接口,调用本接口可打开日志级别为: Debug, 打印调试日志. + */ ++ (void)setDebugMode; + +/*! + * @abstract 关闭日志 + * + * @discussion 关于日志级别的说明, 参考 [JPUSHService setDebugMode] + * + * 虽说是关闭日志, 但还是会打印 Warning, Error 日志. 这二种日志级别, 在程序运行正常时, 不应有打印输出. + * + * 建议在发布的版本里, 调用此接口, 关闭掉日志打印. + */ ++ (void)setLogOFF; + +/*! + 数据采集控制 + + @param control 数据采集配置。 + */ ++ (void)setCollectControl:(JPushCollectControl *)control; + +/*! + * @abstract 设置心跳时间间隔 + * + * @param interval 心跳时间间隔 单位为秒, 取值范围为[30,290] + * + * @discussion 请在初始化函数之前调用该接口。 + * + */ ++ (void)setHeartBeatTimeInterval:(NSInteger)interval; + +/*! + * @abstract 设置SDK地理位置权限开关 + * + * @discussion 关闭地理位置之后,SDK地理围栏的相关功能将受到影响,默认是开启。 + * + */ ++ (void)setLocationEanable:(BOOL)isEanble; + +/*! + * @abstract 设置PUSH开关 + * + * @discussion 关闭PUSH之后,将接收不到极光通知推送、自定义消息推送、liveActivity消息推送,默认是开启。 + * + */ ++ (void)setPushEnable:(BOOL)isEnable completion:(nullable void (^)(NSInteger iResCode))completion; + +/*! + * @abstract 获取推送状态 + * + * @param completion 回调block,返回结果码和推送状态 + * iResCode: 结果码,0表示成功 + * isStopped: YES 表示推送已停止,NO 表示推送功能开启,在 iResCode 为 0 时有效 + * + * @discussion 获取当前推送的停止状态 + * + */ ++ (void)getPushStatus:(nullable void (^)(NSInteger iResCode, BOOL isStopped))completion; + +/*! + * @abstract 设置进入后台是否允许长连接 + * + * @param isEnable YES:允许,NO:不允许,默认是NO,进入后台会关闭长连接,回到前台会重新接入。 + * + * @discussion 请在初始化函数之前调用 + * + */ ++ (void)setBackgroundEnable:(BOOL)isEnable; + +/*! + * @abstract 设置用户分群推送功能开关 + * + * @param isEnable YES:开启,NO:关闭,默认是开启。 + * + */ ++ (void)setSmartPushEnable:(BOOL)isEnable; + +/*! + * @abstract 设置应用数据洞察扩展功能开关 + * + * @param isEnable YES:开启,NO:关闭,默认是开启。 + * + */ ++ (void)setDataInsightsEnable:(BOOL)isEnable; + + +/*! +* @abstract 设置应用内提醒消息的代理 +* +* @discussion 遵守JPushNotiInMessageDelegate的代理对象 +* +*/ ++ (void)setNotiInMessageDelegate:(id)notiInMessageDelegate; + + + +///---------------------------------------------------- +///********************下列方法已过期******************** +///**************请使用新版tag/alias操作接口************** +///---------------------------------------------------- +/// @name Tag alias setting 设置别名与标签 +///---------------------------------------------------- + +/*! + * 下面的接口是可选的 + * 设置标签和(或)别名(若参数为nil,则忽略;若是空对象,则清空;详情请参考文档:https://docs.jiguang.cn/jpush/client/iOS/ios_api/) + * setTags:alias:fetchCompletionHandle:是新的设置标签别名的方法,不再需要显示声明回调函数,只需要在block里面处理设置结果即可. + * WARN: 使用block时需要注意循环引用问题 + */ ++ (void) setTags:(NSSet *)tags + alias:(NSString *)alias +callbackSelector:(SEL)cbSelector + target:(id)theTarget __attribute__((deprecated("JPush 2.1.1 版本已过期"))); ++ (void) setTags:(NSSet *)tags + alias:(NSString *)alias +callbackSelector:(SEL)cbSelector + object:(id)theTarget __attribute__((deprecated("JPush 3.0.6 版本已过期"))); ++ (void) setTags:(NSSet *)tags +callbackSelector:(SEL)cbSelector + object:(id)theTarget __attribute__((deprecated("JPush 3.0.6 版本已过期"))); ++ (void)setTags:(NSSet *)tags + alias:(NSString *)alias +fetchCompletionHandle:(void (^)(int iResCode, NSSet *iTags, NSString *iAlias))completionHandler __attribute__((deprecated("JPush 3.0.6 版本已过期"))); ++ (void) setTags:(NSSet *)tags +aliasInbackground:(NSString *)alias __attribute__((deprecated("JPush 3.0.6 版本已过期"))); ++ (void)setAlias:(NSString *)alias +callbackSelector:(SEL)cbSelector + object:(id)theTarget __attribute__((deprecated("JPush 3.0.6 版本已过期"))); + +@end + +@class UNUserNotificationCenter; +@class UNNotificationResponse; + +@protocol JPUSHRegisterDelegate + +/* + * @brief handle UserNotifications.framework [willPresentNotification:withCompletionHandler:] + * @param center [UNUserNotificationCenter currentNotificationCenter] 新特性用户通知中心 + * @param notification 前台得到的的通知对象 + * @param completionHandler 该callback中的options 请使用UNNotificationPresentationOptions + */ +- (void)jpushNotificationCenter:(UNUserNotificationCenter *)center willPresentNotification:(UNNotification *)notification withCompletionHandler:(void (^)(NSInteger options))completionHandler; +/* + * @brief handle UserNotifications.framework [didReceiveNotificationResponse:withCompletionHandler:] + * @param center [UNUserNotificationCenter currentNotificationCenter] 新特性用户通知中心 + * @param response 通知响应对象 + * @param completionHandler + */ +- (void)jpushNotificationCenter:(UNUserNotificationCenter *)center didReceiveNotificationResponse:(UNNotificationResponse *)response withCompletionHandler:(void(^)(void))completionHandler; + +/* + * @brief handle UserNotifications.framework [openSettingsForNotification:] + * @param center [UNUserNotificationCenter currentNotificationCenter] 新特性用户通知中心 + * @param notification 当前管理的通知对象 + */ +- (void)jpushNotificationCenter:(UNUserNotificationCenter *)center openSettingsForNotification:(UNNotification *)notification NS_AVAILABLE_IOS(12.0); + +/** + * 监测通知授权状态返回的结果 + * @param status 授权通知状态,详见JPAuthorizationStatus + * @param info 更多信息,预留参数 + */ +- (void)jpushNotificationAuthorization:(JPAuthorizationStatus)status withInfo:(nullable NSDictionary *)info; + +@end + +@protocol JPUSHGeofenceDelegate +/** + 触发地理围栏 + @param geofence 地理围栏触发时返回的信息 + @param error 错误信息 + */ +- (void)jpushGeofenceRegion:(nullable NSDictionary *)geofence + error:(nullable NSError *)error; + +/** + 拉取地理围栏列表的回调 + + @param geofenceList 地理围栏列表 + */ +- (void)jpushCallbackGeofenceReceived:(nullable NSArray *)geofenceList; + +/** + 进入地理围栏区域 + + @param geofenceId 地理围栏id + @param userInfo 地理围栏触发时返回的信息 + @param error 错误信息 + */ +- (void)jpushGeofenceIdentifer:(NSString *)geofenceId didEnterRegion:(NSDictionary *_Nullable)userInfo error:(NSError *_Nullable)error __attribute__((deprecated("JPush 3.6.0 版本已过期"))); + +/** + 离开地理围栏区域 + + @param geofenceId 地理围栏id + @param userInfo 地理围栏触发时返回的信息 + @param error 错误信息 + */ +- (void)jpushGeofenceIdentifer:(NSString *)geofenceId didExitRegion:(NSDictionary *_Nullable)userInfo error:(NSError *_Nullable)error __attribute__((deprecated("JPush 3.6.0 版本已过期"))); + +@end + + +@protocol JPUSHNotiInMessageDelegate + +/** + 应用内提醒消息展示的回调 + + @param content 应用内提醒消息的内容 + + */ +- (void)jPushNotiInMessageDidShowWithContent:(NSDictionary *)content; + +/** + 应用内提醒消息点击的回调 + + @param content 应用内提醒消息的内容 + + */ +- (void)jPushNotiInMessageDidClickWithContent:(NSDictionary *)content; + +@end + + +@protocol JPUSHInAppMessageDelegate + +/** + 应用内消息展示的回调 + + @param inAppMessage 应用内消息的内容 + + */ +- (void)jPushInAppMessageDidShow:(JPushInAppMessage *)inAppMessage; + +/** + 应用内消息点击的回调 + + @param inAppMessage 应用内消息的内容 + + */ +- (void)jPushInAppMessageDidClick:(JPushInAppMessage *)inAppMessage; + +@end + +NS_ASSUME_NONNULL_END diff --git a/src/uni_modules/jg-jpush-u/utssdk/app-ios/Libs/JPush/libJPush.a b/src/uni_modules/jg-jpush-u/utssdk/app-ios/Libs/JPush/libJPush.a new file mode 100644 index 00000000..33a53935 Binary files /dev/null and b/src/uni_modules/jg-jpush-u/utssdk/app-ios/Libs/JPush/libJPush.a differ diff --git a/src/uni_modules/jg-jpush-u/utssdk/app-ios/PrivacyInfo.xcprivacy b/src/uni_modules/jg-jpush-u/utssdk/app-ios/PrivacyInfo.xcprivacy new file mode 100644 index 00000000..ab4651f3 --- /dev/null +++ b/src/uni_modules/jg-jpush-u/utssdk/app-ios/PrivacyInfo.xcprivacy @@ -0,0 +1,76 @@ + + + + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeCrashData + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeDeviceID + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypePreciseLocation + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + NSPrivacyTracking + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPITypeReasons + + E174.1 + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryDiskSpace + + + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + + + NSPrivacyAccessedAPITypeReasons + + C617.1 + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + + + + diff --git a/src/uni_modules/jg-jpush-u/utssdk/app-ios/UTS.entitlements b/src/uni_modules/jg-jpush-u/utssdk/app-ios/UTS.entitlements new file mode 100644 index 00000000..28c29bf6 --- /dev/null +++ b/src/uni_modules/jg-jpush-u/utssdk/app-ios/UTS.entitlements @@ -0,0 +1,8 @@ + + + + + aps-environment + production + + diff --git a/src/uni_modules/jg-jpush-u/utssdk/app-ios/config.json b/src/uni_modules/jg-jpush-u/utssdk/app-ios/config.json new file mode 100644 index 00000000..62360f52 --- /dev/null +++ b/src/uni_modules/jg-jpush-u/utssdk/app-ios/config.json @@ -0,0 +1,20 @@ +{ + "frameworks": [ + "UIKit.framework", + "CFNetwork.framework", + "CoreFoundation.framework", + "CoreTelephony.framework", + "SystemConfiguration.framework", + "CoreGraphics.framework", + "Foundation.framework", + "Security.framework", + "UserNotifications.framework", + "AdSupport.framework", + "WebKit.framework", + "AppTrackingTransparency.framework", + "StoreKit.framework", + "libz.tbd", + "libresolv.tbd" + ], + "deploymentTarget": "12.0" +} diff --git a/src/uni_modules/jg-jpush-u/utssdk/app-ios/index.uts b/src/uni_modules/jg-jpush-u/utssdk/app-ios/index.uts new file mode 100644 index 00000000..94c1e250 --- /dev/null +++ b/src/uni_modules/jg-jpush-u/utssdk/app-ios/index.uts @@ -0,0 +1,720 @@ +import { Data } from 'Foundation'; +import { Notification } from 'Foundation'; +import { NSNotification } from 'Foundation'; + +import { Dictionary, Set } from 'Swift'; +import { NSError } from 'Foundation'; +import { NSSet } from 'Foundation'; +import { UIDevice, UIApplication } from 'UIKit'; +import { ATTrackingManager, ATTrackingManagerAuthorizationStatus } from 'AppTrackingTransparency'; +import { ASIdentifierManager } from 'AdSupport'; +import { NotificationCenter } from 'Foundation'; +import { Selector, NSString } from 'Foundation'; +import { UNUserNotificationCenter } from 'UserNotifications'; + +/* 引入 interface.uts 文件中定义的变量 */ +import { EventCallBackParams, EventCallBack } from '../interface.uts'; + +const TAG = "| JIGUANG | JPUSH-uni-" + +// 统一日志方法 +function log(message : string, ...args : any[]) : void { + // 这里拼接args + let fullMessage = message + if (args != null && args.length > 0) { + for (let arg of args) { + fullMessage += " " + JSON.stringify(arg)! + } + } + // 可以根据需要选择使用console.log或iOS的log + console.log(TAG, fullMessage) + // 如果需要使用iOS的log,可以取消注释下面的代码 + // 构建完整的消息字符串 + NSLog("%@ %@ ", TAG, fullMessage) +} + +// 事件回调管理类 +class EventCallbackManager { + private callBack : EventCallBackParams | null = null + private cachedEvents : Array = [] + + setEventCallBack(param : EventCallBackParams) : void { + log("setEventCallBack") + this.callBack = param + + // 如果有缓存的事件,立即回调 + if (this.cachedEvents.length > 0) { + log("处理缓存事件,数量:", this.cachedEvents.length) + for (let event of this.cachedEvents) { + this.triggerCallBack(event) + } + this.cachedEvents = [] + } + } + + triggerCallBack(event : EventCallBack) : void { + if (this.callBack !== null) { + log("触发回调") + if (this.callBack!.callback !== null) { + this.callBack!.callback!(event) + } else { + log("未设置回调函数,事件内容:", event) + } + } else { + log("回调未设置,缓存事件") + this.cachedEvents.push(event) + } + } + + /** + * 统一处理回调的方法 + * @param eventName 事件名称 + * @param eventData 事件数据(JSON字符串) + */ + triggerEvent(eventName : string, eventData : string) : void { + const callback : EventCallBack = { + eventName: eventName, + eventData: eventData + } + this.triggerCallBack(callback) + } + + getCallBack() : EventCallBackParams | null { + return this.callBack + } + + hasCallBack() : boolean { + return this.callBack !== null && this.callBack!.callback !== null + } +} + +// 全局事件回调管理器实例 +const eventCallbackManager = new EventCallbackManager() + +export type InitPushParams = { + appkey : string, + channel : string, + isProduction : boolean, + advertisingId : string +} +/** + * iOS UTS插件实现 - 极光推送 + * 对应AppDelegate.m的功能 + */ +export class JGPushIOSPlugin implements UTSiOSHookProxy { + + // MARK: - 应用程序生命周期回调 + + /** + * UTS插件创建时的回调 + */ + onCreate() : void { + log("JGPushIOSPlugin onCreate") + } + + /** + * 应用正常启动时的回调 + * 极光推送初始化 + */ + applicationDidFinishLaunchingWithOptions(application : UIApplication | null, launchOptions : Map | null = null) : boolean { + log("JGPushIOSPlugin applicationDidFinishLaunchingWithOptions") + // 保存启动选项供后续使用 + JGPushTool.launchOptions = launchOptions + + // 注册方法在DidFinishLaunching方法写一遍,否则点击通知冷启动app不会走系统的通知点击回调反复。 + const entity = JPUSHRegisterEntity() + entity.types = 0 + JPUSHService.register(forRemoteNotificationConfig = entity, delegate = JGPushTool) + + return true + } + + /** + * 远程通知注册成功时的回调 + * 极光推送:将deviceToken发送给服务端 + */ + didRegisterForRemoteNotifications(deviceToken : Data | null) { + log("JGPushIOSPlugin didRegisterForRemoteNotifications") + JGPushTool.registerDeviceToken(deviceToken) + } + + /** + * 远程通知注册失败时的回调 + */ + didFailToRegisterForRemoteNotifications(error : NSError | null) { + log("JGPushIOSPlugin didFailToRegisterForRemoteNotifications", error) + } + + /** + * 收到远程通知时的回调 + */ + didReceiveRemoteNotification(userInfo : Map | null) { + log("JGPushIOSPlugin didReceiveRemoteNotification", userInfo) + } +} + +/** + * 极光推送模块实现 + */ +class JGPushModule implements JPUSHRegisterDelegate { + + launchOptions : Map | null = null + + /** + * 初始化极光推送 + */ + initPush(param : InitPushParams) { + log("JGPushModule initPush", param) + + const method = Selector("didReceiveCustomMessage:") + NotificationCenter.default.addObserver(this, selector = method, name = NSNotification.Name.jpfNetworkDidReceiveMessage, object = null) + // NotificationCenter.default.addObserver(this, selector=method, name=NSNotification.Name("kJPUSHNetworkDidReceiveMessageNotification"), object=null) + + const method1 = Selector("networkDidLogin:") + NotificationCenter.default.addObserver(this, selector = method1, name = NSNotification.Name.jpfNetworkDidLogin, object = null) + // NotificationCenter.default.addObserver(this, selector=method1, name=NSNotification.Name("kJPFNetworkDidLoginNotification"), object=null) + + const method2 = Selector("networkDidClose:") + NotificationCenter.default.addObserver(this, selector = method2, name = NSNotification.Name.jpfNetworkDidClose, object = null) + + // 初始化极光推送服务 + JPUSHService.setup(withOption = this.launchOptions, appKey = param.appkey, channel = param.channel, apsForProduction = param.isProduction, advertisingIdentifier = param.advertisingId) + + // 创建推送注册实体 + const entity = JPUSHRegisterEntity() + const types = new JPAuthorizationOptions(rawValue = (JPAuthorizationOptions.alert.rawValue | + JPAuthorizationOptions.sound.rawValue | + JPAuthorizationOptions.badge.rawValue)) + entity.types = Int(types.rawValue) + // 注册远程通知配置 + JPUSHService.register(forRemoteNotificationConfig = entity, delegate = this) + + log("极光推送SDK初始化完成") + } + + /** + * 注册设备令牌 + */ + registerDeviceToken(token : Data | null) { + log("JGPushModule registerDeviceToken", token) + JPUSHService.registerDeviceToken(token!) + } + + /** + * 收到自定义消息回调 + */ + @objc didReceiveCustomMessage(notification : Notification) { + log("JGPushModule didReceiveCustomMessage", notification) + const userInfo = notification.userInfo + + if (userInfo !== null) { + const jsonString = JSON.stringify(userInfo! as Dictionary) + if (jsonString !== null) { + eventCallbackManager.triggerEvent("onCustomMessage", jsonString!) + } + } + } + + /** + * 网络连接成功回调 + */ + @objc networkDidLogin(notification : Notification) { + log("JGPushModule networkDidLogin", notification) + + eventCallbackManager.triggerEvent("onConnected", "true") + } + + /** + * 网络连接关闭回调 + */ + @objc networkDidClose(notification : Notification) { + log("JGPushModule networkDidClose", notification) + + eventCallbackManager.triggerEvent("onConnected", "false") + } + + /** + * iOS 10 Support - 应用在前台时收到通知 + */ + jpushNotificationCenter(center : UNUserNotificationCenter, @argumentLabel("willPresent") notification : UNNotification, @argumentLabel("withCompletionHandler") completionHandler : (code : Int) => void) : void { + log("JGPushModule jpushNotificationCenter willPresent") + // Required + const userInfo = notification.request.content.userInfo + if ((notification.request.trigger?.isKind(of = UNPushNotificationTrigger.self)) != null) { + JPUSHService.handleRemoteNotification(userInfo) + } + + const u = userInfo as Dictionary; + const jsonString = JSON.stringify(u) + eventCallbackManager.triggerEvent("onNotifyMessageArrived", jsonString!) + + completionHandler(Int(UNNotificationPresentationOptions.alert.rawValue) | Int(UNNotificationPresentationOptions.sound.rawValue) | Int(UNNotificationPresentationOptions.badge.rawValue)) + } + + /** + * iOS 10 Support - 用户点击通知 + */ + jpushNotificationCenter(center : UNUserNotificationCenter, @argumentLabel("didReceive") response : UNNotificationResponse, @argumentLabel("withCompletionHandler") completionHandler : () => void) : void { + log("JGPushModule jpushNotificationCenter didReceive") + // Required + const userInfo = response.notification.request.content.userInfo + if ((response.notification.request.trigger?.isKind(of = UNPushNotificationTrigger.self)) != null) { + JPUSHService.handleRemoteNotification(userInfo) + + } + + const u = userInfo as Dictionary; + const jsonString = JSON.stringify(u) + eventCallbackManager.triggerEvent("onClickMessage", jsonString!) + completionHandler() // 系统要求执行这个方法 + } + + /** + * iOS 12 Support - 用户从通知设置界面进入应用 + */ + jpushNotificationCenter(center : UNUserNotificationCenter, @argumentLabel("openSettingsFor") notification : UNNotification) : void { + log("JGPushModule jpushNotificationCenter openSettingsFor") + if (notification !== null && (notification.request.trigger?.isKind(of = UNPushNotificationTrigger.self)) != null) { + // 从通知界面直接进入应用 + log("从通知界面直接进入应用") + } else { + // 从通知设置界面进入应用 + log("从通知设置界面进入应用") + } + } + + /** + * 通知权限授权回调 + */ + jpushNotificationAuthorization(status : JPAuthorizationStatus, @argumentLabel("withInfo") info : Map | null) : void { + log("JGPushModule jpushNotificationAuthorization", status) + // 处理通知权限状态变化 + } + +} + +// 全局极光推送工具实例 +const JGPushTool : JGPushModule = new JGPushModule() + +// MARK: - 导出函数 + + +/** + * 添加事件回调 + */ +@UTSJS.keepAlive +export function setEventCallBack(param : EventCallBackParams) : void { + log("setEventCallBack", param) + eventCallbackManager.setEventCallBack(param) +} + +// MARK: - 极光推送API方法 + +export function initPush(param : InitPushParams) : void { + log("initPush", param); + JGPushTool.initPush(param); +} + +/** + * 设置调试模式 + */ +export function setDebug(debug : boolean) : void { + log("setDebug", debug) + if (debug) { + JPUSHService.setDebugMode() + } else { + JPUSHService.setLogOFF() + } +} + +/** + * 设置进入后台是否允许长连接 + */ +export function setBackgroundEnable(isEnable : boolean) : void { + log("setBackgroundEnable:", isEnable) + if (isEnable) { + JPUSHService.setBackgroundEnable(true) + } else { + JPUSHService.setBackgroundEnable(false) + } +} + +/** + * 获取推送是否被停止的状态(iOS 6.0.0+) + * @param callback (code, isStopped) code 为 0 表示成功,isStopped 为 true 表示推送已停止。code 为0 时,isStopped的值才有效。 + */ +export function getPushStatus(callback : (code : number, isStopped : boolean) => void) : void { + log("getPushStatus") + const completion = (iResCode : Int, isStopped : Boolean) => { + callback(iResCode as number, isStopped as boolean) + } + JPUSHService.getPushStatus(completion) +} + +/** + * 获取RegistrationID + */ +export function getRegistrationId() : string { + log("getRegistrationId") + return JPUSHService.registrationID() +} + +/** + * 设置标签 + */ +export function setTags(sequence : Int, tags : string[]) : void { + log("setTags", sequence, tags) + // 单独定义回调函数,并明确指定其类型 + const callback = (iResCode : Int, iTags : Swift.Set | null, seq : Int) => { + const result = { + code: iResCode, + tags: iTags, + sequence: seq + } + log("setTags completion", result) + eventCallbackManager.triggerEvent("onTagOperatorResult", JSON.stringify(result)!) + } + const setTags:Swift.Set = new Swift.Set(tags) + JPUSHService.setTags(setTags, completion = callback, seq = sequence) +} + + +/** + * 添加标签 + */ +export function addTags(sequence : Int, tags : string[]) : void { + log("addTags", sequence, tags) + + const callback = (iResCode : Int, iTags : Swift.Set | null, seq : Int) => { + const result = { + code: iResCode, + tags: iTags, + sequence: seq + } + log("addTags completion", result) + eventCallbackManager.triggerEvent("onTagOperatorResult", JSON.stringify(result)!) + } + + + const setTags:Swift.Set = new Swift.Set(tags) + + JPUSHService.addTags(setTags, completion = callback, seq = sequence) +} + +/** + * 删除标签 + */ +export function deleteTags(sequence : Int, tags : string[]) : void { + log("deleteTags", sequence, tags) + + const callback = (iResCode : Int, iTags : Swift.Set | null, seq : Int) => { + const result = { + code: iResCode, + tags: iTags, + sequence: seq + } + log("deleteTags completion", result) + eventCallbackManager.triggerEvent("onTagOperatorResult", JSON.stringify(result)!) + } + + const setTags:Swift.Set = new Swift.Set(tags) + JPUSHService.deleteTags(setTags, completion = callback, seq = sequence) +} + +/** + * 清除所有标签 + */ +export function cleanTags(sequence : Int) : void { + log("cleanTags", sequence) + + const callback = (iResCode : Int, iTags : Swift.Set | null, seq : Int) => { + const result = { + code: iResCode, + tags: iTags, + sequence: seq + } + log("cleanTags completion", result) + eventCallbackManager.triggerEvent("onTagOperatorResult", JSON.stringify(result)!) + } + + JPUSHService.cleanTags(callback, seq = sequence) +} + +/** + * 获取所有标签 + */ +export function getAllTags(sequence : Int) : void { + log("getAllTags", sequence) + + const callback = (iResCode : Int, iTags : Swift.Set | null, seq : Int) => { + const result = { + code: iResCode, + tags: iTags, + sequence: seq + } + log("getAllTags completion", result) + eventCallbackManager.triggerEvent("onTagOperatorResult", JSON.stringify(result)!) + } + + JPUSHService.getAllTags(callback, seq = sequence) +} + +/** + * 验证标签绑定状态 + */ +export function checkTagBindState(sequence : Int, tag : string) : void { + log("checkTagBindState", sequence, tag) + + const callback = + (iResCode : Int, iTags : Swift.Set | null, seq : Int, isBind : boolean) => { + const result = { + code: iResCode, + tags: iTags, + sequence: seq, + isBind: isBind + } + log("validTag completion", result) + eventCallbackManager.triggerEvent("onTagOperatorResult", JSON.stringify(result)!) + } + // JPUSHService.validTag(tag, completion = callback, seq = sequence) + JPUSHService.validTag(tag, completion = callback, seq = sequence) +} + + +/** + * 设置别名 + */ +export function setAlias(sequence : Int, alias : string) : void { + log("setAlias", sequence, alias) + + + const callback : (iResCode : Int, iAlias : String | null, seq : Int) => void = (iResCode : Int, iAlias : String | null, seq : Int) => { + const result = { + code: iResCode, + alias: iAlias, + sequence: seq + } + log("setAlias completion", result) + eventCallbackManager.triggerEvent("onAliasOperatorResult", JSON.stringify(result)!) + } + + JPUSHService.setAlias(alias, completion = callback, seq = sequence) +} + + +/** + * 删除别名 + */ +export function deleteAlias(sequence : Int) : void { + log("deleteAlias", sequence) + + const callback : (iResCode : Int, iAlias : String | null, seq : Int) => void = (iResCode : Int, iAlias : String | null, seq : Int) => { + const result = { + code: iResCode, + alias: iAlias, + sequence: seq + } + log("deleteAlias completion", result) + eventCallbackManager.triggerEvent("onAliasOperatorResult", JSON.stringify(result)!) + } + + JPUSHService.deleteAlias(callback, seq = sequence) +} + +/** + * 获取别名 + */ +export function getAlias(sequence : Int) : void { + log("getAlias", sequence) + + const callback : (iResCode : Int, iAlias : String | null, seq : Int) => void = (iResCode : Int, iAlias : String | null, seq : Int) => { + const result = { + code: iResCode, + alias: iAlias, + sequence: seq + } + log("getAlias completion", result) + eventCallbackManager.triggerEvent("onAliasOperatorResult", JSON.stringify(result)!) + } + + JPUSHService.getAlias(callback, seq = sequence) +} + +/** + * 设置手机号码 + */ +export function setMobileNumber(sequence : number, mobileNumber : string) : void { + log("setMobileNumber", mobileNumber) + + const callback : (error : NSError | null) => void = (error : NSError | null) => { + if (error !== null) { + log("setMobileNumber error", error); + } else { + log("setMobileNumber success"); + } + + const result = { + code: error === null ? 0 : (error as NSError).code, + message: error === null ? "success" : error, + mobileNumber:mobileNumber, + sequence:sequence + } + log("setMobileNumber completion", result) + eventCallbackManager.triggerEvent("onMobileNumberOperatorResult", JSON.stringify(result)!) + } + + JPUSHService.setMobileNumber(mobileNumber, completion = callback); +} + +/** + * 设置角标数量 + */ +export function setBadgeNumber(curNum : Int) : void { + log("setBadgeNumber", curNum) + // 调用极光推送接口设置角标 + JPUSHService.setBadge(curNum) + // 同时调用系统接口设置角标 + if (UTSiOS.available("iOS 16.0, *")) { + // iOS 16 及以上使用 UNUserNotificationCenter + UNUserNotificationCenter.current().setBadgeCount(Int(curNum), withCompletionHandler = (error : NSError | null) => { + if (error != null) { + log("setBadgeCount error:", error) + } + }) + } else { + // iOS 16 以下使用 UIApplication + UIApplication.shared.applicationIconBadgeNumber = Int(curNum) + } +} + +/** + * 重置角标 + */ +export function resetBadge() : void { + log("resetBadge") + JPUSHService.resetBadge() + // 同时调用系统接口重置角标 + if (UTSiOS.available("iOS 16.0, *")) { + // iOS 16 及以上使用 UNUserNotificationCenter + UNUserNotificationCenter.current().setBadgeCount(0, withCompletionHandler = (error : NSError | null) => { + if (error != null) { + log("resetBadge setBadgeCount error:", error) + } + }) + } else { + // iOS 16 以下使用 UIApplication + UIApplication.shared.applicationIconBadgeNumber = 0 + } +} + +/** + * 获取应用角标数量 + */ +export function getBadgeNumber() : number { + const badgeNumber = UIApplication.shared.applicationIconBadgeNumber + log("getBadgeNumber", badgeNumber) + return Number(badgeNumber) +} + +/** 退出业务账号时同步清除系统已展示通知和角标,避免下个账号看到旧消息。 */ +export function clearNotificationAll() : void { + UNUserNotificationCenter.current().removeAllDeliveredNotifications() + resetBadge() +} + + + +import { DispatchQueue } from 'Dispatch' +import { PushServiceResult, LiveActivityResult, LiveActivityPushTokenParams, LiveActivityPushToStartTokenParams } from '../interface.uts' +type IOSPushReply = { generation: number, resolve: (value: PushServiceResult) => void, reject: (error: Error) => void } +type IOSTokenReply = { generation: number, resolve: (value: LiveActivityResult) => void, reject: (error: Error) => void } +const iosPushReplies = new Map() +const iosTokenReplies = new Map() +let iosRequestSequence: number = 0 +export function clearUpgradeCallbacks(): void { + JPushUpgradeCodec.invalidate() + DispatchQueue.main.async(execute = () => { + const current = Number.from(JPushUpgradeCodec.generation()) + const expiredPush = new Array() + const expiredToken = new Array() + iosPushReplies.forEach((reply, id) => { if (reply.generation != current) expiredPush.push(id) }) + iosTokenReplies.forEach((reply, id) => { if (reply.generation != current) expiredToken.push(id) }) + for (const id of expiredPush) { iosPushReplies.delete(id) } + for (const id of expiredToken) { iosTokenReplies.delete(id) } + }) +} +function finishIOSPush(id: number, result: PushServiceResult): void { + DispatchQueue.main.async(execute = () => { + const pending = iosPushReplies.get(id) + if (pending == null) return + iosPushReplies.delete(id) + if (pending.generation == Number.from(JPushUpgradeCodec.generation())) pending.resolve(result) + }) +} +export function turnOffPush(): Promise { + const generation = Number.from(JPushUpgradeCodec.generation()) + return new Promise((resolve,reject) => { + DispatchQueue.main.async(execute = () => { + if (generation != Number.from(JPushUpgradeCodec.generation())) { reject(new Error('BRIDGE_INVALIDATED')); return } + iosRequestSequence += 1 + const id = iosRequestSequence + iosPushReplies.set(id, {generation:generation,resolve:resolve,reject:reject}) + JPUSHService.turnOffPush((code: Int) => { finishIOSPush(id, {code:Number.from(code)}) }) + }) + }) +} +export function turnOnPush(): Promise { + const generation = Number.from(JPushUpgradeCodec.generation()) + return new Promise((resolve,reject) => { + DispatchQueue.main.async(execute = () => { if (generation != Number.from(JPushUpgradeCodec.generation())) { reject(new Error('BRIDGE_INVALIDATED')); return }; JPUSHService.turnOnPush(); resolve({dispatched:true}) }) + }) +} +export function setLiveActivityTokenReportEnabled(enable: boolean): Promise { + const generation = Number.from(JPushUpgradeCodec.generation()) + return new Promise((resolve,reject) => { + DispatchQueue.main.async(execute = () => { if (generation != Number.from(JPushUpgradeCodec.generation())) { reject(new Error('BRIDGE_INVALIDATED')); return }; JPUSHService.setLiveActivityTokenReportEnabled(enable); resolve() }) + }) +} +function registerLiveToken(identifier: string, token: string | null, sequence: number, start: boolean): Promise { + if (!JPushUpgradeCodec.validIdentifier(identifier) || !JPushUpgradeCodec.validToken(token) || !JPushUpgradeCodec.validSequence(sequence)) { + return new Promise((resolve,reject) => { reject(new Error('INVALID_ARGUMENT')) }) + } + if (start) { + if (UTSiOS.available("iOS 17.2, *")) { } else { return new Promise((resolve,reject) => { reject(new Error('UNSUPPORTED_OS')) }) } + } else { + if (UTSiOS.available("iOS 16.1, *")) { } else { return new Promise((resolve,reject) => { reject(new Error('UNSUPPORTED_OS')) }) } + } + const generation = Number.from(JPushUpgradeCodec.generation()) + return new Promise((resolve,reject) => { + DispatchQueue.main.async(execute = () => { + if (generation != Number.from(JPushUpgradeCodec.generation())) { reject(new Error('BRIDGE_INVALIDATED')); return } + iosRequestSequence += 1 + const id = iosRequestSequence + iosTokenReplies.set(id, {generation:generation,resolve:resolve,reject:reject}) + const completion = (code: Int, activityId: string | null, resultToken: Data | null, seq: Int) => { + DispatchQueue.main.async(execute = () => { + const pending = iosTokenReplies.get(id) + if (pending == null) return + iosTokenReplies.delete(id) + if (pending.generation != Number.from(JPushUpgradeCodec.generation())) return + pending.resolve({code:Number.from(code), activityId:activityId, token:JPushUpgradeCodec.encode(resultToken), sequence:Number.from(seq)}) + }) + } + if (start) JPUSHService.registerLiveActivity(identifier, pushToStartToken=JPushUpgradeCodec.decode(token), completion=completion, seq=Int(sequence)) + else JPUSHService.registerLiveActivity(identifier, pushToken=JPushUpgradeCodec.decode(token), completion=completion, seq=Int(sequence)) + }) + }) +} +export function registerLiveActivityPushToken(params: LiveActivityPushTokenParams): Promise { + return registerLiveToken(params.activityId,params.token,params.sequence,false) +} +export function registerLiveActivityPushToStartToken(params: LiveActivityPushToStartTokenParams): Promise { + return registerLiveToken(params.activityAttributes,params.token,params.sequence,true) +} +export function getRegistrationIdAsync(): Promise { return Promise.resolve(getRegistrationId()) } +export function isPushStoppedAsync(): Promise { return new Promise((resolve,reject) => { reject(new Error('UNSUPPORTED_PLATFORM')) }) } +export function requestSubscribeChannels(channels: string[]): Promise { return new Promise((resolve,reject) => { reject(new Error('UNSUPPORTED_PLATFORM')) }) } +export function initCrashHandler(): Promise { return new Promise((resolve,reject) => { reject(new Error('UNSUPPORTED_PLATFORM')) }) } +export function stopCrashHandler(): Promise { return new Promise((resolve,reject) => { reject(new Error('UNSUPPORTED_PLATFORM')) }) } + +export function initAsync(): Promise { return new Promise((resolve,reject) => { reject(new Error('UNSUPPORTED_PLATFORM')) }) } diff --git a/src/uni_modules/jg-jpush-u/utssdk/interface.uts b/src/uni_modules/jg-jpush-u/utssdk/interface.uts new file mode 100644 index 00000000..65450665 --- /dev/null +++ b/src/uni_modules/jg-jpush-u/utssdk/interface.uts @@ -0,0 +1,45 @@ +/** + * interface.uts + * uts插件接口定义文件,按规范定义接口可以在HBuilderX中更好的做到语法提示 + */ + +export type EventCallBack = { + eventName: string, + eventData: string +} + +export type EventCallBackParams = { + callback?: (res: EventCallBack) => void +} + +// /** +// * 错误码 +// * 根据uni错误码规范要求,建议错误码以90开头,以下是错误码示例: +// * - 9010001 错误信息1 +// * - 9010002 错误信息2 +// */ +// export type MyApiErrorCode = 9010001 | 9010002; +// /** +// * myApi 的错误回调参数 +// */ +// export interface MyApiFail extends IUniError { +// errCode : MyApiErrorCode +// }; + +// /* 异步函数定义 */ +// export type MyApi = (options : MyApiOptions) => void + +// /* 同步函数定义 */ +// export type MyApiSync = (paramA : boolean) => MyApiResult + +export type PushServiceResult = { code?: number, msg?: string, dispatched?: boolean } +export type LiveActivityPushTokenParams = { activityId: string, token: string | null, sequence: number } +export type LiveActivityPushToStartTokenParams = { activityAttributes: string, token: string | null, sequence: number } +export type LiveActivityResult = { code: number, activityId: string | null, token: string | null, sequence: number } +export type RequestSubscribeChannels = (channels: string[]) => Promise +export type TurnOffPush = () => Promise +export type TurnOnPush = () => Promise +export type CrashHandlerControl = () => Promise +export type LiveActivityTokenReportControl = (enable: boolean) => Promise +export type RegisterLiveActivityPushToken = (params: LiveActivityPushTokenParams) => Promise +export type RegisterLiveActivityPushToStartToken = (params: LiveActivityPushToStartTokenParams) => Promise diff --git a/src/uni_modules/jg-jpush-u/utssdk/unierror.uts b/src/uni_modules/jg-jpush-u/utssdk/unierror.uts new file mode 100644 index 00000000..5dd156cc --- /dev/null +++ b/src/uni_modules/jg-jpush-u/utssdk/unierror.uts @@ -0,0 +1,39 @@ +// /* 此规范为 uni 规范,可以按照自己的需要选择是否实现 */ +// import { MyApiErrorCode, MyApiFail } from "./interface.uts" +// /** +// * 错误主题 +// * 注意:错误主题一般为插件名称,每个组件不同,需要使用时请更改。 +// * [可选实现] +// */ +// export const UniErrorSubject = 'uts-api'; + + +// /** +// * 错误信息 +// * @UniError +// * [可选实现] +// */ +// export const MyAPIErrors : Map = new Map([ +// /** +// * 错误码及对应的错误信息 +// */ +// [9010001, 'custom error mseeage1'], +// [9010002, 'custom error mseeage2'], +// ]); + + +// /** +// * 错误对象实现 +// */ +// export class MyApiFailImpl extends UniError implements MyApiFail { + +// /** +// * 错误对象构造函数 +// */ +// constructor(errCode : MyApiErrorCode) { +// super(); +// this.errSubject = UniErrorSubject; +// this.errCode = errCode; +// this.errMsg = MyAPIErrors.get(errCode) ?? ""; +// } +// } diff --git a/vite.config.ts b/vite.config.ts index d8e92672..ecc25d01 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -22,6 +22,7 @@ export default async (command: 'build' | 'serve', mode: string) => { }); const isH5 = process.env.UNI_PLATFORM === 'h5'; + const isApp = process.env.UNI_PLATFORM === 'app' || process.env.UNI_PLATFORM === 'app-plus'; if (isH5) { return { @@ -92,6 +93,8 @@ export default async (command: 'build' | 'serve', mode: string) => { Unocss(), viteCompression({ verbose: false, + // Android 将 .gz 与原资源按同一路径合并,App 打包直接保留原始资源。 + disable: isApp, }), uniReadPagesV3Plugin({ pagesJsonDir: path.resolve(__dirname, './src/pages.json'),