├── .gitignore
├── LICENSE
├── README.md
├── build.gradle
└── src
├── androidTest
└── java
│ └── com
│ └── oasisfeng
│ └── nevo
│ └── decorators
│ └── wechat
│ └── EmojiTranslatorTest.kt
└── main
├── AndroidManifest.xml
├── assets
├── agent.apk
└── dummy-auto.apk
├── java
└── com
│ └── oasisfeng
│ └── nevo
│ └── decorators
│ └── wechat
│ ├── AgentShortcuts.kt
│ ├── AssetFileProvider.kt
│ ├── CompatModeController.kt
│ ├── ConversationManager.kt
│ ├── EmojiMap.java
│ ├── EmojiTranslator.kt
│ ├── IconHelper.kt
│ ├── MessagingBuilder.kt
│ ├── SmartReply.kt
│ ├── WeChatDecorator.kt
│ ├── WeChatDecoratorSettingsActivity.kt
│ ├── WeChatDecoratorSettingsReceiver.kt
│ └── WeChatMessage.kt
└── res
├── values-night
└── themes.xml
├── values
├── strings.xml
├── themes.xml
└── values.xml
└── xml
└── decorators_wechat_settings.xml
/.gitignore:
--------------------------------------------------------------------------------
1 | # Local configuration file (sdk path, etc)
2 | local.properties
3 | /src/debug
4 | /src/release
5 |
6 | # Android Studio
7 | .idea
8 | *.iml
9 | /gradle
10 | gradlew
11 | gradlew.bat
12 | .gradle
13 | build
14 |
15 | # OSX files
16 | .DS_Store
17 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright [yyyy] [name of copyright owner]
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # WeChat Modernized (Nevolution Decorator)
2 |
3 | Introduction
4 | ------------
5 |
6 | Enhance notification experience of WeChat with following features:
7 |
8 | * Use "Messaging" notification template optimized for instant messaging. (Android 7+)
9 | * Notification Channel (Android 8+): Messages, Group Conversations and Misc.
10 | * "Direct Reply" (Android 7+, Android Auto app required to be installed)
11 | * Swipe to Mark Read (Android Auto app required to be installed)
12 | * Non-group messages placed above group messages in notification group.
13 | * Colorized notification icon.
14 |
15 |
16 | Extension Pack
17 | --------------
18 | [APK download](https://github.com/Nevolution/decorator-wechat/releases/tag/ext)
19 |
20 | No need to install the extension pack if you have Android Auto installed.
21 |
22 |
23 | Contribution
24 | ------------
25 |
26 | This decorator focuses on modernized experience generally suitable for vast majority of WeChat users.
27 | For customized experience aimed for personalized or minor users, please develop a separate decorator, which could work together with this one in user's device.
28 |
29 | Pull-request and issue report are welcome.
30 |
--------------------------------------------------------------------------------
/build.gradle:
--------------------------------------------------------------------------------
1 | buildscript {
2 | repositories {
3 | google()
4 | mavenCentral()
5 | }
6 |
7 | dependencies {
8 | classpath 'com.android.tools.build:gradle:7.1.1'
9 |
10 | // NOTE: Do not place your application dependencies here; they belong
11 | // in the individual module build.gradle files
12 | }
13 | }
14 |
15 | repositories {
16 | google()
17 | mavenCentral()
18 | }
19 |
20 | if (! project.plugins.hasPlugin("com.android.library")) apply plugin: 'com.android.application'
21 | apply plugin: 'kotlin-android'
22 |
23 | android {
24 | compileSdkVersion 31
25 |
26 | defaultConfig {
27 | minSdkVersion 26
28 | targetSdkVersion 30
29 | resConfigs "en", "zh"
30 |
31 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
32 | }
33 |
34 | aaptOptions {
35 | noCompress "apk"
36 | }
37 |
38 | kotlinOptions.jvmTarget = "1.8"
39 |
40 | compileOptions {
41 | sourceCompatibility JavaVersion.VERSION_1_8
42 | targetCompatibility JavaVersion.VERSION_1_8
43 | }
44 | }
45 |
46 | dependencies {
47 | implementation project(':sdk') //implementation 'com.oasisfeng.nevo:sdk:2.0.0-rc01'
48 | implementation 'androidx.core:core-ktx:1.7.0'
49 | implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7'
50 | androidTestImplementation 'junit:junit:4.13.2'
51 | }
52 |
--------------------------------------------------------------------------------
/src/androidTest/java/com/oasisfeng/nevo/decorators/wechat/EmojiTranslatorTest.kt:
--------------------------------------------------------------------------------
1 | package com.oasisfeng.nevo.decorators.wechat
2 |
3 | import com.oasisfeng.nevo.decorators.wechat.EmojiTranslator.translate
4 | import org.junit.Assert
5 | import org.junit.Test
6 |
7 | /**
8 | * Created by Oasis on 2018-8-9.
9 | */
10 | class EmojiTranslatorTest {
11 |
12 | @Test fun testConvert() {
13 | test("[Smile]", "😃")
14 | test("Left[Smile]", "Left😃")
15 | test("[Smile] Right", "😃 Right")
16 | test("Left[Smile] Right", "Left😃 Right")
17 | test("Left [色][色][发呆]Right", "Left 😍😍😳Right")
18 | test("Left[[Smile]", "Left[😃")
19 | test("Left[Smile]]", "Left😃]")
20 | test("Left[[Smile]]", "Left[😃]")
21 | test("Left[NotEmoji][][[Smile][", "Left[NotEmoji][][😃[")
22 | }
23 |
24 | companion object {
25 | private fun test(input: String, expected: String) = Assert.assertEquals(expected, translate(input).toString())
26 | }
27 | }
--------------------------------------------------------------------------------
/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
17 |
18 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
50 |
51 |
57 |
58 |
63 |
64 |
65 |
66 |
--------------------------------------------------------------------------------
/src/main/assets/agent.apk:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Nevolution/decorator-wechat/14c807aa87eba2afef3697cd95ae7b89476e1bf2/src/main/assets/agent.apk
--------------------------------------------------------------------------------
/src/main/assets/dummy-auto.apk:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Nevolution/decorator-wechat/14c807aa87eba2afef3697cd95ae7b89476e1bf2/src/main/assets/dummy-auto.apk
--------------------------------------------------------------------------------
/src/main/java/com/oasisfeng/nevo/decorators/wechat/AgentShortcuts.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2015 The Nevolution Project
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.oasisfeng.nevo.decorators.wechat
18 |
19 | import android.content.ComponentName
20 | import android.content.Context
21 | import android.content.Intent
22 | import android.content.LocusId
23 | import android.content.pm.LauncherApps
24 | import android.content.pm.PackageManager
25 | import android.content.pm.PackageManager.GET_ACTIVITIES
26 | import android.content.pm.ShortcutInfo
27 | import android.content.pm.ShortcutManager
28 | import android.graphics.drawable.Icon.TYPE_RESOURCE
29 | import android.os.Build.VERSION.SDK_INT
30 | import android.os.Build.VERSION_CODES.Q
31 | import android.os.Process
32 | import android.os.UserHandle
33 | import android.os.UserManager
34 | import android.util.ArrayMap
35 | import android.util.Log
36 | import android.util.LruCache
37 | import androidx.core.content.getSystemService
38 | import com.oasisfeng.nevo.decorators.wechat.ConversationManager.Conversation
39 | import com.oasisfeng.nevo.decorators.wechat.IconHelper.toLocalAdaptiveIcon
40 | import java.lang.reflect.Method
41 |
42 | class AgentShortcuts(private val context: Context) {
43 |
44 | companion object {
45 | private const val FLAG_ALLOW_EMBEDDED = -0x80000000
46 | fun buildShortcutId(key: String) = "C:$key"
47 | }
48 |
49 | /** @return true if shortcut is ready */
50 | private fun updateShortcut(id: String, conversation: Conversation, agentContext: Context): Boolean {
51 | if (agentContext.getSystemService(UserManager::class.java)?.isUserUnlocked == false) return false // Shortcuts cannot be changed if user is locked.
52 |
53 | val activity = agentContext.packageManager.resolveActivity(Intent(Intent.ACTION_MAIN) // Use agent context to resolve in proper user.
54 | .addCategory(Intent.CATEGORY_LAUNCHER).setPackage(AGENT_PACKAGE), 0)?.activityInfo?.name
55 | ?: return false.also { Log.d(TAG, "No shortcut update due to lack of agent launcher activity") }
56 |
57 | val sm = agentContext.getShortcutManager() ?: return false
58 | if (sm.isRateLimitingActive)
59 | return false.also { Log.w(TAG, "Due to rate limit, shortcut is not updated: $id") }
60 |
61 | val shortcuts = sm.dynamicShortcuts.apply { sortBy { it.rank }}; val count = shortcuts.size
62 | if (count >= sm.maxShortcutCountPerActivity - sm.manifestShortcuts.size)
63 | sm.removeDynamicShortcuts(listOf(shortcuts.removeAt(0).id.also { Log.i(TAG, "Evict excess shortcut: $it") }))
64 |
65 | val intent = if (conversation.id != null) Intent().setClassName(WECHAT_PACKAGE, "com.tencent.mm.ui.LauncherUI")
66 | .putExtra("Main_User", conversation.id).putExtra(@Suppress("SpellCheckingInspection") "Intro_Is_Muti_Talker", false)
67 | .addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY)
68 | else {
69 | val bubbleActivity = (mAgentBubbleActivity
70 | ?: try { context.packageManager.getPackageInfo(AGENT_PACKAGE, GET_ACTIVITIES).activities
71 | .firstOrNull { it.enabled && it.flags.and(FLAG_ALLOW_EMBEDDED) != 0 }?.name ?: "" }
72 | catch (e: PackageManager.NameNotFoundException) { "" }.also { mAgentBubbleActivity = it }) // "" to indicate N/A
73 | if (SDK_INT >= Q && bubbleActivity.isNotEmpty())
74 | Intent(Intent.ACTION_VIEW_LOCUS).putExtra(Intent.EXTRA_LOCUS_ID, id).setClassName(AGENT_PACKAGE, bubbleActivity)
75 | else Intent().setClassName(AGENT_PACKAGE, activity)
76 | }
77 |
78 | val shortcut = ShortcutInfo.Builder(agentContext, id).setActivity(ComponentName(AGENT_PACKAGE, activity))
79 | .setShortLabel(conversation.title!!).setRank(if (conversation.isGroupChat()) 1 else 0) // Always keep last direct message conversation on top.
80 | .setIntent(intent.apply { if (action == null) action = Intent.ACTION_MAIN })
81 | .setCategories(setOf(ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION)).apply {
82 | conversation.icon?.run { setIcon(toLocalAdaptiveIcon(context, sm)) }
83 | if (SDK_INT >= Q) {
84 | setLongLived(true).setLocusId(LocusId(id))
85 | if (! conversation.isGroupChat()) setPerson(conversation.sender().build().toNative())
86 | }
87 | }.build()
88 | Log.i(TAG, "Updating shortcut \"${shortcut.id}\"${if (BuildConfig.DEBUG) ": " + shortcut.intent.toString() else ""}")
89 | return if (sm.addDynamicShortcuts(listOf(shortcut))) true.also { Log.i(TAG, "Shortcut updated: $id") }
90 | else false.also { Log.e(TAG, "Unexpected rate limit.") }
91 | }
92 |
93 | private fun createAgentContext(profile: UserHandle): Context? =
94 | try {
95 | if (profile == Process.myUserHandle()) context.createPackageContext(AGENT_PACKAGE, 0)
96 | else mMethodCreatePackageContextAsUser?.invoke(context, AGENT_PACKAGE, 0, profile) as? Context
97 | } catch (e: PackageManager.NameNotFoundException) { null
98 | } catch (e: RuntimeException) { null.also { Log.e(TAG, "Error creating context for agent in user ${profile.hashCode()}", e) }}
99 |
100 | /** @return whether shortcut is ready */
101 | fun updateShortcutIfNeeded(id: String, conversation: Conversation, profile: UserHandle): Boolean {
102 | if (! conversation.isChat() || conversation.isBotMessage()) return false
103 | val agentContext = mAgentContextByProfile[profile] ?: return false
104 | if (mDynamicShortcutContacts.get(id) != null) return true
105 | try { if (updateShortcut(id, conversation, agentContext))
106 | return true.also { if (conversation.icon?.type != TYPE_RESOURCE) mDynamicShortcutContacts.put(id, Unit) }} // If no large icon, wait for the next update
107 | catch (e: RuntimeException) { Log.e(TAG, "Error publishing shortcut: $id", e) }
108 | return false
109 | }
110 |
111 | private fun Context.getShortcutManager() = getSystemService(ShortcutManager::class.java)
112 |
113 | private var mAgentBubbleActivity: String? = null
114 | private val mPackageEventReceiver = object: LauncherApps.Callback() {
115 |
116 | private fun update(pkg: String, user: UserHandle) {
117 | if (pkg == AGENT_PACKAGE) mAgentContextByProfile[user] = createAgentContext(user)
118 | }
119 |
120 | override fun onPackageRemoved(pkg: String, user: UserHandle) { update(pkg, user) }
121 | override fun onPackageAdded(pkg: String, user: UserHandle) { update(pkg, user) }
122 | override fun onPackageChanged(pkg: String, user: UserHandle) { update(pkg, user) }
123 | override fun onPackagesAvailable(pkgs: Array, user: UserHandle, replacing: Boolean) { pkgs.forEach { update(it, user) }}
124 | override fun onPackagesUnavailable(pkgs: Array, user: UserHandle, replacing: Boolean) { pkgs.forEach { update(it, user) }}
125 | }
126 |
127 | /** Local mark to reduce repeated shortcut updates */
128 | private val mDynamicShortcutContacts = LruCache(3) // Do not rely on maxShortcutCountPerActivity(), as most launcher only display top 4 shortcuts (including manifest shortcuts)
129 |
130 | private val mMethodCreatePackageContextAsUser: Method? by lazy {
131 | try { Context::class.java.getMethod("createPackageContextAsUser") } catch (e: ReflectiveOperationException) { null }}
132 | private val mAgentContextByProfile = ArrayMap()
133 |
134 | init {
135 | context.getSystemService()?.registerCallback(mPackageEventReceiver)
136 | context.getSystemService()?.userProfiles?.forEach {
137 | mAgentContextByProfile[it] = createAgentContext(it) }
138 | }
139 |
140 | fun close() {
141 | context.getSystemService()?.unregisterCallback(mPackageEventReceiver)
142 | mAgentContextByProfile.clear()
143 | }
144 | }
145 |
--------------------------------------------------------------------------------
/src/main/java/com/oasisfeng/nevo/decorators/wechat/AssetFileProvider.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2015 The Nevolution Project
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.oasisfeng.nevo.decorators.wechat
18 |
19 | import android.content.ContentProvider
20 | import android.content.ContentValues
21 | import android.content.res.AssetFileDescriptor
22 | import android.database.Cursor
23 | import android.net.Uri
24 | import android.util.Log
25 | import java.io.FileNotFoundException
26 | import java.io.IOException
27 |
28 | /**
29 | * Expose asset file with content URI
30 | *
31 | * Created by Oasis on 2019-1-28.
32 | */
33 | class AssetFileProvider : ContentProvider() {
34 |
35 | @Throws(FileNotFoundException::class) override fun openAssetFile(uri: Uri, mode: String): AssetFileDescriptor? {
36 | val filename = uri.lastPathSegment ?: throw FileNotFoundException()
37 | val context = context!!
38 | return try {
39 | val suffix = context.getString(R.string.repacked_asset_suffix)
40 | val offset = context.resources.getInteger(R.integer.repacked_asset_offset)
41 | val afd = context.assets.openFd(
42 | if (suffix.isNotEmpty() && filename.endsWith(suffix)) filename + context.getString(R.string.repacked_asset_appendix) else filename)
43 | AssetFileDescriptor(afd.parcelFileDescriptor, afd.startOffset + offset, afd.length - offset)
44 | } catch (e: IOException) {
45 | null.also { Log.e(TAG, "Error opening asset", e) }
46 | }
47 | }
48 |
49 | override fun onCreate() = true
50 |
51 | override fun getType(uri: Uri): String? = null
52 | override fun query(uri: Uri, p: Array?, s: String?, sa: Array?, so: String?): Cursor? = null
53 | override fun insert(uri: Uri, values: ContentValues?): Uri? = null
54 | override fun delete(uri: Uri, selection: String?, selectionArgs: Array?) = 0
55 | override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?) = 0
56 | }
--------------------------------------------------------------------------------
/src/main/java/com/oasisfeng/nevo/decorators/wechat/CompatModeController.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2015 The Nevolution Project
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.oasisfeng.nevo.decorators.wechat
18 |
19 | import android.app.Activity
20 | import android.content.BroadcastReceiver
21 | import android.content.Context
22 | import android.content.Intent
23 | import android.net.Uri
24 | import android.os.Handler
25 | import android.os.Looper
26 | import android.util.Log
27 |
28 | object CompatModeController {
29 |
30 | fun query(context: Context, callback: (Boolean) -> Unit) = sendRequest(context, null, callback)
31 | fun request(context: Context, enabled: Boolean, callback: (Boolean) -> Unit) = sendRequest(context, enabled, callback)
32 |
33 | private fun sendRequest(context: Context, enabled: Boolean?, callback: (Boolean) -> Unit) {
34 | val uri = Uri.fromParts("nevo", "compat", if (enabled == null) null else if (enabled) "1" else "0")
35 | context.sendOrderedBroadcast(Intent("", uri), null, object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) {
36 | when (resultCode) {
37 | Activity.RESULT_FIRST_USER -> callback(true) // Changed (for request) or enabled (for query)
38 | Activity.RESULT_OK -> callback(false) // Unchanged
39 | else -> Log.e(TAG, "Unexpected result code: $resultCode") }
40 | }}, Handler(Looper.getMainLooper()), Activity.RESULT_CANCELED, null, null)
41 | }
42 | }
--------------------------------------------------------------------------------
/src/main/java/com/oasisfeng/nevo/decorators/wechat/ConversationManager.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2015 The Nevolution Project
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.oasisfeng.nevo.decorators.wechat
18 |
19 | import android.app.Notification
20 | import android.os.UserHandle
21 | import android.text.TextUtils
22 | import android.util.ArrayMap
23 | import android.util.SparseArray
24 | import androidx.annotation.IntDef
25 | import androidx.core.app.Person
26 | import androidx.core.graphics.drawable.IconCompat
27 |
28 | /**
29 | * Manage all conversations.
30 | *
31 | * Created by Oasis on 2019-4-11.
32 | */
33 | class ConversationManager {
34 |
35 | class Conversation internal constructor(val nid: Int) { // The notification ID of conversation (hash code of "id" below)
36 |
37 | @IntDef(TYPE_UNKNOWN, TYPE_DIRECT_MESSAGE, TYPE_GROUP_CHAT, TYPE_BOT_MESSAGE)
38 | @Retention(AnnotationRetention.SOURCE) internal annotation class ConversationType
39 |
40 | @Volatile var id: String? = null // The unique ID of conversation in WeChat
41 | var count = 0
42 | var title: CharSequence? = null
43 | var summary: CharSequence? = null
44 | var ticker: CharSequence? = null
45 | var timestamp: Long = 0
46 | var icon: IconCompat? = null
47 | private var sender: Person.Builder? = null
48 | var ext: Notification.CarExtender.UnreadConversation? = null // Of the latest notification
49 |
50 | /** @return previous type */
51 | fun setType(type: Int): Int {
52 | if (type == mType) return type
53 | val previousType = mType
54 | mType = type
55 | sender = if (type == TYPE_UNKNOWN || type == TYPE_GROUP_CHAT) null
56 | else sender().setKey(id).setBot(type == TYPE_BOT_MESSAGE) // Always set key as it may change
57 | if (type != TYPE_GROUP_CHAT) mParticipants.clear()
58 | return previousType
59 | }
60 |
61 | fun sender(): Person.Builder = sender ?: object : Person.Builder() { override fun build(): Person {
62 | if (mType == TYPE_GROUP_CHAT) return SENDER_PLACEHOLDER
63 | val isBot = mType == TYPE_BOT_MESSAGE
64 | setBot(isBot)
65 | if (isBot || mType == TYPE_UNKNOWN || mType == TYPE_DIRECT_MESSAGE)
66 | setIcon(icon).setName(if (title != null) title else " ") // Cannot be empty string, or it will be treated as null.
67 | return super.build()
68 | }}
69 |
70 | fun isGroupChat() = mType == TYPE_GROUP_CHAT
71 | fun isBotMessage() = mType == TYPE_BOT_MESSAGE
72 | fun isTypeUnknown() = mType == TYPE_UNKNOWN
73 | fun isChat() = ticker != null && TextUtils.indexOf(ticker, ':') > 0
74 | fun typeToString() = mType.toString()
75 |
76 | fun getGroupParticipant(key: String, name: String): Person? {
77 | check(isGroupChat()) { "Not group chat" }
78 | var builder: Person.Builder? = null
79 | var participant = mParticipants[key]
80 | if (participant == null) builder = Person.Builder().setKey(key)
81 | else if (name != participant.uri!!.substring(SCHEME_ORIGINAL_NAME.length)) // Original name is changed
82 | builder = participant.toBuilder()
83 | if (builder != null)
84 | mParticipants[key] = builder.setUri(SCHEME_ORIGINAL_NAME + name).setName(EmojiTranslator.translate(name))
85 | .build().also { participant = it }
86 | return participant
87 | }
88 |
89 | @ConversationType private var mType = 0
90 | private val mParticipants: MutableMap = ArrayMap()
91 |
92 | companion object {
93 | const val TYPE_UNKNOWN = 0
94 | const val TYPE_DIRECT_MESSAGE = 1
95 | const val TYPE_GROUP_CHAT = 2
96 | const val TYPE_BOT_MESSAGE = 3
97 | private const val SCHEME_ORIGINAL_NAME = "ON:"
98 |
99 | fun getOriginalName(person: Person) = person.uri?.takeIf { it.startsWith(SCHEME_ORIGINAL_NAME) }
100 | ?.substring(SCHEME_ORIGINAL_NAME.length) ?: person.name
101 | }
102 | }
103 |
104 | fun getOrCreateConversation(profile: UserHandle, id: Int): Conversation {
105 | val profileId = profile.hashCode()
106 | val conversations = mConversations[profileId]
107 | ?: SparseArray().also { mConversations.put(profileId, it) }
108 | return conversations[id] ?: Conversation(id).also { conversations.put(id, it) }
109 | }
110 |
111 | fun getConversation(user: UserHandle, id: Int) = mConversations[user.hashCode()]?.get(id)
112 |
113 | private val mConversations = SparseArray>()
114 |
115 | companion object {
116 | private val SENDER_PLACEHOLDER = Person.Builder().setName(" ").build() // Cannot be empty string, or it will be treated as null.
117 | }
118 | }
--------------------------------------------------------------------------------
/src/main/java/com/oasisfeng/nevo/decorators/wechat/EmojiMap.java:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2015 The Nevolution Project
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.oasisfeng.nevo.decorators.wechat;
18 |
19 | import static android.os.Build.VERSION.SDK_INT;
20 | import static android.os.Build.VERSION_CODES.O_MR1;
21 |
22 | /**
23 | * Static map for WeChat Emoji markers
24 | *
25 | * Created by Oasis on 2018-8-9.
26 | */
27 | class EmojiMap {
28 |
29 | // Pull Request is welcome. Please describe how to verify the related emoji in the pull request.
30 | // Proper emoji is not found for lines commented out. If you have good candidate, please let us know.
31 | // Columns are split by "tab" for visual alignment
32 | static final String[][] MAP = new String[][] {
33 | { "OK", "OK", "👌" },
34 | { "耶", "Yeah!", "✌" },
35 | { "嘘", "Silent", "🤫" },
36 | { "晕", "Dizzy", "😲" },
37 | { "衰", "BadLuck", "😳" },
38 | { "色", "Drool", "😍" },
39 | { "囧", "Tension", "☺" },
40 | { "鸡", "Chick", "🐥" },
41 | { "强", "Thumbs Up", "👍" },
42 | { "弱", "Weak", "👎" },
43 | { "睡", "Sleep", "😴" },
44 | { "吐", "Puke", "🤢" },
45 | { "困", "Drowsy", "😪" },
46 | { "發", "Rich", "🀅" },
47 | { "微笑", "Smile", "😃" },
48 | { "撇嘴", "Grimace", "😖" },
49 | { "发呆", "Scowl", "😳" },
50 | { "得意", "CoolGuy", "😎" },
51 | { "流泪", "Sob", "😭" },
52 | { "害羞", "Shy", "☺" },
53 | { "闭嘴", "Shutup", "🤐" },
54 | { "大哭", "Cry", "😣" },
55 | { "尴尬", "Awkward", "😰" },
56 | { "发怒", "Angry", "😡" },
57 | { "调皮", "Tongue", "😜" },
58 | { "呲牙", "Grin", "😁" },
59 | { "惊讶", "Surprise", "😱" },
60 | { "难过", "Frown", "🙁" },
61 | { "抓狂", "Scream", "😫" },
62 | { "偷笑", "Chuckle", "😅" },
63 | { "愉快", "Joyful", "☺" },
64 | { "白眼", "Slight", "🙄" },
65 | { "傲慢", "Smug", "😕" },
66 | { "惊恐", "Panic", "😱" },
67 | { "流汗", "Sweat", "😓" },
68 | { "憨笑", "Laugh", "😄" },
69 | { "悠闲", "Loafer", "😌" },
70 | { "奋斗", "Strive", "💪" },
71 | { "咒骂", "Scold", "😤" },
72 | { "疑问", "Doubt", "❓" },
73 | { "骷髅", "Skull", "💀" },
74 | { "敲打", "Hammer", "👊" },
75 | { "捂脸", "Facepalm", "🤦" },
76 | { "奸笑", "Smirk", "😏" },
77 | { "皱眉", "Concerned", "😟" },
78 | { "红包", "Packet", SDK_INT > O_MR1 ? "🧧"/* Emoji 11+ */: "💰" },
79 | { "小狗", "Pup", "🐶" },
80 | { "再见", "Bye", "🙋" },
81 | { "擦汗", "Relief", "😥" },
82 | { "鼓掌", "Clap", "👏" },
83 | { "坏笑", "Trick", "👻" },
84 | { "哈欠", "Yawn", "😪" },
85 | { "鄙视", "Lookdown", "😒" },
86 | { "委屈", "Wronged", "😣" },
87 | { "阴险", "Sly", "😈" },
88 | { "亲亲", "Kiss", "😘" },
89 | { "菜刀", "Cleaver", "🔪" },
90 | { "西瓜", "Melon", "🍉" },
91 | { "啤酒", "Beer", "🍺" },
92 | { "咖啡", "Coffee", "☕" },
93 | { "猪头", "Pig", "🐷" },
94 | { "玫瑰", "Rose", "🌹" },
95 | { "凋谢", "Wilt", "🥀" },
96 | { "嘴唇", "Lip", "💋" },
97 | { "爱心", "Heart", "❤" },
98 | { "心碎", "BrokenHeart", "💔" },
99 | { "蛋糕", "Cake", "🎂" },
100 | { "炸弹", "Bomb", "💣" },
101 | { "便便", "Poop", "💩" },
102 | { "月亮", "Moon", "🌙" },
103 | { "太阳", "Sun", "🌞" },
104 | { "拥抱", "Hug", "🤗" },
105 | { "握手", "Shake", "🤝" },
106 | { "胜利", "Victory", "✌" },
107 | { "抱拳", "Salute", "🙏" },
108 | { "拳头", "Fist", "✊" },
109 | // { "跳跳", "Waddle", "" },
110 | // { "发抖", "Tremble", "" },
111 | { "怄火", "Aaagh!", "😡" },
112 | // { "转圈", "Twirl", "" },
113 | { "蜡烛", "Candle", "🕯️" },
114 | // { "勾引", "Beckon", ""},
115 | // { "嘿哈", "Hey", "" },
116 | // { "机智", "Smart", "" },
117 | // { "抠鼻", "DigNose", "" },
118 | // { "可怜", "Whimper", "" },
119 | { "快哭了", "Puling", "😔" },
120 | // { "左哼哼", "Bah!L", "" },
121 | // { "右哼哼", "Bah!R", "" },
122 | { "破涕为笑", "Lol", "😂" },
123 |
124 | // From WeChat for iOS
125 | { "强壮", null, "💪"},
126 | { "鬼魂", null, "👻"},
127 |
128 | // From WeChat for PC
129 | { "篮球", "Basketball", "🏀" },
130 | { "乒乓", "PingPong", "🏓" },
131 | { "饭", "Rice", "🍚" },
132 | { "瓢虫", "Ladybug", "🐞" },
133 | { "礼物", "Gift", "🎁" },
134 | // { "差劲", "Pinky", "" },
135 | { "爱你", "Love", "🤟" },
136 | { null, "NO", "🙅" },
137 | { "爱情", "InLove", "💕" },
138 | { "飞吻", "Blowkiss", "😘" },
139 | { "闪电", "Lightning", "⚡" },
140 | { "刀", null, "🔪" }, // Dup of "Cleaver"
141 | { "足球", "Soccer", "⚽" },
142 | { "棒球", "Baseball", "⚾" },
143 | { "橄榄球", "Football", "🏈" },
144 | { "钱", "Money", "💰" },
145 | { "相机", "Camera", "📷" },
146 | { "干杯", "Cheers", "🍻" },
147 | { "宝石", "Gem", "💎" },
148 | { "茶", "Tea", "🍵" },
149 | { "药丸", "Pill", "💊" },
150 | { "庆祝", "Party", "🎆" },
151 | { "火箭", "Rocket ship", "🚀" },
152 | };
153 | }
154 |
--------------------------------------------------------------------------------
/src/main/java/com/oasisfeng/nevo/decorators/wechat/EmojiTranslator.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2015 The Nevolution Project
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.oasisfeng.nevo.decorators.wechat
18 |
19 | import android.text.SpannableStringBuilder
20 | import android.text.TextUtils
21 | import android.util.Log
22 |
23 | /**
24 | * Translate Emoji markers to Emoji characters.
25 | *
26 | * Created by Oasis on 2018-8-9.
27 | */
28 | object EmojiTranslator {
29 |
30 | private val CHINESE_MAP: MutableMap = HashMap(EmojiMap.MAP.size)
31 | private val ENGLISH_MAP: MutableMap = HashMap(EmojiMap.MAP.size)
32 |
33 | init {
34 | for (entry in EmojiMap.MAP) {
35 | entry[0]?.also { CHINESE_MAP[it] = entry[2] }
36 | entry[1]?.also { ENGLISH_MAP[it] = entry[2] }
37 | }
38 | }
39 |
40 | fun translate(text: CharSequence?): CharSequence? {
41 | if (text == null) return null
42 | var bracketEnd = text.indexOf(']')
43 | if (bracketEnd == -1) return text
44 | var bracketStart = TextUtils.lastIndexOf(text, '[', bracketEnd - 2) // At least 1 char between brackets
45 | if (bracketStart == -1) return text
46 |
47 | var builder: SpannableStringBuilder? = null
48 | var offset = 0
49 | while (bracketStart >= 0 && bracketEnd >= 0) {
50 | val marker = text.subSequence(bracketStart + 1, bracketEnd).toString()
51 | val firstChar = marker[0]
52 | val emoji = (if (firstChar in 'A'..'Z') ENGLISH_MAP else CHINESE_MAP)[marker]
53 | if (emoji != null) {
54 | if (builder == null) builder = SpannableStringBuilder(text)
55 | builder.replace(bracketStart + offset, bracketEnd + 1 + offset, emoji)
56 | offset += emoji.length - marker.length - 2
57 | } else if (BuildConfig.DEBUG) Log.d(TAG, "Not translated $marker: $text")
58 | bracketEnd = TextUtils.indexOf(text, ']', bracketEnd + 3) // "]...[X..."
59 | bracketStart = TextUtils.lastIndexOf(text, '[', bracketEnd - 2)
60 | }
61 | return builder ?: text
62 | }
63 | }
--------------------------------------------------------------------------------
/src/main/java/com/oasisfeng/nevo/decorators/wechat/IconHelper.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2015 The Nevolution Project
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.oasisfeng.nevo.decorators.wechat
18 |
19 | import android.content.Context
20 | import android.content.pm.ShortcutManager
21 | import android.graphics.Bitmap
22 | import android.graphics.Canvas
23 | import android.graphics.drawable.AdaptiveIconDrawable
24 | import android.graphics.drawable.Icon
25 | import androidx.core.content.getSystemService
26 | import androidx.core.graphics.drawable.IconCompat
27 |
28 | object IconHelper {
29 |
30 | fun convertToAdaptiveIcon(context: Context, source: IconCompat): Icon =
31 | if (source.type == Icon.TYPE_RESOURCE) source.toIcon(null)
32 | else source.toLocalAdaptiveIcon(context, context.getSystemService()!!)
33 |
34 | private fun drawableToBitmap(context: Context, sm: ShortcutManager, icon: IconCompat): Bitmap {
35 | val extraInsetFraction = AdaptiveIconDrawable.getExtraInsetFraction()
36 | val width = sm.iconMaxWidth; val height = sm.iconMaxHeight
37 | val xInset = (width * extraInsetFraction).toInt(); val yInset = (height * extraInsetFraction).toInt()
38 | return Bitmap.createBitmap(width + xInset * 2, height + yInset * 2, Bitmap.Config.ARGB_8888).also { bitmap ->
39 | icon.loadDrawable(context)?.apply {
40 | setBounds(xInset, yInset, width + xInset, height + yInset)
41 | draw(Canvas(bitmap))
42 | }
43 | }
44 | }
45 |
46 | fun IconCompat.toLocalAdaptiveIcon(context: Context, sm: ShortcutManager): Icon =
47 | if (type == IconCompat.TYPE_ADAPTIVE_BITMAP) toIcon(null)
48 | else Icon.createWithAdaptiveBitmap(drawableToBitmap(context, sm, this))
49 | }
50 |
--------------------------------------------------------------------------------
/src/main/java/com/oasisfeng/nevo/decorators/wechat/MessagingBuilder.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2015 The Nevolution Project
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.oasisfeng.nevo.decorators.wechat
18 |
19 | import android.annotation.SuppressLint
20 | import android.app.Notification
21 | import android.app.NotificationManager
22 | import android.app.PendingIntent
23 | import android.app.PendingIntent.FLAG_IMMUTABLE
24 | import android.app.PendingIntent.FLAG_UPDATE_CURRENT
25 | import android.app.RemoteInput
26 | import android.content.BroadcastReceiver
27 | import android.content.Context
28 | import android.content.Intent
29 | import android.content.IntentFilter
30 | import android.net.Uri
31 | import android.os.Build.VERSION.SDK_INT
32 | import android.os.Build.VERSION_CODES.P
33 | import android.os.Bundle
34 | import android.os.Process
35 | import android.os.UserHandle
36 | import android.service.notification.StatusBarNotification
37 | import android.text.TextUtils
38 | import android.util.ArrayMap
39 | import android.util.Log
40 | import android.util.LongSparseArray
41 | import android.util.Pair
42 | import androidx.annotation.RequiresApi
43 | import androidx.core.app.NotificationCompat
44 | import androidx.core.app.Person
45 | import com.oasisfeng.nevo.decorators.wechat.ConversationManager.Conversation
46 | import com.oasisfeng.nevo.sdk.MutableStatusBarNotification
47 |
48 | /**
49 | * Build the modernized [MessagingStyle][NotificationCompat.MessagingStyle] for WeChat conversation.
50 | *
51 | * Refactored by Oasis on 2018-8-9.
52 | */
53 | internal class MessagingBuilder(private val mContext: Context, private val mController: Controller) {
54 |
55 | fun buildFromArchive(conversation: Conversation, n: Notification, title: CharSequence, archive: List): NotificationCompat.MessagingStyle? {
56 | // Chat history in big content view
57 | if (archive.isEmpty())
58 | return null.also { Log.d(TAG, "No history") }
59 | val lines = LongSparseArray>(MAX_NUM_HISTORICAL_LINES)
60 | var count = 0
61 | var numLinesWithColon = 0
62 | val redundantPrefix = title.toString() + SENDER_MESSAGE_SEPARATOR
63 | for (each in archive) {
64 | val notification = each.notification
65 | val itsExtras = notification.extras
66 | val itsTitle = EmojiTranslator.translate(itsExtras.getCharSequence(Notification.EXTRA_TITLE))
67 | if (title != itsTitle) {
68 | Log.d(TAG, "Skip other conversation with the same key in archive: $itsTitle") // ID reset by WeChat due to notification removal in previous evolving
69 | continue
70 | }
71 | val itsText = itsExtras.getCharSequence(Notification.EXTRA_TEXT)
72 | if (itsText == null) {
73 | Log.w(TAG, "No text in archived notification.")
74 | continue
75 | }
76 | val result = trimAndExtractLeadingCounter(itsText)
77 | if (result >= 0) {
78 | count = result and 0xFFFF
79 | var trimmedText = itsText.subSequence(result shr 16, itsText.length)
80 | if (trimmedText.toString().startsWith(redundantPrefix)) // Remove redundant prefix
81 | trimmedText = trimmedText.subSequence(
82 | redundantPrefix.length,
83 | trimmedText.length
84 | ) else if (trimmedText.toString().indexOf(
85 | SENDER_MESSAGE_SEPARATOR
86 | ) > 0
87 | ) numLinesWithColon++
88 | lines.put(notification.`when`, Pair(trimmedText, notification.tickerText))
89 | } else {
90 | count = 1
91 | lines.put(notification.`when`, Pair(itsText, n.tickerText))
92 | if (itsText.toString().indexOf(SENDER_MESSAGE_SEPARATOR) > 0) numLinesWithColon++
93 | }
94 | }
95 | n.number = count
96 | if (lines.size() == 0) {
97 | Log.w(TAG, "No lines extracted, expected $count")
98 | return null
99 | }
100 | val messaging = NotificationCompat.MessagingStyle(mUserSelf)
101 | val senderInline = numLinesWithColon == lines.size()
102 | var i = 0
103 | val size = lines.size()
104 | while (i < size) { // All lines have colon in text
105 | val line = lines.valueAt(i)
106 | messaging.addMessage(buildMessage(conversation, lines.keyAt(i), line.second, line.first,
107 | if (senderInline) null else title.toString()))
108 | i++
109 | }
110 | Log.i(TAG, "Built from archive.")
111 | return messaging
112 | }
113 |
114 | fun buildFromConversation(conversation: Conversation, sbn: MutableStatusBarNotification): NotificationCompat.MessagingStyle? {
115 | val ext = conversation.ext ?: return null
116 | val n = sbn.notification
117 | val latestTimestamp = ext.latestTimestamp
118 | if (latestTimestamp > 0) {
119 | conversation.timestamp = latestTimestamp
120 | n.`when` = latestTimestamp
121 | }
122 | val onReply = ext.replyPendingIntent
123 | val onRead = ext.readPendingIntent
124 | if (onRead != null) mMarkReadPendingIntents[sbn.key] = onRead // Mapped by evolved key,
125 | val messages = buildMessages(conversation)
126 | val remoteInput = ext.remoteInput
127 | if (onReply != null && remoteInput != null && conversation.isChat()) {
128 | val inputHistory = n.extras.getCharSequenceArray(Notification.EXTRA_REMOTE_INPUT_HISTORY)
129 | val proxy = proxyDirectReply(conversation.nid, sbn, onReply, remoteInput, inputHistory)
130 | val replyRemoteInput = RemoteInput.Builder(remoteInput.resultKey).addExtras(remoteInput.extras)
131 | .setAllowFreeFormInput(true).setChoices(SmartReply.generateChoices(messages))
132 | val participant =
133 | ext.participant // No need to getParticipants() due to actually only one participant at most, see CarExtender.Builder().
134 | if (BuildConfig.DEBUG && conversation.id != null)
135 | replyRemoteInput.setLabel(conversation.id)
136 | else if (participant != null)
137 | replyRemoteInput.setLabel(participant)
138 | val replyAction = Notification.Action.Builder(null, mContext.getString(R.string.action_reply), proxy)
139 | .addRemoteInput(replyRemoteInput.build()).setAllowGeneratedReplies(true)
140 | if (SDK_INT >= P) replyAction.setSemanticAction(Notification.Action.SEMANTIC_ACTION_REPLY)
141 | n.addAction(replyAction.build())
142 | }
143 | val messaging = NotificationCompat.MessagingStyle(mUserSelf)
144 | for (message in messages) messaging.addMessage(message)
145 | return messaging
146 | }
147 |
148 | /** Intercept the PendingIntent in RemoteInput to update the notification with replied message upon success. */
149 | private fun proxyDirectReply(cid: Int, sbn: MutableStatusBarNotification, onReply: PendingIntent,
150 | remoteInput: RemoteInput, inputHistory: Array?): PendingIntent {
151 | val proxy = Intent(ACTION_REPLY) // Separate action to avoid PendingIntent overwrite.
152 | .setData(Uri.fromParts(SCHEME_KEY, sbn.key, null))
153 | .putExtra(EXTRA_REPLY_ACTION, onReply).putExtra(EXTRA_RESULT_KEY, remoteInput.resultKey)
154 | .putExtra(EXTRA_ORIGINAL_KEY, sbn.originalKey).putExtra(EXTRA_CONVERSATION_ID, cid)
155 | .putExtra(Intent.EXTRA_USER, sbn.user)
156 | if (inputHistory != null)
157 | proxy.putCharSequenceArrayListExtra(Notification.EXTRA_REMOTE_INPUT_HISTORY, arrayListOf(*inputHistory))
158 | return PendingIntent.getBroadcast(mContext, 0, proxy.setPackage(mContext.packageName), FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE)
159 | }
160 |
161 | private val mReplyReceiver: BroadcastReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, proxy: Intent) {
162 | val replyAction = proxy.getParcelableExtra(EXTRA_REPLY_ACTION)
163 | val resultKey = proxy.getStringExtra(EXTRA_RESULT_KEY)
164 | val replyPrefix = proxy.getStringExtra(EXTRA_REPLY_PREFIX)
165 | val data = proxy.data
166 | val results = RemoteInput.getResultsFromIntent(proxy)
167 | val user = proxy.getParcelableExtra(Intent.EXTRA_USER)
168 | val input = results?.getCharSequence(resultKey)
169 | if (data == null || replyAction == null || resultKey == null || input == null || user == null)
170 | return // Should never happen
171 | val key = data.schemeSpecificPart
172 | val originalKey = proxy.getStringExtra(EXTRA_ORIGINAL_KEY)
173 | if (BuildConfig.DEBUG && input.toString() == "debug") {
174 | val conversation = mController.getConversation(user, proxy.getIntExtra(EXTRA_CONVERSATION_ID, 0))
175 | if (conversation != null)
176 | showDebugNotification(mContext, conversation, "Type: " + conversation.typeToString())
177 | return mController.recastNotification(originalKey ?: key, null)
178 | }
179 | val text: CharSequence = replyPrefix?.plus(input)?.also {
180 | results.putCharSequence(resultKey, it)
181 | RemoteInput.addResultsToIntent(arrayOf(RemoteInput.Builder(resultKey).build()), proxy, results)
182 | } ?: input
183 |
184 | val history = proxy.getCharSequenceArrayListExtra(Notification.EXTRA_REMOTE_INPUT_HISTORY)
185 | try {
186 | val inputData = addTargetPackageAndWakeUp(replyAction)
187 | inputData.clipData = proxy.clipData
188 | replyAction.send(mContext, 0, inputData, PendingIntent.OnFinished { pendingIntent: PendingIntent, intent: Intent, _: Int, _: String?, _: Bundle? ->
189 | if (BuildConfig.DEBUG) Log.d(TAG, "Reply sent: " + intent.toUri(0))
190 | val addition = Bundle()
191 | val inputs: Array
192 | val toCurrentUser = Process.myUserHandle() == pendingIntent.creatorUserHandle
193 | inputs = if (toCurrentUser && context.packageManager.queryBroadcastReceivers(intent, 0).isEmpty())
194 | arrayOf(context.getString(R.string.wechat_with_no_reply_receiver))
195 | else history?.apply { add(0, text) }?.toTypedArray() ?: arrayOf(text)
196 | addition.putCharSequenceArray(Notification.EXTRA_REMOTE_INPUT_HISTORY, inputs)
197 | mController.recastNotification(originalKey ?: key, addition)
198 | markRead(key)
199 | }, null)
200 | } catch (e: PendingIntent.CanceledException) {
201 | Log.w(TAG, "Reply action is already cancelled: $key")
202 | abortBroadcast()
203 | }
204 | }}
205 |
206 | /** @param key the evolved key */
207 | fun markRead(key: String) {
208 | val action = mMarkReadPendingIntents.remove(key) ?: return
209 | try { action.send(mContext, 0, addTargetPackageAndWakeUp(action)) }
210 | catch (e: PendingIntent.CanceledException) { Log.w(TAG, "Mark-read action is already cancelled: $key") }
211 | }
212 |
213 | internal interface Controller {
214 | fun recastNotification(key: String, addition: Bundle?)
215 | fun getConversation(user: UserHandle, id: Int): Conversation?
216 | }
217 |
218 | fun close() {
219 | try { mContext.unregisterReceiver(mReplyReceiver) } catch (_: RuntimeException) {}
220 | }
221 |
222 | init {
223 | mContext.registerReceiver(mReplyReceiver, IntentFilter(ACTION_REPLY).apply { addDataScheme(SCHEME_KEY) })
224 | }
225 |
226 | private val mUserSelf: Person = buildPersonFromProfile(mContext)
227 | private val mMarkReadPendingIntents: MutableMap = ArrayMap()
228 |
229 | companion object {
230 | private const val MAX_NUM_HISTORICAL_LINES = 10
231 | private const val ACTION_REPLY = "REPLY"
232 | private const val SCHEME_KEY = "key"
233 | private const val EXTRA_REPLY_ACTION = "pending_intent"
234 | private const val EXTRA_RESULT_KEY = "result_key"
235 | private const val EXTRA_ORIGINAL_KEY = "original_key"
236 | private const val EXTRA_REPLY_PREFIX = "reply_prefix"
237 | private const val EXTRA_CONVERSATION_ID = "cid"
238 | private const val KEY_TEXT = "text"
239 | private const val KEY_TIMESTAMP = "time"
240 | private const val KEY_SENDER = "sender"
241 |
242 | @RequiresApi(P) private val KEY_SENDER_PERSON = "sender_person"
243 | private const val KEY_DATA_MIME_TYPE = "type"
244 | private const val KEY_DATA_URI = "uri"
245 | private const val KEY_EXTRAS_BUNDLE = "extras"
246 |
247 | fun showDebugNotification(context: Context, convs: Conversation, summary: String?) {
248 | val bigText = StringBuilder().append(convs.summary).append("\nT:").append(convs.ticker)
249 | val messages = if (convs.ext != null) convs.ext!!.messages else null
250 | if (messages != null) for (msg in messages) bigText.append("\n").append(msg)
251 | val n = Notification.Builder(context, "Debug").setSmallIcon(android.R.drawable.stat_sys_warning)
252 | .setContentTitle(convs.id).setContentText(convs.ticker).setSubText(summary).setShowWhen(true)
253 | .setStyle(Notification.BigTextStyle().setBigContentTitle(convs.title).bigText(bigText.toString()))
254 | context.getSystemService(NotificationManager::class.java)
255 | .notify(if (convs.id != null) convs.id.hashCode() else convs.title.hashCode(), n.build())
256 | }
257 |
258 | private fun buildMessage(conversation: Conversation, `when`: Long, ticker: CharSequence?, text: CharSequence,
259 | senderArg: String?): NotificationCompat.MessagingStyle.Message {
260 | var sender = senderArg
261 | var actualText: CharSequence? = text
262 | if (sender == null) {
263 | sender = text.extractSenderFromText()
264 | if (sender != null) {
265 | actualText = text.subSequence(sender.length + SENDER_MESSAGE_SEPARATOR.length, text.length)
266 | if (conversation.title == sender) sender = null // In this case, the actual sender is user itself.
267 | }
268 | }
269 | actualText = EmojiTranslator.translate(actualText)
270 | val person = when {
271 | sender != null && sender.isEmpty() -> null // Empty string as a special mark for "self"
272 | conversation.isGroupChat() -> // Group nick is used in ticker and content text, while original nick in sender.
273 | sender?.let { conversation.getGroupParticipant(it, ticker?.extractSenderFromText() ?: it) }
274 | else -> conversation.sender().build()
275 | }
276 | return NotificationCompat.MessagingStyle.Message(actualText, `when`, person)
277 | }
278 |
279 | private fun CharSequence.extractSenderFromText(): String? {
280 | val posColon = TextUtils.indexOf(this, SENDER_MESSAGE_SEPARATOR)
281 | return if (posColon > 0) toString().substring(0, posColon) else null
282 | }
283 |
284 | /** @return the extracted count in 0xFF range and start position in 0xFF00 range */
285 | private fun trimAndExtractLeadingCounter(text: CharSequence?): Int {
286 | // Parse and remove the leading "[n]" or [n条/則/…]
287 | if (text == null || text.length < 4 || text[0] != '[') return -1
288 | var textStart = 2
289 | var countEnd: Int
290 | while (text[textStart++] != ']') if (textStart >= text.length) return -1
291 | try {
292 | val num = text.subSequence(1, textStart - 1).toString() // may contain the suffix "条/則"
293 | countEnd = 0
294 | while (countEnd < num.length) {
295 | if (! Character.isDigit(num[countEnd])) break
296 | countEnd++
297 | }
298 | if (countEnd == 0) return -1 // Not the expected "unread count"
299 | val count = num.substring(0, countEnd).toInt()
300 | if (count < 2) return -1
301 | return if (count < 0xFFFF) count and 0xFFFF or (textStart shl 16 and -0x10000)
302 | else 0xFFFF or (textStart shl 16 and 0xFF00)
303 | } catch (ignored: NumberFormatException) {
304 | Log.d(TAG, "Failed to parse: $text")
305 | return -1
306 | }
307 | }
308 |
309 | /** Ensure the PendingIntent works even if WeChat is stopped or background-restricted. */
310 | private fun addTargetPackageAndWakeUp(action: PendingIntent): Intent {
311 | return Intent().addFlags(Intent.FLAG_INCLUDE_STOPPED_PACKAGES).setPackage(action.creatorPackage)
312 | }
313 |
314 | fun flatIntoExtras(messaging: NotificationCompat.MessagingStyle, extras: Bundle) {
315 | val user = messaging.user
316 | extras.putCharSequence(NotificationCompat.EXTRA_SELF_DISPLAY_NAME, user.name)
317 | if (SDK_INT >= P) extras.putParcelable(Notification.EXTRA_MESSAGING_PERSON, user.toNative()) // Not included in NotificationCompat
318 | if (messaging.conversationTitle != null)
319 | extras.putCharSequence(NotificationCompat.EXTRA_CONVERSATION_TITLE, messaging.conversationTitle)
320 | val messages = messaging.messages
321 | if (messages.isNotEmpty())
322 | extras.putParcelableArray(NotificationCompat.EXTRA_MESSAGES, getBundleArrayForMessages(messages))
323 | //if (! mHistoricMessages.isEmpty()) extras.putParcelableArray(Notification.EXTRA_HISTORIC_MESSAGES, MessagingBuilder.getBundleArrayForMessages(mHistoricMessages));
324 | extras.putBoolean(NotificationCompat.EXTRA_IS_GROUP_CONVERSATION, messaging.isGroupConversation)
325 | }
326 |
327 | private fun getBundleArrayForMessages(messages: List) =
328 | messages.map { toBundle(it) }.toTypedArray()
329 |
330 | private fun toBundle(message: NotificationCompat.MessagingStyle.Message) = Bundle().apply {
331 | putCharSequence(KEY_TEXT, message.text)
332 | putLong(KEY_TIMESTAMP, message.timestamp) // Must be included even for 0
333 | message.person?.also { sender ->
334 | putCharSequence(KEY_SENDER, sender.name) // Legacy listeners need this
335 | if (SDK_INT >= P) putParcelable(KEY_SENDER_PERSON, sender.toNative())
336 | }
337 | if (message.dataMimeType != null) putString(KEY_DATA_MIME_TYPE, message.dataMimeType)
338 | if (message.dataUri != null) putParcelable(KEY_DATA_URI, message.dataUri)
339 | if (! message.extras.isEmpty) putBundle(KEY_EXTRAS_BUNDLE, message.extras)
340 | //if (message.isRemoteInputHistory()) putBoolean(KEY_REMOTE_INPUT_HISTORY, message.isRemoteInputHistory());
341 | }
342 |
343 | private fun buildPersonFromProfile(context: Context): Person {
344 | return Person.Builder().setName(context.getString(R.string.self_display_name)).build()
345 | }
346 | }
347 | }
348 |
349 | @RequiresApi(P) @SuppressLint("RestrictedApi") fun Person.toNative() = toAndroidPerson()
350 |
--------------------------------------------------------------------------------
/src/main/java/com/oasisfeng/nevo/decorators/wechat/SmartReply.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2015 The Nevolution Project
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.oasisfeng.nevo.decorators.wechat
18 |
19 | import android.text.TextUtils
20 | import androidx.core.app.NotificationCompat.MessagingStyle.Message
21 |
22 | /**
23 | * A no-smart implementation for Smart Reply
24 | *
25 | * Created by Oasis on 2018-8-10.
26 | */
27 | internal object SmartReply {
28 | private val REPLIES_FOR_QUESTION =
29 | arrayOf(arrayOf("👌", "好", "对", "没问题"), arrayOf("👌", "OK", "Ye"))
30 |
31 | fun generateChoices(messages: List): Array? {
32 | if (messages.isEmpty()) return null
33 | val text = messages[messages.size - 1].text
34 | val chinese = TextUtils.indexOf(text, '?') >= 0
35 | return if (chinese || TextUtils.indexOf(text, '?') >= 0) REPLIES_FOR_QUESTION[if (chinese) 0 else 1] else null
36 | }
37 | }
--------------------------------------------------------------------------------
/src/main/java/com/oasisfeng/nevo/decorators/wechat/WeChatDecorator.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2015 The Nevolution Project
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.oasisfeng.nevo.decorators.wechat
18 |
19 | import android.app.ActivityOptions
20 | import android.app.Notification.*
21 | import android.app.NotificationChannel
22 | import android.app.NotificationManager
23 | import android.app.PendingIntent
24 | import android.content.*
25 | import android.media.AudioAttributes
26 | import android.net.Uri
27 | import android.os.*
28 | import android.os.Build.VERSION.SDK_INT
29 | import android.os.Build.VERSION_CODES
30 | import android.os.Build.VERSION_CODES.P
31 | import android.os.Build.VERSION_CODES.Q
32 | import android.provider.Settings
33 | import android.service.notification.NotificationListenerService
34 | import android.service.notification.NotificationListenerService.REASON_APP_CANCEL
35 | import android.service.notification.NotificationListenerService.REASON_CHANNEL_BANNED
36 | import android.util.Log
37 | import androidx.annotation.ColorInt
38 | import androidx.annotation.RequiresApi
39 | import androidx.annotation.StringRes
40 | import androidx.core.app.NotificationCompat
41 | import androidx.core.app.Person
42 | import androidx.core.graphics.drawable.IconCompat
43 | import com.oasisfeng.nevo.decorators.wechat.AgentShortcuts.Companion.buildShortcutId
44 | import com.oasisfeng.nevo.decorators.wechat.ConversationManager.Conversation
45 | import com.oasisfeng.nevo.decorators.wechat.IconHelper.convertToAdaptiveIcon
46 | import com.oasisfeng.nevo.sdk.MutableNotification
47 | import com.oasisfeng.nevo.sdk.MutableStatusBarNotification
48 | import com.oasisfeng.nevo.sdk.NevoDecoratorService
49 | import java.util.*
50 | import java.util.concurrent.CountDownLatch
51 | import java.util.concurrent.TimeUnit.MILLISECONDS
52 |
53 | const val WECHAT_PACKAGE = "com.tencent.mm"
54 | const val AGENT_PACKAGE = "com.oasisfeng.nevo.agents.v1.wechat"
55 | private const val DESIRED_BUBBLE_EXPANDED_HEIGHT = 512
56 |
57 | /**
58 | * Bring state-of-art notification experience to WeChat.
59 | *
60 | * Created by Oasis on 2015/6/1.
61 | */
62 | class WeChatDecorator : NevoDecoratorService() {
63 |
64 | companion object {
65 | private const val IGNORE_CAR_EXTENDER = false // For test purpose
66 | const val CHANNEL_MESSAGE = "message_channel_new_id" // Channel ID used by WeChat for all message notifications
67 | private const val MAX_NUM_ARCHIVED = 20
68 | private const val OLD_CHANNEL_MESSAGE = "message" // old name for migration
69 | private const val CHANNEL_MISC = "reminder_channel_id" // Channel ID used by WeChat for misc. notifications
70 | private const val OLD_CHANNEL_MISC = "misc" // old name for migration
71 | private const val CHANNEL_DND = "message_dnd_mode_channel_id" // Channel ID used by WeChat for its own DND mode
72 | private const val CHANNEL_VOIP = "voip_notify_channel_new_id" // Channel ID used by WeChat for VoIP notification
73 | private const val CHANNEL_GROUP_CONVERSATION = "group" // WeChat has no separate group for group conversation
74 | private const val GROUP_GROUP = "nevo.group.wechat.group"
75 | private const val GROUP_BOT = "nevo.group.wechat.bot"
76 | private const val GROUP_DIRECT = "nevo.group.wechat"
77 | private const val GROUP_MISC = "misc" // Not auto-grouped
78 | private const val KEY_SERVICE_MESSAGE = "notifymessage" // Virtual WeChat account for service notification messages
79 | private const val EXTRA_USERNAME = "Main_User" // Extra in content intent
80 |
81 | @ColorInt private val PRIMARY_COLOR = -0xcc4cce
82 | @ColorInt private val LIGHT_COLOR = -0xff0100
83 | const val ACTION_SETTINGS_CHANGED = "SETTINGS_CHANGED"
84 | const val PREFERENCES_NAME = "decorators-wechat"
85 | private const val EXTRA_SILENT_RECAST = "silent_recast"
86 |
87 | private fun getUser(key: String): UserHandle {
88 | val posPipe = key.indexOf('|')
89 | if (posPipe > 0)
90 | try { return userHandleOf(key.substring(0, posPipe).toInt()) } catch (_: NumberFormatException) {}
91 | Log.e(TAG, "Invalid key: $key")
92 | return Process.myUserHandle() // Only correct for single user.
93 | }
94 |
95 | private fun userHandleOf(user: Int): UserHandle {
96 | val currentUser = Process.myUserHandle()
97 | if (user == currentUser.hashCode()) return currentUser
98 | UserHandle.getUserHandleForUid(user * 100000 + 1)
99 | val parcel = Parcel.obtain()
100 | try { return UserHandle(parcel.apply { writeInt(user); setDataPosition(0) }) }
101 | finally { parcel.recycle() }
102 | }
103 | }
104 |
105 | public override fun apply(evolving: MutableStatusBarNotification): Boolean {
106 | val n = evolving.notification
107 | val flags = n.flags
108 | if (flags and FLAG_GROUP_SUMMARY != 0) {
109 | n.extras.putCharSequence(EXTRA_SUB_TEXT, getText(when(n.group) {
110 | GROUP_GROUP -> R.string.header_group_chat
111 | GROUP_BOT -> R.string.header_bot_message
112 | else -> return false }))
113 | return true
114 | }
115 | val extras = n.extras
116 | val title = extras.getCharSequence(EXTRA_TITLE)
117 | val channel = n.channelId
118 | if (title.isNullOrEmpty()) return false.also { Log.e(TAG, "Title is missing: $evolving") }
119 | if (flags and FLAG_ONGOING_EVENT != 0 && channel == CHANNEL_VOIP) return false
120 |
121 | n.color = PRIMARY_COLOR // Tint the small icon
122 | extras.putBoolean(EXTRA_SHOW_WHEN, true)
123 | if (isEnabled(mPrefKeyWear)) n.flags = n.flags and FLAG_LOCAL_ONLY.inv() // Remove FLAG_LOCAL_ONLY
124 | if (n.tickerText == null /* Legacy misc. notifications */ || channel == CHANNEL_MISC) {
125 | if (channel == null) n.channelId = CHANNEL_MISC
126 | n.group = GROUP_MISC // Avoid being auto-grouped
127 | Log.d(TAG, "Skip further process for non-conversation notification: $title") // E.g. web login confirmation notification.
128 | return flags and FLAG_FOREGROUND_SERVICE == 0
129 | }
130 | val contentText = extras.getCharSequence(EXTRA_TEXT) ?: return true
131 |
132 | val inputHistory = extras.getCharSequenceArray(EXTRA_REMOTE_INPUT_HISTORY)
133 | if (inputHistory != null || extras.getBoolean(EXTRA_SILENT_RECAST)) n.flags = n.flags or FLAG_ONLY_ALERT_ONCE
134 |
135 | val profile = evolving.user
136 | val conversation = mConversationManager.getOrCreateConversation(profile, evolving.originalId).also {
137 | it.icon = IconCompat.createFromIcon(this, n.getLargeIcon() ?: n.smallIcon)
138 | it.title = title; it.summary = contentText; it.ticker = n.tickerText; it.timestamp = n.`when`
139 | it.ext = if (IGNORE_CAR_EXTENDER) null else CarExtender(n).unreadConversation
140 | }
141 |
142 | val originalKey = evolving.originalKey
143 | var messaging = mMessagingBuilder.buildFromConversation(conversation, evolving)
144 | if (messaging == null) // EXTRA_TEXT will be written in buildFromArchive()
145 | messaging = mMessagingBuilder.buildFromArchive(conversation, n, title,
146 | getArchivedNotifications(originalKey, MAX_NUM_ARCHIVED))
147 | if (messaging == null) return true
148 | val messages = messaging.messages
149 | if (messages.isEmpty()) return true
150 | if (conversation.id == null) try {
151 | val latch = CountDownLatch(1)
152 | n.contentIntent.send(this, 0, null, { _: PendingIntent?, intent: Intent, _: Int, _: String?, _: Bundle? ->
153 | val id = intent.getStringExtra(EXTRA_USERNAME) ?: return@send Unit.also {
154 | Log.e(TAG, "Unexpected null ID received for conversation: " + conversation.title) }
155 | conversation.id = id // setType() below will trigger rebuilding of conversation sender.
156 | latch.countDown()
157 | if (BuildConfig.DEBUG && id.hashCode() != conversation.nid) Log.e(TAG, "NID is not hash code of CID")
158 | }, null, null, mActivityBlocker)
159 | try {
160 | if (latch.await(100, MILLISECONDS)) {
161 | if (BuildConfig.DEBUG) Log.d(TAG, "Conversation ID retrieved: " + conversation.id)
162 | } else Log.w(TAG, "Timeout retrieving conversation ID")
163 | } catch (_: InterruptedException) {}
164 | } catch (_: PendingIntent.CanceledException) {}
165 |
166 | val cid = conversation.id
167 | if (cid == null) {
168 | if (conversation.isTypeUnknown())
169 | conversation.setType(guessConversationType(conversation))
170 | } else conversation.setType(when {
171 | cid.endsWith("@chatroom") || cid.endsWith("@im.chatroom") -> Conversation.TYPE_GROUP_CHAT // @im.chatroom is WeWork
172 | cid.startsWith("gh_") || cid == KEY_SERVICE_MESSAGE -> Conversation.TYPE_BOT_MESSAGE
173 | cid.endsWith("@openim") -> Conversation.TYPE_DIRECT_MESSAGE
174 | else -> Conversation.TYPE_UNKNOWN
175 | })
176 | if (SDK_INT >= VERSION_CODES.R && inputHistory != null) { // EXTRA_REMOTE_INPUT_HISTORY is no longer supported on Android R.
177 | for (i in inputHistory.indices.reversed()) // Append them to messages in MessagingStyle.
178 | messages.add(NotificationCompat.MessagingStyle.Message(inputHistory[i], 0L, null as Person?))
179 | extras.remove(EXTRA_REMOTE_INPUT_HISTORY)
180 | }
181 | val isGroupChat = conversation.isGroupChat()
182 | if (SDK_INT >= P && KEY_SERVICE_MESSAGE == cid) { // Setting conversation title before Android P will make it a group chat.
183 | messaging.conversationTitle = getString(R.string.header_service_message) // A special header for this non-group conversation with multiple senders
184 | n.group = GROUP_BOT
185 | } else n.group = if (isGroupChat) GROUP_GROUP else if (conversation.isBotMessage()) GROUP_BOT else GROUP_DIRECT
186 | if (isGroupChat && mUseExtraChannels && channel != CHANNEL_DND) n.channelId = CHANNEL_GROUP_CONVERSATION
187 | else if (channel == null) n.channelId = CHANNEL_MESSAGE // WeChat versions targeting O+ have its own channel for message }
188 | if (isGroupChat) messaging.setGroupConversation(true).conversationTitle = title
189 | MessagingBuilder.flatIntoExtras(messaging, extras)
190 | extras.putString(EXTRA_TEMPLATE, TEMPLATE_MESSAGING)
191 |
192 | if (SDK_INT > Q) maybeAddBubbleMetadata(n, conversation, profile)
193 | return true
194 | }
195 |
196 | @RequiresApi(VERSION_CODES.R)
197 | private fun maybeAddBubbleMetadata(n: MutableNotification, conversation: Conversation, profile: UserHandle) {
198 | if (! conversation.isChat() || conversation.isBotMessage()) return
199 | val cid = conversation.id ?: return
200 | val shortcutId = buildShortcutId(cid)
201 | val shortcutReady = mAgentShortcuts.updateShortcutIfNeeded(shortcutId, conversation, profile)
202 | if (shortcutReady) n.shortcutId = shortcutId
203 | n.locusId = LocusId(shortcutId)
204 | val builder = if (shortcutReady) BubbleMetadata.Builder(shortcutId)
205 | else BubbleMetadata.Builder(n.contentIntent, convertToAdaptiveIcon(this, conversation.icon!!))
206 | n.bubbleMetadata = builder.setDesiredHeight(DESIRED_BUBBLE_EXPANDED_HEIGHT).build()
207 | }
208 |
209 | private fun isEnabled(mPrefKeyCallTweak: String) = mPreferences.getBoolean(mPrefKeyCallTweak, false)
210 |
211 | override fun onNotificationRemoved(key: String, reason: Int): Boolean {
212 | if (reason == REASON_APP_CANCEL) { // For ongoing notification, or if "Removal-Aware" of Nevolution is activated
213 | Log.d(TAG, "Cancel notification: $key")
214 | } else if (reason == REASON_CHANNEL_BANNED && ! isChannelAvailable(getUser(key))) {
215 | Log.w(TAG, "Channel lost, disable extra channels from now on.")
216 | mUseExtraChannels = false
217 | mHandler.post { recastNotification(key, null) }
218 | } else if (reason == NotificationListenerService.REASON_CANCEL) // Exclude the removal request by us in above case. (Removal-Aware is only supported on Android 8+)
219 | mMessagingBuilder.markRead(key)
220 | return false
221 | }
222 |
223 | private fun isChannelAvailable(user: UserHandle) =
224 | getNotificationChannel(WECHAT_PACKAGE, user, CHANNEL_GROUP_CONVERSATION) != null
225 |
226 | override fun onConnected() {
227 | val channels = ArrayList()
228 | channels.add(makeChannel(CHANNEL_GROUP_CONVERSATION, R.string.channel_group_message, false))
229 | // WeChat versions targeting O+ have its own channels for message and misc
230 | channels.add(migrate(OLD_CHANNEL_MESSAGE, CHANNEL_MESSAGE, R.string.channel_message, false))
231 | channels.add(migrate(OLD_CHANNEL_MISC, CHANNEL_MISC, R.string.channel_misc, true))
232 | createNotificationChannels(WECHAT_PACKAGE, Process.myUserHandle(), channels)
233 | }
234 |
235 | private fun migrate(old_id: String, new_id: String, @StringRes new_name: Int, silent: Boolean): NotificationChannel {
236 | val channelMessage = getNotificationChannel(WECHAT_PACKAGE, Process.myUserHandle(), old_id)
237 | deleteNotificationChannel(WECHAT_PACKAGE, Process.myUserHandle(), old_id)
238 | return channelMessage?.let { cloneChannel(it, new_id, new_name) } ?: makeChannel(new_id, new_name, silent)
239 | }
240 |
241 | private fun makeChannel(channel_id: String, @StringRes name: Int, silent: Boolean): NotificationChannel {
242 | val channel = NotificationChannel(channel_id, getString(name), NotificationManager.IMPORTANCE_HIGH /* Allow heads-up (by default) */)
243 | if (! silent) {
244 | val attributes = AudioAttributes.Builder().setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
245 | .setUsage(AudioAttributes.USAGE_NOTIFICATION_COMMUNICATION_INSTANT).build()
246 | channel.setSound(getDefaultSound(), attributes)
247 | } else channel.setSound(null, null)
248 | channel.enableLights(true)
249 | channel.lightColor = LIGHT_COLOR
250 | return channel
251 | }
252 |
253 | private fun cloneChannel(channel: NotificationChannel, id: String, new_name: Int) =
254 | NotificationChannel(id, getString(new_name), channel.importance).apply {
255 | group = channel.group
256 | description = channel.description
257 | lockscreenVisibility = channel.lockscreenVisibility
258 | setSound(Optional.ofNullable(channel.sound).orElse(getDefaultSound()), channel.audioAttributes)
259 | setBypassDnd(channel.canBypassDnd())
260 | lightColor = channel.lightColor
261 | setShowBadge(channel.canShowBadge())
262 | vibrationPattern = channel.vibrationPattern
263 | }
264 |
265 | // Before targeting O, WeChat actually plays sound by itself (not via Notification).
266 | private fun getDefaultSound(): Uri? = Settings.System.DEFAULT_NOTIFICATION_URI
267 |
268 | override fun onCreate() {
269 | val context = createDeviceProtectedStorageContext()
270 | mPreferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_MULTI_PROCESS)
271 | mPrefKeyWear = getString(R.string.pref_wear)
272 | mMessagingBuilder = MessagingBuilder(this, object : MessagingBuilder.Controller {
273 | override fun recastNotification(key: String, addition: Bundle?) = this@WeChatDecorator.recastNotification(key, addition)
274 | override fun getConversation(user: UserHandle, id: Int) = mConversationManager.getConversation(user, id)
275 | }) // Must be called after loadPreferences().
276 | mAgentShortcuts = AgentShortcuts(this)
277 | val filter = IntentFilter(Intent.ACTION_PACKAGE_REMOVED)
278 | filter.addDataScheme("package")
279 | registerReceiver(mSettingsChangedReceiver, IntentFilter(ACTION_SETTINGS_CHANGED))
280 | }
281 |
282 | override fun onDestroy() {
283 | unregisterReceiver(mSettingsChangedReceiver)
284 | mAgentShortcuts.close()
285 | mMessagingBuilder.close()
286 | }
287 |
288 | private val mSettingsChangedReceiver: BroadcastReceiver = object : BroadcastReceiver() {
289 | override fun onReceive(context: Context, intent: Intent) {
290 | val extras = intent.extras
291 | val keys = if (extras != null) extras.keySet() else emptySet()
292 | if (keys.isEmpty()) return
293 | val editor = mPreferences.edit()
294 | for (key in keys) editor.putBoolean(key, extras!!.getBoolean(key)).apply()
295 | editor.apply()
296 | }
297 | }
298 | private val mConversationManager = ConversationManager()
299 | private lateinit var mMessagingBuilder: MessagingBuilder
300 | private lateinit var mAgentShortcuts: AgentShortcuts
301 | private var mUseExtraChannels = true // Extra channels should not be used in Insider mode, as WeChat always removes channels not maintained by itself.
302 | private lateinit var mPreferences: SharedPreferences
303 | private lateinit var mPrefKeyWear: String
304 | private val mHandler = Handler(Looper.myLooper()!!)
305 | private val mActivityBlocker = ActivityOptions.makeBasic().setLaunchDisplayId(Int.MAX_VALUE / 2).toBundle()
306 | }
307 |
308 | const val TAG = "Nevo.Decorator[WeChat]"
309 |
--------------------------------------------------------------------------------
/src/main/java/com/oasisfeng/nevo/decorators/wechat/WeChatDecoratorSettingsActivity.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2015 The Nevolution Project
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | @file:Suppress("DEPRECATION")
18 |
19 | package com.oasisfeng.nevo.decorators.wechat
20 |
21 | import android.app.AlertDialog
22 | import android.content.*
23 | import android.content.Intent.*
24 | import android.content.pm.PackageManager.*
25 | import android.net.Uri
26 | import android.os.Bundle
27 | import android.preference.Preference
28 | import android.preference.Preference.OnPreferenceClickListener
29 | import android.preference.PreferenceActivity
30 | import android.provider.Settings
31 | import android.util.Log
32 | import androidx.annotation.StringRes
33 | import com.oasisfeng.nevo.decorators.wechat.WeChatDecorator.Companion.ACTION_SETTINGS_CHANGED
34 | import com.oasisfeng.nevo.sdk.NevoDecoratorService
35 |
36 | /**
37 | * Entry activity. Some ROMs (including Samsung, OnePlus) require a launcher activity to allow any component being bound by other app.
38 | */
39 | class WeChatDecoratorSettingsActivity : PreferenceActivity() {
40 |
41 | @Deprecated("Deprecated in Java") override fun onCreate(savedInstanceState: Bundle?) {
42 | super.onCreate(savedInstanceState)
43 | val manager = preferenceManager
44 | manager.sharedPreferencesName = WeChatDecorator.PREFERENCES_NAME
45 | manager.setStorageDeviceProtected()
46 | preferenceManager.sharedPreferences.registerOnSharedPreferenceChangeListener(mPreferencesChangeListener)
47 | addPreferencesFromResource(R.xml.decorators_wechat_settings)
48 | }
49 |
50 | override fun onResume() {
51 | super.onResume()
52 | val pm = packageManager
53 |
54 | val isNevoInstalled = try { pm.getApplicationInfo(NEVOLUTION_PACKAGE, 0); true } catch (e: NameNotFoundException) { false }
55 | val isWechatInstalled = try { pm.getApplicationInfo(WECHAT_PACKAGE, GET_UNINSTALLED_PACKAGES); true } catch (e: NameNotFoundException) { false }
56 | val running = isDecoratorRunning()
57 |
58 | findPreference(getString(R.string.pref_activate)).apply {
59 | isEnabled = ! isNevoInstalled || isWechatInstalled // No reason to promote WeChat if not installed.
60 | isSelectable = ! running
61 | summary = when {
62 | ! isNevoInstalled -> getText(R.string.pref_activate_summary_nevo_not_installed)
63 | ! isWechatInstalled -> getText(R.string.pref_activate_summary_wechat_not_installed)
64 | running -> getText(R.string.pref_activate_summary_already_activated)
65 | else -> null
66 | }
67 | onPreferenceClickListener = when {
68 | ! isNevoInstalled -> OnPreferenceClickListener { installNevolution() }
69 | isWechatInstalled && ! running -> OnPreferenceClickListener { activate() }
70 | else -> null
71 | }
72 | }
73 |
74 | val context = this
75 | (findPreference(getString(R.string.pref_compat_mode)) as android.preference.TwoStatePreference).apply {
76 | val isAndroidAutoAvailable = getPackageVersion(ANDROID_AUTO_PACKAGE) >= 0
77 | if (isAndroidAutoAvailable && Settings.Global.getInt(contentResolver, Settings.Global.DEVELOPMENT_SETTINGS_ENABLED, 0) != 0) {
78 | CompatModeController.query(context) { checked: Boolean? -> isChecked = checked!! }
79 | onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _: Preference?, newValue: Any ->
80 | val enabled = newValue as Boolean
81 | CompatModeController.request(context, enabled) { changed: Boolean -> if (changed) isChecked = enabled }
82 | false // Will be updated by the callback in previous line.
83 | }
84 | } else preferenceScreen.removePreference(this)
85 | }
86 |
87 | findPreference(getString(R.string.pref_agent))?.apply {
88 | val agentVersion = getPackageVersion(AGENT_PACKAGE)
89 | isEnabled = isWechatInstalled
90 | if (agentVersion >= CURRENT_AGENT_VERSION) {
91 | val launcherIntent = Intent(ACTION_MAIN).addCategory(CATEGORY_LAUNCHER)
92 | .setPackage(AGENT_PACKAGE)
93 | val disabled = pm.queryIntentActivities(launcherIntent, 0).isEmpty()
94 | @StringRes val prefix = if (disabled) R.string.pref_agent_summary_prefix_disabled else R.string.pref_agent_summary_prefix_enabled
95 | summary = """
96 | ${getString(prefix)}
97 | ${getString(R.string.pref_agent_summary_installed)}
98 | """.trimIndent()
99 | onPreferenceClickListener = OnPreferenceClickListener { selectAgentLabel() }
100 | } else {
101 | setSummary(if (agentVersion < 0) R.string.pref_agent_summary else R.string.pref_agent_summary_update)
102 | onPreferenceClickListener = OnPreferenceClickListener {
103 | startActivity(Intent(ACTION_VIEW, Uri.parse(AGENT_URL)).addFlags(FLAG_ACTIVITY_NEW_TASK))
104 | true
105 | }
106 | }
107 | }
108 | }
109 |
110 | private fun selectAgentLabel(): Boolean {
111 | val pm = packageManager
112 | val query = Intent(ACTION_MAIN).addCategory(CATEGORY_LAUNCHER).setPackage(AGENT_PACKAGE)
113 | val resolves = pm.queryIntentActivities(query, GET_DISABLED_COMPONENTS)
114 | val size = resolves.size
115 | check(size > 1) { "No activities found for $query" }
116 | val labels = resolves.map { it.activityInfo.loadLabel(pm) }
117 | .plus(getText(R.string.action_disable_agent_launcher_entrance)).toTypedArray()
118 | AlertDialog.Builder(this).setSingleChoiceItems(labels, -1) { dialog: DialogInterface, which: Int -> // TODO: Item cannot be selected on Sony device?
119 | for (i in resolves.indices)
120 | pm.setComponentEnabledSetting(ComponentName(AGENT_PACKAGE, resolves[i].activityInfo.name),
121 | if (i == which) COMPONENT_ENABLED_STATE_ENABLED else COMPONENT_ENABLED_STATE_DISABLED, DONT_KILL_APP)
122 | dialog.dismiss()
123 | }.show()
124 | return true
125 | }
126 |
127 | private fun isDecoratorRunning(): Boolean {
128 | val service = Intent(this, WeChatDecorator::class.java).setAction(NevoDecoratorService.ACTION_DECORATOR_SERVICE)
129 | return null != mDummyReceiver.peekService(this, service)
130 | }
131 |
132 | private fun getPackageVersion(pkg: String): Int {
133 | return try { packageManager.getPackageInfo(pkg, 0).versionCode } catch (e: NameNotFoundException) { -1 }
134 | }
135 |
136 | @Deprecated("Deprecated in Java") override fun onDestroy() {
137 | preferenceManager.sharedPreferences.unregisterOnSharedPreferenceChangeListener(mPreferencesChangeListener)
138 | super.onDestroy()
139 | }
140 |
141 | private fun installNevolution(): Boolean {
142 | try { startActivity(Intent(ACTION_VIEW, Uri.parse(APP_MARKET_PREFIX + NEVOLUTION_PACKAGE))) }
143 | catch (ignored: ActivityNotFoundException) { } // TODO: Landing web page
144 | return true
145 | }
146 |
147 | private fun activate(): Boolean {
148 | try {
149 | startActivityForResult(Intent("com.oasisfeng.nevo.action.ACTIVATE_DECORATOR").setPackage(NEVOLUTION_PACKAGE)
150 | .putExtra("nevo.decorator", ComponentName(this, WeChatDecorator::class.java))
151 | .putExtra("nevo.target", WECHAT_PACKAGE), 0)
152 | } catch (e: ActivityNotFoundException) {
153 | startActivity(Intent(ACTION_MAIN).addCategory(CATEGORY_LAUNCHER).setPackage(NEVOLUTION_PACKAGE))
154 | }
155 | return true
156 | }
157 |
158 | private val mPreferencesChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { prefs: SharedPreferences, key: String? ->
159 | Log.d(TAG, "Settings changed, notify decorator now.")
160 | sendBroadcast(Intent(ACTION_SETTINGS_CHANGED).setPackage(packageName).putExtra(key, prefs.getBoolean(key, false)))
161 | }
162 |
163 | private val mDummyReceiver: BroadcastReceiver = object : BroadcastReceiver() { override fun onReceive(c: Context, i: Intent) {}}
164 |
165 | companion object {
166 | private const val CURRENT_AGENT_VERSION = 1700
167 | private const val NEVOLUTION_PACKAGE = "com.oasisfeng.nevo"
168 | private const val ANDROID_AUTO_PACKAGE = "com.google.android.projection.gearhead"
169 | private const val APP_MARKET_PREFIX = "market://details?id="
170 | private const val AGENT_URL = "https://github.com/Nevolution/decorator-wechat/releases"
171 | }
172 | }
173 |
--------------------------------------------------------------------------------
/src/main/java/com/oasisfeng/nevo/decorators/wechat/WeChatDecoratorSettingsReceiver.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2015 The Nevolution Project
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.oasisfeng.nevo.decorators.wechat
18 |
19 | import android.content.BroadcastReceiver
20 | import android.content.Context
21 | import android.content.Intent
22 | import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
23 | import com.oasisfeng.nevo.decorators.wechat.WeChatDecoratorSettingsActivity
24 |
25 | /**
26 | * Created by Oasis on 2018/4/26.
27 | */
28 | class WeChatDecoratorSettingsReceiver : BroadcastReceiver() {
29 |
30 | override fun onReceive(context: Context, intent: Intent) =
31 | context.startActivity(Intent(context, WeChatDecoratorSettingsActivity::class.java).addFlags(FLAG_ACTIVITY_NEW_TASK))
32 | }
--------------------------------------------------------------------------------
/src/main/java/com/oasisfeng/nevo/decorators/wechat/WeChatMessage.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2015 The Nevolution Project
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.oasisfeng.nevo.decorators.wechat
18 |
19 | import android.text.TextUtils
20 | import android.util.Log
21 | import androidx.core.app.NotificationCompat
22 | import com.oasisfeng.nevo.decorators.wechat.ConversationManager.Conversation
23 | import kotlin.math.min
24 |
25 | /**
26 | * Parse various fields collected from notification to build a structural message.
27 | *
28 | * Known cases
29 | * -------------
30 | * Direct message 1 unread Ticker: "Oasis: Hello", Title: "Oasis", Summary: "Hello" CarExt: "Hello"
31 | * Direct message >1 unread Ticker: "Oasis: [Link] WTF", Title: "Oasis", Summary: "[2]Oasis: [Link] WTF"
32 | * Service message 1 unread Ticker: "FedEx: [Link] Status", Title: "FedEx", Summary: "[Link] Status" CarExt: "[Link] Status"
33 | * Service message >1 unread Ticker: "FedEx: Delivered", Title: "FedEx", Summary: "[2]FedEx: Delivered" CarExt: "[Link] Delivered"
34 | * Group chat with 1 unread Ticker: "GroupNick: Hello", Title: "Group", Summary: "GroupNick: Hello" CarExt: "GroupNick: Hello"
35 | * Group chat with >1 unread Ticker: "GroupNick: [Link] Mm", Title: "Group", Summary: "[2]GroupNick: [Link] Mm" CarExt: "GroupNick: [Link] Mm"
36 | *
37 | * Created by Oasis on 2019-4-19.
38 | */
39 |
40 | const val SENDER_MESSAGE_SEPARATOR = ": "
41 | private const val SELF = ""
42 |
43 | class WeChatMessage constructor(private val conversation: Conversation, sender: CharSequence?,
44 | val text: CharSequence?, private val time: Long) {
45 | private val sender = sender?.toString() // Nick defaults to sender
46 | private val nick = this.sender
47 |
48 | fun toMessage(): NotificationCompat.MessagingStyle.Message {
49 | val person = if (sender == SELF) null else if (conversation.isGroupChat()) conversation.getGroupParticipant(
50 | sender!!, nick!!
51 | ) else conversation.sender().build()
52 | return NotificationCompat.MessagingStyle.Message(text, time, person)
53 | }
54 | }
55 |
56 | fun buildMessages(conversation: Conversation): List {
57 | val extMessages = conversation.ext?.messages?.takeIf { it.isNotEmpty() } // Sometimes extender messages are empty, for unknown cause.
58 | ?: return listOf(buildFromBasicFields(conversation).toMessage()) // No messages in car conversation
59 | val basicMessage = buildFromBasicFields(conversation)
60 | val messages = ArrayList(extMessages.size)
61 | var endOfPeers = -1
62 | if (! conversation.isGroupChat()) {
63 | endOfPeers = extMessages.size - 1
64 | while (endOfPeers >= -1) {
65 | if (endOfPeers >= 0 && TextUtils.equals(basicMessage.text, extMessages[endOfPeers])) break // Find the actual end line which matches basic fields, in case extra lines are sent by self
66 | endOfPeers-- }}
67 | var i = 0
68 | val count = extMessages.size
69 | while (i < count) {
70 | messages.add(buildFromCarMessage(conversation, extMessages[i], endOfPeers in 0 until i).toMessage())
71 | i++ }
72 | return messages
73 | }
74 |
75 | private fun buildFromBasicFields(conversation: Conversation): WeChatMessage {
76 | // Trim the possible trailing white spaces in ticker.
77 | var ticker = conversation.ticker
78 | var tickerLength = ticker!!.length
79 | var tickerEnd = tickerLength
80 | while (tickerEnd > 0 && ticker[tickerEnd - 1] == ' ') tickerEnd--
81 | if (tickerEnd != tickerLength) {
82 | ticker = ticker.subSequence(0, tickerEnd)
83 | tickerLength = tickerEnd
84 | }
85 | var sender: CharSequence? = null
86 | var text: CharSequence?
87 | var pos = TextUtils.indexOf(ticker, SENDER_MESSAGE_SEPARATOR)
88 | var unreadCount = 0
89 | if (pos > 0) {
90 | sender = ticker.subSequence(0, pos)
91 | text = ticker.subSequence(pos + SENDER_MESSAGE_SEPARATOR.length, tickerLength)
92 | } else text = ticker
93 | val summary = conversation.summary
94 | val contentLength = summary!!.length
95 | var contentWithoutPrefix = summary
96 | if (contentLength > 3 && summary[0] == '[' && TextUtils.indexOf(summary, ']', 1).also { pos = it } > 0) {
97 | unreadCount = parsePrefixAsUnreadCount(summary.subSequence(1, pos))
98 | if (unreadCount > 0) {
99 | conversation.count = unreadCount
100 | contentWithoutPrefix = summary.subSequence(pos + 1, contentLength)
101 | } else if (TextUtils.equals(summary.subSequence(pos + 1, contentLength), text)) conversation.setType(
102 | Conversation.TYPE_BOT_MESSAGE
103 | ) // Only bot message omits prefix (e.g. "[Link]")
104 | }
105 | if (sender == null) { // No sender in ticker, blindly trust the sender in summary text.
106 | pos = TextUtils.indexOf(contentWithoutPrefix, SENDER_MESSAGE_SEPARATOR)
107 | if (pos > 0) {
108 | sender = contentWithoutPrefix.subSequence(0, pos)
109 | text = contentWithoutPrefix.subSequence(pos + 1, contentWithoutPrefix.length)
110 | } else text = contentWithoutPrefix
111 | } else if (! startsWith(contentWithoutPrefix, sender, SENDER_MESSAGE_SEPARATOR)) { // Ensure sender matches (in ticker and summary)
112 | if (unreadCount > 0) // When unread count prefix is present, sender should also be included in summary.
113 | Log.e(TAG, "Sender mismatch: \"$sender\" in ticker, summary: " + summary.subSequence(0, min(10, contentLength)))
114 | if (startsWith(ticker, sender, SENDER_MESSAGE_SEPARATOR)) // Normal case for single unread message
115 | return WeChatMessage(conversation, sender, contentWithoutPrefix, conversation.timestamp)
116 | }
117 | return WeChatMessage(conversation, sender, text, conversation.timestamp)
118 | }
119 |
120 | /**
121 | * Parse unread count prefix in the form of "n" or "n条/則/…".
122 | * @return unread count, or 0 if unrecognized as unread count
123 | */
124 | private fun parsePrefixAsUnreadCount(prefix: CharSequence): Int {
125 | val length = prefix.length
126 | if (length < 1) return 0
127 | val count =
128 | if (length > 1 && !Character.isDigit(prefix[length - 1])) prefix.subSequence(0, length - 1) else prefix
129 | return try {
130 | count.toString().toInt()
131 | } catch (ignored: NumberFormatException) { // Probably just emoji like "[Cry]"
132 | Log.d(TAG, "Failed to parse as int: $prefix")
133 | 0
134 | }
135 | }
136 |
137 | fun guessConversationType(conversation: Conversation): Int {
138 | val ext = conversation.ext
139 | val messages = ext?.messages
140 | val numMessages = messages?.size ?: 0
141 | val lastMessage = if (numMessages > 0) messages!![numMessages - 1] else null
142 | if (numMessages > 1) { // Car extender messages with multiple senders are strong evidence for group chat.
143 | var sender: String? = null
144 | for (message in messages!!) {
145 | val splits = message.split(':', limit = 2).toTypedArray()
146 | if (splits.size < 2) continue
147 | if (sender == null) sender =
148 | splits[0] else if (sender != splits[0]) return Conversation.TYPE_GROUP_CHAT // More than one sender
149 | }
150 | }
151 | val content = conversation.summary ?: return Conversation.TYPE_UNKNOWN
152 | val ticker = conversation.ticker.toString()
153 | .trim { it <= ' ' } // Ticker text (may contain trailing spaces) always starts with sender (same as title for direct message, but not for group chat).
154 | // Content text includes sender for group and service messages, but not for direct messages.
155 | val pos = TextUtils.indexOf(content, ticker) // Seek for the ticker text in content.
156 | return if (pos in 0..6) { // Max length (up to 999 unread): [999t]
157 | // The content without unread count prefix, may or may not start with sender nick
158 | val contentWithoutCount = if (pos > 0 && content[0] == '[') content.subSequence(pos, content.length) else content
159 | // content_wo_count.startsWith(title + SENDER_MESSAGE_SEPARATOR)
160 | if (startsWith(contentWithoutCount, conversation.title, SENDER_MESSAGE_SEPARATOR)) { // The title of group chat is group name, not the message sender
161 | val text = contentWithoutCount.subSequence(conversation.title!!.length + SENDER_MESSAGE_SEPARATOR.length,
162 | contentWithoutCount.length)
163 | if (startWithBracketedPrefixAndOneSpace(lastMessage, text)) // Ticker: "Bot name: Text", Content: "[2] Bot name: Text", Message: "[Link] Text"
164 | return Conversation.TYPE_BOT_MESSAGE else if (isBracketedPrefixOnly(lastMessage)) return Conversation.TYPE_BOT_MESSAGE
165 | return Conversation.TYPE_DIRECT_MESSAGE // Most probably a direct message with more than 1 unread
166 | }
167 | Conversation.TYPE_GROUP_CHAT
168 | } else if (TextUtils.indexOf(ticker, content) >= 0) {
169 | if (startWithBracketedPrefixAndOneSpace(lastMessage, content)) Conversation.TYPE_BOT_MESSAGE
170 | else Conversation.TYPE_UNKNOWN // Indistinguishable (direct message with 1 unread, or a service text message without link)
171 | } else Conversation.TYPE_BOT_MESSAGE // Most probably a service message with link
172 | }
173 |
174 | private fun startWithBracketedPrefixAndOneSpace(text: String?, needle: CharSequence): Boolean {
175 | if (text == null) return false
176 | val start = text.indexOf(needle.toString())
177 | return start > 3 && text[0] == '[' && text[start - 1] == ' ' && text[start - 2] == ']'
178 | }
179 |
180 | private fun isBracketedPrefixOnly(text: String?): Boolean {
181 | val length = (text ?: return false).length
182 | return length in 3..4 && text[0] == '[' && text[length - 1] == ']'
183 | }
184 |
185 | private fun startsWith(text: CharSequence?, needle1: CharSequence?, needle2: String): Boolean {
186 | val needle1Length = needle1!!.length
187 | val needle2Length = needle2.length
188 | return (text!!.length > needle1Length + needle2Length && TextUtils.regionMatches(text, 0, needle1, 0, needle1Length)
189 | && TextUtils.regionMatches(text, needle1Length, needle2, 0, needle2Length))
190 | }
191 |
192 | private fun buildFromCarMessage(conversation: Conversation, message: String, from_self: Boolean): WeChatMessage {
193 | var text: String? = message
194 | var sender: String? = null
195 | val pos = if (from_self) 0 else TextUtils.indexOf(message, SENDER_MESSAGE_SEPARATOR)
196 | if (pos > 0) {
197 | sender = message.substring(0, pos)
198 | val titleAsSender = TextUtils.equals(sender, conversation.title)
199 | if (conversation.isGroupChat() || titleAsSender) { // Verify the sender with title for non-group conversation
200 | text = message.substring(pos + SENDER_MESSAGE_SEPARATOR.length)
201 | if (conversation.isGroupChat() && titleAsSender) sender =
202 | SELF // WeChat incorrectly use group chat title as sender for self-sent messages.
203 | } else sender = null // Not really the sender name, revert the parsing result.
204 | }
205 | return WeChatMessage(conversation, if (from_self) SELF else sender, EmojiTranslator.translate(text), 0)
206 | }
207 |
--------------------------------------------------------------------------------
/src/main/res/values-night/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
17 |
18 |
19 | 女娲石 - 即时通信增强(支持微信通知)
20 | 微信通知增强
21 | 符合当代 Android 的最佳通知体验:会话样式、直接回复、划除即已读等高级特性。
22 | 你
23 | 回复
24 | 群聊
25 | 微信号
26 | 服务消息
27 | 回复失败(微信缺少处理回复的组件,可能已禁用)
28 |
29 | 新消息通知
30 | 新群组消息通知
31 | 其它通知
32 |
33 |
34 |
35 | 启用
36 | “微信”未安装
37 | “女娲石”尚未安装
38 | 已启用
39 | 兼容模式 (试验性)
40 | 强制设备全局进入兼容模式,其影响与具体设备有关。(例如 “息屏显示” 或 “双击唤醒” 无法工作)\n最新版本微信需要这个模式才能启用特性扩展包。
41 | 体验增强包
42 | 通过安装一个微信的替身,支持快捷方式(“扫码”及最近的会话)、通知圆点(需启动器支持)、聊天气泡(Android 11+)等特性,还可消除女娲石工作在外源模式时微信通知前缀。
43 | 已启用
44 | 已禁用
45 | 启用此功能后,启动器中会多出一个与微信相同图标的启动入口,它是一个微信启动图标的“替身”,通知圆点(需启动器支持)和“扫码”快捷方式(Android 7.1+)将显示在其上。\n点击可切换此替身的显示名或禁用。
46 | 有更新版本,请点击这里开始升级。🆕
47 | 隐藏替身的启动入口\n(将失去“快捷方式”和“消息气泡”)
48 | 解决双重提示问题
49 | 消除双重提示音、震动及横幅快速闪替。
50 | 如果遇到通知横幅的闪替问题,请关闭微信本身通知设置中“新消息通知”的横幅选项(或调降重要性)。\n\n如果遇到双重提示音或震动,请关闭微信本身通知设置中“新消息通知”的提示音或震动。\n\n请避免修改除“新消息通知”之外的其它通知类别。
51 | 打开微信本身的通知设置
52 | Wear OS (实验性)
53 | 同步通知至 Wear OS 设备。(如果设备上安装有微信 Wear 版,请勿开启)
54 | 开源项目
55 | 欢迎来 GitHub 上访问我们
56 | 继续
57 |
--------------------------------------------------------------------------------
/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/src/main/res/values/values.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | - 通话
5 | - 通話
6 | - call
7 |
8 |
9 |
10 | 0
11 |
12 | activate
13 | compat
14 | agent
15 | wear
16 |
17 |
--------------------------------------------------------------------------------
/src/main/res/xml/decorators_wechat_settings.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
9 |
10 |
15 |
16 |
20 |
21 |
24 |
25 |
28 |
29 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
43 |
44 |
47 |
48 |
49 |
50 |
51 |
--------------------------------------------------------------------------------