├── .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 |