235 Commits
Author SHA1 Message Date
Yuxin Wu ca9fc96b98 Update README 2026-01-09 20:06:18 +08:00
CK ZhangandYuxin Wu 30eef32ae6 Implement reply quote UI for HTML export (#107)
* render: improve reply quote UI

* render: address PR review comments

* res: avoid masking errors in img decode
2026-01-09 20:03:21 +08:00
CK ZhangandGitHub 11d60a7487 Fix Python 3.13 compatibility (#106)
* compat: support Python 3.13 (imghdr removal)

* compat: drop unnecessary future annotations import
2026-01-01 19:17:47 +08:00
Chang Qian (Fred)andGitHub 928b92ade1 To make it compatible with user (not userdebug) builds, use adb shell su instead of adb root whenever possible (#102) 2025-09-12 23:11:41 -07:00
643bdd8984 Improve stability of WS connection with WXGF decoder app (#101)
* In case TYPE_LINK is missing a title, use url as title to avoid aborting

* Retry WS connection on broken pipe

* Update render.py

* Update wxgf.py

---------

Co-authored-by: Yuxin Wu <[email protected]>
2025-07-05 19:02:00 -07:00
Chang Qian (Fred)andGitHub a38c12fe4b Fix dump-audio (#103) 2025-07-05 18:51:15 -07:00
Yuxin Wu a0d362d9f6 Move frida.md 2025-02-03 10:37:35 -08:00
Yuxin Wu eedaa9c9e0 support downloading avatar 2025-01-30 14:30:04 -08:00
Yuxin Wu e1a1b0b946 remove ensure_unicode 2025-01-30 12:05:48 -08:00
Yuxin Wu b271469b94 support wxgf emoji 2025-01-30 12:07:31 -08:00
Yuxin Wu c782325cfa improve rendering of various types 2025-01-29 01:47:42 -08:00
Yuxin Wu 837a9ef2f7 update builtin emojis 2025-01-28 23:47:52 -08:00
Yuxin Wu a508e29003 update readme 2025-01-28 19:24:29 -08:00
Yuxin Wu 83bbb0a1fd add android app 2025-01-28 19:00:47 -08:00
Yuxin Wu 1e754f229a add wxgf client code 2025-01-28 06:11:40 -08:00
Yuxin Wu 6d9e4a00a9 fix utf8 decode error 2025-01-22 04:25:01 -08:00
Yuxin Wu 20708ef755 update 2025-01-05 17:03:38 +08:00
JakeandGitHub cb76a41edc Update README.md (#99)
old link doesn't work anymore.
2024-08-13 16:33:02 -07:00
Yuxin WuandGitHub 4e7593f3b2 Update README.md 2021-12-13 16:46:57 -08:00
Yuxin Wu 3e86880913 update docs 2021-04-19 01:22:36 -07:00
52fb966dc5 🌈style(view): fix body scroll (#84)
Co-authored-by: lqzh <[email protected]>
2021-04-13 15:58:53 -07:00
lqzhgoodandGitHub a841e342cb Update requirements.txt (#82)
add `numpy` module
2021-04-13 00:19:28 -07:00
Yuxin Wu 5b7d293b06 update docs about sfs/avatar.index 2021-02-02 11:00:48 -08:00
Yuxin Wu 541f6aa5ee update 2021-01-07 19:12:20 -08:00
Yuxin Wu b818b6e2aa catch decryption error (#72) 2020-10-03 04:38:19 -07:00
Yuxin Wu 6f86622c93 use tempfile for audio decoding 2020-07-17 12:26:25 -07:00
Kai JiaandGitHub f61b9d59b7 Fix a typo in README (#66) 2020-07-06 10:11:34 -07:00
Yuxin Wu 78fffc6d33 update readme 2020-07-02 17:00:46 -07:00
Yuxin Wu 8b5a28c39c update readme 2020-07-01 01:26:15 -07:00
Yuxin Wu f2ba97117c better error message 2020-06-30 23:31:56 -07:00
Yuxin Wu 7eb7c60a81 remove "internal_emoji" 2020-06-30 23:16:19 -07:00
Yuxin Wu b12cd93f0d decrypt local emoji 2020-06-30 23:09:46 -07:00
Yuxin Wu e314b9d815 check md5 of local emoji as well 2020-06-29 11:21:07 -07:00
Yuxin Wu e46378aa25 use encrypturl if cdnurl does not produce correct md5 2020-06-29 11:07:44 -07:00
Yuxin Wu 64e5e5eb3f aes-decrypt emoji from encrypturl 2020-06-28 02:57:14 -07:00
Yuxin Wu 1f1a28e605 check for two avatar storage 2020-06-28 00:33:07 -07:00
Yuxin Wu eeba4b7e6c fix old avatar storage 2020-06-28 00:12:39 -07:00
Yuxin Wu f6acd0b0bf move common/ into wechat/ 2020-06-27 20:34:05 -07:00
Yuxin Wu 973993723b support both chatid and nickname (fix #51) 2020-06-27 20:04:34 -07:00
Yuxin Wu 55784bd1bf avoid assertion for type 49 (close #52) 2020-06-27 19:56:28 -07:00
Yuxin Wu 7571334eca update readme 2020-06-27 19:45:42 -07:00
Yuxin Wu bfb2d0c914 add missing file 2020-06-27 19:32:21 -07:00
Yuxin Wu aa9a1c9129 compatibility with an old database file 2020-06-27 19:00:43 -07:00
Yuxin Wu 86224a8151 update 2020-06-27 17:34:52 -07:00
Yuxin Wu 1395351755 support latest smileys 2020-06-27 17:31:35 -07:00
Yuxin Wu 628bc024c8 move libchat to legacy 2020-06-27 16:34:35 -07:00
Yuxin Wu d6d2aaea55 handle emoji thumbnail 2020-06-27 16:32:07 -07:00
Yuxin Wu 4dc13a16f7 add support for video file 2020-06-27 15:54:03 -07:00
Yuxin Wu 95826392df rewrite with python3 2020-06-27 15:21:17 -07:00
Yuxin Wu 503a43c953 update 2020-06-27 02:21:57 -07:00
Yuxin Wu 0f3fd34b54 update 2020-06-27 01:49:45 -07:00
Yuxin Wu 0f97ecc9f0 rewrite decryption logic 2020-06-27 01:45:15 -07:00
Yuxin Wu a82bd281a7 sqlcipher compatibilty 2020-06-26 19:01:18 -07:00
Yuxin Wu 249b2cf229 update in parser 2020-06-26 16:53:20 -07:00
Yuxin Wu 7fc6632907 remove my emails 2018-08-26 01:21:45 -07:00
Yuxin Wu e5240137ee update docs 2017-11-29 04:04:22 -08:00
Yuxin Wu ed21c3b630 update docs 2017-11-29 04:02:49 -08:00
Yuxin Wu 8e68c59893 use emoji cover for failures 2017-11-29 03:55:09 -08:00
Yuxin Wu b82bcc7673 fix avatar rendering 2017-11-29 03:26:40 -08:00
Yuxin Wu 5893e7c6e6 update docs 2017-11-29 02:33:15 -08:00
futurewrgandYuxin Wu 20ae09848b Add support for new avatar storage (#50)
* Add support for new avatar storage,modify the README and android-interact.sh

* Show the nickname for chatroom, fix the IOError in avatar.py

* style change
2017-11-29 01:34:51 -08:00
Yuxin WuandGitHub 9e776ca38e fix dependencies (fix #49) 2017-10-14 23:55:42 -07:00
Yuxin Wu f8ede162c0 use userid instead of nickname as identifiers everywhere. (#39) 2017-03-02 17:46:30 +08:00
Yuxin Wu b2abfa7309 update README (#39) 2017-03-02 12:09:30 +08:00
Yuxin Wu 9f1ae9363d bug fix on chatroom msg handling 2017-02-09 13:24:37 +08:00
Yuxin Wu 613f856662 update readme 2016-10-12 14:03:10 -04:00
Yuxin Wu 4ee06766fd bug fix 2016-10-12 13:53:45 -04:00
Yuxin Wu 06217dbfb8 bugfix 2016-10-12 13:53:11 -04:00
Yuxin WuandGitHub 748cccb745 Update README.md 2016-10-11 12:11:07 -04:00
Yuxin Wu 54dc3156dd update readme 2016-10-10 01:19:59 -04:00
Yuxin Wu c4c20633ea update readme 2016-10-10 01:19:21 -04:00
Yuxin Wu 9a45dfaf35 bug fix 2016-09-06 02:01:32 -04:00
Jakukyo FrielandYuxin Wu b4d374cbe2 Refine README: (#31)
* Add commands to install dependencies on debian.

* README: mention `requests` in dependencies.

Thanks @h82k to point out this in #29

* README: fix broken link.

* Improve instructions on uncompress emoji.cache.

See #29

* README: remove unnecessary unpack command.

> unpacking emoji should not be necessary.
> I think things should work without running emoji-cache-tool.py.
> That tool is mainly for me to see what's in the cache.

-- @ppwwyyxx in #29

* README: fix `dump-html.py` command line.

See #29
2016-07-26 16:56:38 -07:00
Yuxin Wu 29115b104f see if readme looks better 2016-07-02 22:54:39 -07:00
Yuxin Wu 3731443254 avoid using cover, and use compressed emoji.cache 2016-06-18 23:37:04 -07:00
Yuxin Wu 11081b2ba8 script improvements 2016-06-18 22:24:20 -07:00
Yuxin Wu 49a4187b46 update readme 2016-06-18 10:16:41 -07:00
Yuxin Wu ac958c32db add help 2016-06-18 10:12:41 -07:00
Yuxin Wu 1f2f7560bc emoji cache downloadd 2016-06-18 10:04:53 -07:00
Yuxin Wu 91ac3126f8 support red envelope 2016-06-17 23:20:08 -07:00
Yuxin Wu 212caa37cf request emoji from cdnUrl 2016-06-17 23:13:39 -07:00
Yuxin Wu a8777b9c1b use static cover for emojis 2016-06-01 11:22:34 -07:00
Yuxin Wu 3c28406935 fix printing 2016-04-29 11:14:42 -04:00
Yuxin Wu cae58fb5ec Merge pull request #27 from myGod0rz/patch-1
Update plot-num-msg-by-time.py
2016-04-23 12:50:39 -04:00
myGod0rz 260ae7ed28 Update plot-num-msg-by-time.py
from wechat.util import ......
from common.textutil import ensure_unicode
2016-04-23 18:07:01 +08:00
Yuxin Wu e644246311 dump audio 2016-04-21 18:35:38 -04:00
Yuxin Wu 268544183d update script with argparse 2016-04-21 18:13:06 -04:00
Yuxin Wu 1135c81cc5 update donate button 2016-04-16 00:26:49 -04:00
Yuxin Wu 23a1c09797 Merge pull request #25 from xrf/readme
Update README.md
2016-03-18 22:02:54 -04:00
Fei Yuan 876b41e3eb Update README.md
Clarify a few things and improve the formatting.
2016-03-18 22:01:28 -04:00
Yuxin Wu 1e5f15f506 Merge pull request #24 from xrf/compatibility
Allow android-interact.sh to be run in another directory
2016-03-18 12:07:19 -04:00
Yuxin Wu 2a3d37dd98 Merge pull request #23 from xrf/iphonesubinfo
Fallback to 'service call' to obtain IMEI
2016-03-18 10:07:20 -04:00
Fei Yuan fd64ea52d7 Allow android-interact.sh to be run in another directory
In order to source compatibility.sh, the working directory must be
changed to the directory of the script.  But finding the script's
directory requires $REALPATH, which is defined in compatibility.sh.

To break the cycle, the script now makes use of Python's
os.path.realpath.
2016-03-18 03:08:10 -04:00
Fei Yuan 5eb6e71515 Fallback to 'service call' to obtain IMEI
Uses the solution from http://stackoverflow.com/a/32293148

Tested and works on Android 6.0.1 on Nexus 4.

Fixes #9.
2016-03-18 02:52:38 -04:00
Yuxin Wu fcde19dc23 update 2016-02-08 10:38:38 -05:00
Yuxin Wu f3956fa50a list chats 2016-01-21 00:53:53 -05:00
Yuxin Wu 1cfc8b707b update avatar parsing 2016-01-21 00:16:04 -05:00
Yuxin Wu 28c7d3d599 group message with proper handling of user name 2016-01-20 14:12:14 -05:00
Yuxin Wu 683bdc30d0 Update README.md 2016-01-14 19:43:29 -05:00
Yuxin Wu 85061e9bd5 Update README.md 2016-01-10 01:27:20 -05:00
Yuxin Wu 76962d0ac1 Delete DOES_IT_WORK.md 2016-01-10 01:26:44 -05:00
Yuxin Wu b0b8a3e867 Update DOES_IT_WORK.md 2016-01-10 01:20:04 -05:00
Yuxin Wu 3f80e56365 Update DOES_IT_WORK.md 2016-01-10 01:16:47 -05:00
Yuxin Wu 6ab40e92ff add does-it-work 2016-01-10 01:14:22 -05:00
Yuxin Wu 0875e4d97a use empty avatar in failure 2016-01-08 21:38:21 -05:00
ymy513 86619339a5 Welcome 2016-01-05 00:10:28 -05:00
ppwwyyxx 4d13d287d0 add gitattr 2015-11-21 22:04:49 -05:00
ppwwyyxx 42ee381c7e fix when contacts are deleted 2015-11-09 10:48:41 -05:00
ppwwyyxx 13e3d456e8 use newer version of sqlcipher 2015-11-03 15:17:56 -05:00
Yuxin Wu ffcfa9237a Merge pull request #15 from SwordYork/master
find correct avatar.block.xxxxx file (maybe)
2015-10-09 23:13:58 -04:00
Sword York b6066065ed find correct avatar.block.xxxxx file (maybe) 2015-10-10 11:01:19 +08:00
ppwwyyxx 62e66b8769 update README 2015-10-09 15:15:35 -04:00
ppwwyyxx 3f40bec3ef clean-up legacy code 2015-10-09 15:07:28 -04:00
ppwwyyxx 26115c48df update scripts for avatar code 2015-10-09 14:54:19 -04:00
Yuxin Wu 82240095b6 Merge pull request #14 from SwordYork/master
Avatar support for new version wechat
2015-10-09 13:36:49 -04:00
Sword York ab1ea56242 more detail usage 2015-10-09 15:16:50 +08:00
Sword York 4eae9cd7c1 fix bug, add avt_db support 2015-10-09 10:21:41 +08:00
Sword York c156a3142b read avatar from sfs/avatar.block.0000x 2015-10-08 18:16:01 +08:00
ppwwyyxx 5ca0e4e90e more logging in android-interact 2015-08-30 09:22:04 -04:00
ppwwyyxx 88003355c2 use root in adb 2015-08-27 20:24:08 -04:00
ppwwyyxx ce9e2550d0 fix donation 2015-08-23 11:19:46 -04:00
ppwwyyxx 5f176e3bd1 add donation 2015-08-23 11:04:14 -04:00
ppwwyyxx 7015793abe fix import bug 2015-08-23 10:27:10 -04:00
ppwwyyxx f27476fa1c add a chinese title 2015-06-25 21:20:43 +08:00
ppwwyyxx 022e439cbe move utils to common 2015-06-18 00:07:15 +08:00
ppwwyyxx f0dc8fb3af fix rename of a script 2015-06-17 10:10:26 +08:00
ppwwyyxx 79fbe7889f update readme 2015-06-17 10:07:18 +08:00
ppwwyyxx 3b16f40ec2 fix readme 2015-06-16 23:54:45 +08:00
ppwwyyxx d00c0e5cc1 add silk and corresponding code, for wechat 6.0+ 2015-06-16 23:52:27 +08:00
ppwwyyxx 9cc70cec79 make jquery loaded first 2015-06-16 23:47:53 +08:00
Yuxin Wu 7457e23829 Merge pull request #11 from ppwwyyxx/revert-7-html-archive-format
Revert "Changed the formatting of the generated HTML"
2015-06-16 23:46:26 +08:00
Yuxin Wu 25092daa46 Revert "Changed the formatting of the generated HTML" 2015-06-16 23:45:13 +08:00
ppwwyyxx e6208102e4 add silk parsing code 2015-06-16 23:17:43 +08:00
ppwwyyxx 15c8fd6168 rename scripts 2015-06-16 22:25:18 +08:00
ppwwyyxx b3ac340072 move sqlcipher to third-party 2015-06-16 22:20:39 +08:00
Yuxin Wu 85247692c6 Merge pull request #6 from trammel/docfix
Updated README.md to add links to rooted adb and additional required programs
2015-05-25 17:42:15 +08:00
Yuxin Wu ed851845fa Merge pull request #7 from trammel/html-archive-format
Changed the formatting of the generated HTML
2015-05-25 17:29:32 +08:00
ppwwyyxx c60296c6dd bug fix 2015-05-25 15:23:06 +08:00
ppwwyyxx f399643bb9 some small fix.. 2015-05-25 15:17:17 +08:00
Yuxin Wu c6044221d7 Merge pull request #8 from chaserhkj/chaserhkj
Use XML parsers to parse messages, fixes various bugs
2015-04-28 13:54:12 +08:00
KangJing Huang (Chaserhkj) 3358a89faf Use XML parsers to parse messages 2015-04-27 20:03:24 +08:00
Jonathon Padfield b3acacddfd Accidentally used spaces instead of tabs in markdown list item. 2015-04-27 16:03:40 +08:00
Yuxin Wu 06b7e631b3 Merge pull request #5 from trammel/unknown-contact
Added more meaningful error message when the user provides an unknown contact
2015-04-27 14:23:34 +08:00
Yuxin Wu 7348dabbb3 Merge pull request #4 from trammel/smiley-unicode
Fixed smiley unicode parsing to work on Python 2.7, Mac OSX
2015-04-27 14:04:34 +08:00
Jonathon Padfield 41feb7c180 Changed the formatting of the generated HTML, to not have a scrollable chat/style box, but instead a single page of text. It's more easily printable, and searchable. Also added full YYYY-MM-DD date times, because messages can go back years. 2015-04-27 13:29:56 +08:00
Jonathon Padfield 7492366d85 Added more meaningful error message when the user provides an unknown contact name. 2015-04-27 13:29:22 +08:00
Jonathon Padfield ca6334e48e Fixed smiley unicode parsing to work on Python 2.7, Mac OSX 2015-04-27 13:28:45 +08:00
Jonathon Padfield 8ae9a81283 Updated README.md to add links to rooted adb (required on some devices to pull files) and additional required programs 2015-04-27 13:28:00 +08:00
ppwwyyxx cdb0272770 add encryption in todo 2015-04-19 17:32:54 +08:00
ppwwyyxx 987d8bda78 some small change 2015-04-12 21:10:11 +08:00
ppwwyyxx 5cc0cefb2b msg get through sqlite 2015-03-28 00:09:12 +08:00
ppwwyyxx 99faea5637 msg to libchat seems to work 2015-03-25 23:15:22 +08:00
ppwwyyxx 4572cb045e rename 2015-03-25 17:47:31 +08:00
ppwwyyxx 9b3dbb2665 add libchat db 2015-03-25 16:44:12 +08:00
ppwwyyxx e7b41487db add libchat 2015-03-25 15:41:29 +08:00
Yuxin Wu b1dd50e08a Merge pull request #3 from chaserhkj/chaserhkj
Added a stat report script.
2015-03-08 10:54:39 +08:00
HuangKangJing 869bc15e1f Added a stat report script. 2015-03-07 21:22:57 +08:00
Yuxin Wu b85b515137 Merge pull request #2 from chaserhkj/chaserhkj
Fix 2 problems
2015-03-02 11:40:53 +08:00
HuangKangJing c8c64d9142 Image is in PIL.Image now on most platforms 2015-03-02 11:20:28 +08:00
HuangKangJing 9656bb3ffc dill should be added to dependencies 2015-03-02 11:19:59 +08:00
ppwwyyxx a60fa569a9 bug fix 2015-02-02 13:32:11 +08:00
Yuxin Wu 142caa80fc Merge pull request #1 from vuryleo/master
Add some bsd compatibility & darwin version of sqlcipher
2015-01-20 14:58:30 +08:00
Vury Leo 49ff512437 Add darwin version of sqlcipher v2.1.1 2015-01-20 12:03:44 +08:00
Vury Leo ca63c5a190 Add bsd compatibility 2015-01-19 14:35:40 +08:00
ppwwyyxx 7c5a4a3df8 add future 2015-01-12 00:19:11 +08:00
ppwwyyxx 651c5dda1c change order or example 2015-01-12 00:09:51 +08:00
ppwwyyxx 9c7ec015ab add example 2015-01-12 00:09:00 +08:00
ppwwyyxx 69fb2c5dc9 smaller size by avoid repeating image and unnecessary smiley css 2015-01-11 23:43:56 +08:00
Yuxin Wu 442a3fc6fc add license 2015-01-11 21:57:58 +08:00
ppwwyyxx 34a3a7accc add note about sqlcipher 2015-01-11 21:53:42 +08:00
ppwwyyxx 26e2881f66 fix dirname check 2015-01-09 22:43:24 +08:00
ppwwyyxx 0baca87c3f fix todos in readme 2015-01-09 22:32:11 +08:00
ppwwyyxx 7468f7db35 better logger, locate voice message 2015-01-09 22:29:01 +08:00
ppwwyyxx 8eea048660 not required 2015-01-09 12:11:23 +08:00
ppwwyyxx 6476338832 resource dir 2015-01-09 12:09:53 +08:00
ppwwyyxx 39dfcf677b add adb in readme 2015-01-09 12:08:22 +08:00
ppwwyyxx b35d96418b add automatic db decrypt 2015-01-09 12:06:09 +08:00
ppwwyyxx 7e28d5e221 change decrypted db name 2015-01-09 12:03:16 +08:00
ppwwyyxx cddca228b0 add screenshots 2015-01-09 11:57:12 +08:00
ppwwyyxx c2c9de8fcb speed up voice rendering 2015-01-08 00:53:16 +08:00
ppwwyyxx add87ef670 fix some efficient issue 2015-01-08 00:25:09 +08:00
ppwwyyxx 1a655c37e7 fix 1048625 2015-01-07 23:46:08 +08:00
ppwwyyxx 29cb4037e2 readme and prompt 2015-01-07 22:19:17 +08:00
ppwwyyxx c4b497145c add video TODO 2015-01-07 22:03:56 +08:00
ppwwyyxx a3f3cedf7a group chat 2015-01-07 22:02:53 +08:00
ppwwyyxx 555ba90930 update readme 2015-01-07 21:13:30 +08:00
ppwwyyxx f477db0c84 rename template.html 2015-01-03 00:24:52 +08:00
ppwwyyxx b892e519c1 refactor avatar css 2015-01-02 23:24:14 +08:00
ppwwyyxx 138ebf63ee fix bug in android script 2014-12-31 23:28:29 +08:00
ppwwyyxx 93b68015a7 some bug fix 2014-12-27 00:18:42 +08:00
ppwwyyxx 91c8b63875 32bit support 2014-12-26 00:17:23 +08:00
ppwwyyxx f35e4248f6 add pysox, 2x faster 2014-12-25 23:20:35 +08:00
ppwwyyxx c23caa9ad8 bug fix 2014-12-25 10:18:51 +08:00
ppwwyyxx 9cb981749a various bug fixes 2014-12-25 10:04:54 +08:00
ppwwyyxx 8c17349a2c fix readme 2014-12-24 23:25:27 +08:00
ppwwyyxx 9888506420 update readme 2014-12-24 22:54:22 +08:00
ppwwyyxx bcaa7dcae6 multiple html output 2014-12-23 00:03:40 +08:00
ppwwyyxx 7ee52e32f5 bug fix in time-slice 2014-12-22 23:07:36 +08:00
ppwwyyxx f03eb9b5bd add time in html 2014-12-22 22:49:33 +08:00
ppwwyyxx e32547421a fix missing internal emoji 2014-12-22 22:00:20 +08:00
ppwwyyxx b8f7a15213 minor fix 2014-12-22 16:53:23 +08:00
ppwwyyxx d03b353173 add internal emoji files 2014-12-22 09:57:39 +08:00
ppwwyyxx 937fa8b88f add internal emoji 2014-12-22 09:49:30 +08:00
ppwwyyxx e2d2f5166f minor fixes.. 2014-12-21 23:54:19 +08:00
ppwwyyxx 4065a9bbac some minor fixes 2014-12-21 23:35:54 +08:00
ppwwyyxx ea0204fad7 fix DOM parsing 2014-12-21 21:54:26 +08:00
ppwwyyxx 4dd5c3ac36 fix missing file 2014-12-21 21:23:44 +08:00
ppwwyyxx 0622f64f16 fix emoji size 2014-12-21 21:15:51 +08:00
ppwwyyxx c41bdf3815 emoji works 2014-12-20 20:30:46 +08:00
ppwwyyxx 0d219538a6 emoji -> smiley 2014-12-20 17:55:46 +08:00
ppwwyyxx 5b97d3d95f some bug fixes 2014-12-20 15:47:55 +08:00
ppwwyyxx 4bf6d00000 emoji done 2014-12-17 00:09:28 +08:00
ppwwyyxx 378ad73751 a lot of emoji file 2014-12-16 23:51:14 +08:00
ppwwyyxx f91316a9fa emoji works for only some format... 2014-12-15 22:40:42 +08:00
ppwwyyxx 4b847d0c3b add emoji parse 2014-12-14 01:57:44 +08:00
ppwwyyxx 0e89e304c3 bigimg fancybox work 2014-12-13 00:39:10 +08:00
ppwwyyxx 6ba264130c fix title hight 2014-12-12 23:42:29 +08:00
ppwwyyxx 74c58a3b9c add logger 2014-12-12 23:15:59 +08:00
ppwwyyxx 543c79dfc1 image thumbnail 2014-11-23 23:12:07 +08:00
ppwwyyxx 9c5b9afa12 voice works! 2014-11-23 18:04:22 +08:00
ppwwyyxx fb05d2722f voice works 2014-11-23 17:53:00 +08:00
ppwwyyxx 4006bd39df update readme 2014-11-23 16:45:10 +08:00
ppwwyyxx d614eaf454 avatar done 2014-11-23 16:38:04 +08:00
ppwwyyxx 5f39a09da3 update readme 2014-11-22 23:57:48 +08:00
ppwwyyxx 3fcfd43921 generate msg-only html 2014-11-22 23:52:50 +08:00
ppwwyyxx b15c0f0d79 change file name 2014-11-22 22:33:02 +08:00
ppwwyyxx 9793382814 add Render 2014-11-22 22:30:10 +08:00
ppwwyyxx b7d97d4cb2 add Res.py 2014-11-22 21:59:59 +08:00
ppwwyyxx 680ee0e962 video/links 2014-11-22 08:26:10 +08:00
ppwwyyxx 35dbf6fcda plt by hour 2014-11-21 15:57:44 +08:00
ppwwyyxx a65c9280b5 plot 2014-11-21 15:19:35 +08:00
ppwwyyxx 9b55b7a303 add todo 2014-11-21 14:41:19 +08:00
ppwwyyxx 436717da10 add readme and driver 2014-11-21 14:37:18 +08:00
ppwwyyxx 83040b8a8d msg can work 2014-11-21 14:09:09 +08:00
ppwwyyxx 68c0e92aac init 2014-11-21 13:09:01 +08:00
110 changed files with 3464 additions and 474 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
output/
resource
*.db
*.db.decrypted
*.db.decoded
*2015*
*2017*
2020*
+27 -44
View File
@@ -1,14 +1,17 @@
## Dump WeChat Messages from Android
## Render WeChat Messages from Android
## 导出安卓微信消息记录
## 安卓微信消息记录分析工具
WeChat, as the most popular mobile IM app in China, doesn't provide any methods to export structured message history.
WeChat, as the most popular mobile IM app in China, doesn't provide any methods to read its message history.
Customers are not able to analyze their own chat messages or interact with other data analysis tools.
We reverse-engineered the storage protocol of WeChat messages, and
provide this tool to decrypt and parse WeChat messages on a rooted android phone.
It can also render the messages into self-contained html files including voice messages, images, emojis, videos, etc.
We provide this tool that can parse WeChat messages from a rooted android phone.
This is necessary to provide interoperability between WeChat messages and other message analysis tools.
As examples, we provide sample scripts to obtain statistics of the message history and
render the messages into self-contained html files including voice messages, images, emojis, videos, etc.
Users can also write custom programs based on this tool to manage their chat messages.
The tool is last verified to work with latest version of wechat on 2025/01/01.
The tool is last verified to work with latest version of WeChat on 2025/01/01.
If the tool works for you, please take a moment to add your phone/OS to [the wiki](https://github.com/ppwwyyxx/wechat-dump/wiki).
## How to use:
@@ -16,51 +19,30 @@ If the tool works for you, please take a moment to add your phone/OS to [the wik
#### Dependencies:
+ adb and rooted android phone connected to a Linux/Mac OSX/Win10+Bash.
+ Python >= 3.8
+ [sqlcipher](https://github.com/sqlcipher/sqlcipher) >= 4.1
+ sox (command line tools)
+ Silk audio decoder (included; build it with `./third-party/compile_silk.sh`)
+ Other python dependencies: `pip install -r requirements.txt`.
#### Get Necessary Data:
1. Pull database file and (for older wechat versions) avatar index:
1. Pull database file and (for older WeChat versions) avatar index:
+ Automatic: `./android-interact.sh db`. It may use an incorrect userid.
+ Manual:
+ Figure out your `${userid}` by inspecting the contents of `/data/data/com.tencent.mm/MicroMsg` on the __root__ filesystem of the device.
It should be a 32-character-long name consisting of hexadecimal digits.
+ Get `/data/data/com.tencent.mm/MicroMsg/${userid}/EnMicroMsg.db` from the device.
2. Decrypt database file:
+ Automatic: `./decrypt-db.py decrypt --input EnMicroMsg.db`
+ Manual:
+ Get WeChat uin (an integer), possible ways are:
+ `./decrypt-db.py uin`, which looks for uin in `/data/data/com.tencent.mm/shared_prefs/`
+ Login to [web wechat](https://wx.qq.com), get wxuin=1234567 from `document.cookie`
+ Get your device id (a positive integer), possible ways are:
+ `./decrypt-db.py imei` implements some ways to find device id.
+ Call `*#06#` on your phone
+ Find IMEI in system settings
+ Decrypt database with combination of uin and device id:
```
./decrypt-db.py decrypt --input EnMicroMsg.db --imei <device id> --uin <uin>
```
NOTE: you may need to try different ways to get device id and find one that can decrypt the
database. Some phones may have multiple IMEIs, you may need to try them all.
See [#33](https://github.com/ppwwyyxx/wechat-dump/issues/33).
The command will dump decrypted database at `EnMicroMsg.db.decrypted`.
If the above decryption doesn't work, you can also try the [password cracker](https://github.com/chg-hou/EnMicroMsg.db-Password-Cracker)
to brute-force the key. The encryption key is not very strong.
3. Copy the WeChat user resource directory `/data/data/com.tencent.mm/MicroMsg/${userid}/{avatar,emoji,image2,sfs,video,voice2}` from the phone to the `resource` directory:
2. Decode `EnMicroMsg.db`. We do not provide instructions to do that.
3. Copy the unencrypted WeChat user resource directory `/data/data/com.tencent.mm/MicroMsg/${userid}/{avatar,emoji,image2,sfs,video,voice2}` from the phone to the `resource` directory:
+ `./android-interact.sh res`
+ Change `RES_DIR` in the script if the location of these directories is different on your phone.
For older version of wechat, the directory may be `/mnt/sdcard/tencent/MicroMsg/`
For older version of WeChat, the directory may be `/mnt/sdcard/tencent/MicroMsg/`
+ This can take a while. It can be faster to first archive it with `tar` with or without compression, and then copy the archive,
`busybox tar` is recommended as the Android system's `tar` may choke on long paths.
+ In the end, we need a `resource` directory with the following subdir: `avatar,emoji,image2,sfs,video,voice2`.
4. (Optional) Install and start a WXGF decoder server on an android device. Without this, certain WXGF images will not be rendered or will be rendered in low resolution.
See [WXGFDecoder](WXGFDecoder) for instructions.
4. (Optional) Download the emoji cache from [here](https://github.com/ppwwyyxx/wechat-dump/releases/download/0.1/emoji.cache.tar.bz2)
and decompress it under `wechat-dump`. This will avoid downloading too many emojis during rendering.
@@ -68,16 +50,16 @@ If the tool works for you, please take a moment to add your phone/OS to [the wik
tar xf emoji.cache.tar.bz2
#### Run:
+ Parse and dump text messages of __every__ chat (requires decrypted database):
+ Parse and dump text messages of __every__ chat (requires decoded database):
```
./dump-msg.py decrypted.db output_dir
./dump-msg.py decoded.db output_dir
```
+ List all chats (required decrypted database):
+ List all chats (required decoded database):
```
./list-chats.py decrypted.db
./list-chats.py decoded.db
```
+ Generate statistics report on text messages (requires `output_dir` from `./dump-msg.py`):
@@ -86,15 +68,14 @@ If the tool works for you, please take a moment to add your phone/OS to [the wik
./count-message.sh output_dir
```
+ Dump messages of one contact to html, containing voice messages, emojis, and images (requires decrypted database and `resource`):
+ Dump messages of one contact to html, containing voice messages, emojis, and images (requires decoded database and `resource`):
```
./dump-html.py "<contact_display_name>"
```
The output file is `output.html`.
Check `./dump-html.py -h` to use different paths.
* The output file is `output.html`. Check `./dump-html.py -h` to use different input/output paths.
* Add `--wxgf-server ws://xx.xx.xx.xx:xxxx` to use a WXGF decoder server.
### Examples:
Screenshots of generated html:
@@ -104,7 +85,9 @@ Screenshots of generated html:
See [here](http://ppwwyyxx.com/static/wechat/example.html) for an example html.
### TODO List (help needed!)
* __IMPORTANT__ Some emojis and chat images are stored in a proprietary "wxgf" format. We don't yet know how to decode this format.
* After chat history migration, some emojis in the `EmojiInfo` table don't have corresponding URLs but only a md5 -
they are not downloaded by WeChat until the message needs to be displayed. We don't know how to manually download these emojis.
* Decoding WXGF images using an android app is too complex. Looking for an easier way (e.g. qemu).
* Fix rare unhandled message types: > 10000 and < 0
* Better user experiences... see `grep 'TODO' wechat -R`
+11
View File
@@ -0,0 +1,11 @@
*.iml
.gradle
local.properties
.idea
app/release
.DS_Store
build
captures
.externalNativeBuild
.cxx
local.properties
+29
View File
@@ -0,0 +1,29 @@
# WXGF Decoder
This is an android app that decodes wechat's proprietary "WXGF/WXAM" image format.
## Build & Install
Open this project directory using android studio, and build against an ARMv8 device.
Pre-built APKs can be downloaded at [releases](https://github.com/ppwwyyxx/wechat-dump/releases).
Install the APK to your android phone.
Note that this app uses native libs, therefore requires an ARMv8 compatible device to run.
The native libs are extracted from the WeChat v7.0.22 APK downloaded from [here](https://www.apkmirror.com/apk/wechat-tencent/wechat/wechat-7-0-22-release/wechat-7-0-22-android-apk-download/)
## Use
<p align="center">
<img src="screenshot.jpg" alt="screenshot" width="400">
</p>
1. (Optional) Configure the port number if needed, or leave it to default.
2. Click "Start Server"
3. Use the server address `ws://xx.xx.xx.xx:xxxx` to decode
* Make sure this address is accessible from the computer that runs `wechat-dump`.
For example, connect them into the same Wifi.
4. Once a client is connected and decoding request is received, logs will be printed in the app.
+1
View File
@@ -0,0 +1 @@
build
+55
View File
@@ -0,0 +1,55 @@
import com.android.build.api.dsl.Packaging
plugins {
alias(libs.plugins.android.application)
}
android {
namespace = "com.ppwwyyxx.wxgfdecoder"
compileSdk = 35
defaultConfig {
applicationId = "com.ppwwyyxx.wxgfdecoder"
minSdk = 24
targetSdk = 35
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
signingConfig = signingConfigs.getByName("debug")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
buildFeatures {
viewBinding = true
}
packagingOptions {
pickFirst( "jniLibs/arm64-v8a/libvoipCodec.so")
}
}
dependencies {
implementation("org.java-websocket:Java-WebSocket:1.5.4")
implementation(libs.appcompat)
implementation(libs.material)
implementation(libs.constraintlayout)
implementation(libs.lifecycle.livedata.ktx)
implementation(libs.lifecycle.viewmodel.ktx)
implementation(libs.navigation.fragment)
implementation(libs.navigation.ui)
testImplementation(libs.junit)
androidTestImplementation(libs.ext.junit)
androidTestImplementation(libs.espresso.core)
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:extractNativeLibs="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.WXAMConverter"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,227 @@
package com.ppwwyyxx.wxgfdecoder;
import android.content.Context;
import android.os.Build;
import android.os.PowerManager;
import android.os.Bundle;
import android.widget.Button;
import android.widget.TextView;
import android.widget.EditText;
import com.tencent.mm.plugin.gif.MMWXGFJNI;
import androidx.appcompat.app.AppCompatActivity;
import com.ppwwyyxx.wxgfdecoder.databinding.ActivityMainBinding;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.util.Arrays;
import java.net.NetworkInterface;
import java.net.SocketException;
import java.util.Collections;
import java.util.List;
import org.java_websocket.WebSocket;
import org.java_websocket.handshake.ClientHandshake;
import org.java_websocket.server.WebSocketServer;
public class MainActivity extends AppCompatActivity {
private ActivityMainBinding binding;
private Button startButton;
private TextView hostnameTextView, portTextView, logTextView;
private EditText portEditText;
private WebSocketServer server;
static {
System.loadLibrary("wechatcommon");
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
binding = ActivityMainBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
// Keep screen on
getWindow().addFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
// If the app is running on Android 8.0 or higher, request for ignoring battery optimizations
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
PowerManager powerManager = (PowerManager) getSystemService(Context.POWER_SERVICE);
if (!powerManager.isIgnoringBatteryOptimizations(getPackageName())) {
// TODO: request for ignoring battery optimizations
}
}
MMWXGFJNI.initialize(getApplicationInfo().nativeLibraryDir);
// Set up UI
startButton = findViewById(R.id.start_server_button);
hostnameTextView = findViewById(R.id.text_view_hostname);
portTextView = findViewById(R.id.text_view_port);
portEditText = findViewById(R.id.edit_text_port);
logTextView = findViewById(R.id.log_text_area);
logTextView.setText("");
startButton.setOnClickListener(v -> {
try {
startWebSocketServer();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
// Run a unit test:
testWxgfDecoder();
}
private void startWebSocketServer() throws InterruptedException {
if (server != null) {
server.stop();
server = null;
}
int port;
try {
port = Integer.parseInt(portEditText.getText().toString());
} catch (NumberFormatException e) {
port = 8080;
}
try {
server = new WebSocketServer(new InetSocketAddress(port)) {
@Override
public void onOpen(WebSocket conn, ClientHandshake handshake) {
printLog("new connection from " + conn.getRemoteSocketAddress().getAddress().getHostAddress());
}
@Override
public void onClose(WebSocket conn, int code, String reason, boolean remote) {
printLog("closed " + conn.getRemoteSocketAddress() + " with exit code " + code + " additional info: " + reason);
}
@Override
public void onMessage( WebSocket conn, ByteBuffer message ) {
byte[] msg = message.array();
printLog("received binary message from " + conn.getRemoteSocketAddress() + ": size=" + msg.length
+ " -- " + Arrays.toString(Arrays.copyOfRange(msg, 0, 10)));
byte[] res = com.tencent.mm.plugin.gif.MMWXGFJNI.nativeWxam2PicBuf(msg);
if (res != null) {
printLog("After decoding: size=" + res.length + " -- " +
Arrays.toString(Arrays.copyOfRange(res, 0, 10)));
ByteBuffer response = ByteBuffer.wrap(res);
conn.send(response); // the response gif
} else {
printLog("Failed to decode.");
conn.send("FAILED".getBytes());
}
}
@Override
public void onMessage( WebSocket conn, String message) {
printLog("Unexpected! received text message from " + conn.getRemoteSocketAddress() + ": " + message);
}
@Override
public void onError(WebSocket conn, Exception ex) {
printLog("an error occurred on connection " + conn + ":" + ex);
}
@Override
public void onStart() {
try {
String hostName = getLocalIpAddress(true);
int port = server.getPort();
printLog("server started successfully at ws://" + hostName + ":" + port);
runOnUiThread(() -> { // update UI in UI thread
hostnameTextView.setText("Hostname: " + hostName);
portTextView.setText("Port: " + String.valueOf(port));
});
} catch (Exception e) {
e.printStackTrace();
}
}
};
server.start();
} catch (Exception e) {
e.printStackTrace();
}
}
private void printLog(String log) {
System.out.println(log);
runOnUiThread(() -> {
String currentLog = logTextView.getText().toString();
logTextView.setText(currentLog + "\n" + log);
});
}
public String getLocalIpAddress(boolean useIPv4) throws SocketException {
List<NetworkInterface> interfaces = Collections.list(NetworkInterface.getNetworkInterfaces());
for (NetworkInterface intf : interfaces) {
List<InetAddress> addrs = Collections.list(intf.getInetAddresses());
for (InetAddress addr : addrs) {
if (!addr.isLoopbackAddress()) {
String sAddr = addr.getHostAddress();
boolean isIPv4 = sAddr.indexOf(':')<0;
if (useIPv4) {
if (isIPv4)
return sAddr;
} else {
return sAddr;
}
}
} }
return "localhost";
}
// Methods below are for debugging the decoder:
private void testWxgfDecoder() {
byte[] wxgf = null;
try {
wxgf = readBinary(this);
} catch (IOException e) {
throw new RuntimeException(e);
}
System.out.println("Unit test: wxgf size=" + wxgf.length);
byte[] res = com.tencent.mm.plugin.gif.MMWXGFJNI.nativeWxam2PicBuf(wxgf);
System.out.println("Unit test: res size=" + res.length + " -- " + Arrays.toString(Arrays.copyOfRange(res, 0, 10)));
try {
writeFile(this, "test.jpg", res);
} catch (IOException e) {
throw new RuntimeException(e);
}
if (wxgf.length != 64367 || res.length != 705188) {
throw new RuntimeException("test failed");
}
}
public static byte[] readBinary(Context context) throws IOException {
InputStream inputStream = context.getResources().openRawResource(R.raw.test_wxgf); // R.raw.image refers to your image.jpg file
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
byte[] buffer = new byte[1024];
int len;
while ((len = inputStream.read(buffer)) != -1) {
byteArrayOutputStream.write(buffer, 0, len);
}
inputStream.close(); // Close the input stream
return byteArrayOutputStream.toByteArray();
}
public static void writeFile(Context context, String filename, byte[] data) throws IOException {
File file = new File(context.getExternalFilesDir(null), filename); // Use null for the type argument to get the root external files directory
System.out.println("Writing file to: " + file.getAbsolutePath());
FileOutputStream outputStream = new FileOutputStream(file);
outputStream.write(data);
outputStream.close();
}
}
@@ -0,0 +1,37 @@
package com.ppwwyyxx.wxgfdecoder.ui.dashboard;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider;
import com.ppwwyyxx.wxgfdecoder.databinding.FragmentDashboardBinding;
public class DashboardFragment extends Fragment {
private FragmentDashboardBinding binding;
public View onCreateView(@NonNull LayoutInflater inflater,
ViewGroup container, Bundle savedInstanceState) {
DashboardViewModel dashboardViewModel =
new ViewModelProvider(this).get(DashboardViewModel.class);
binding = FragmentDashboardBinding.inflate(inflater, container, false);
View root = binding.getRoot();
final TextView textView = binding.textDashboard;
dashboardViewModel.getText().observe(getViewLifecycleOwner(), textView::setText);
return root;
}
@Override
public void onDestroyView() {
super.onDestroyView();
binding = null;
}
}
@@ -0,0 +1,19 @@
package com.ppwwyyxx.wxgfdecoder.ui.dashboard;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.ViewModel;
public class DashboardViewModel extends ViewModel {
private final MutableLiveData<String> mText;
public DashboardViewModel() {
mText = new MutableLiveData<>();
mText.setValue("This is dashboard fragment");
}
public LiveData<String> getText() {
return mText;
}
}
@@ -0,0 +1,34 @@
package com.ppwwyyxx.wxgfdecoder.ui.home;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider;
import com.ppwwyyxx.wxgfdecoder.databinding.FragmentHomeBinding;
public class HomeFragment extends Fragment {
private FragmentHomeBinding binding;
public View onCreateView(@NonNull LayoutInflater inflater,
ViewGroup container, Bundle savedInstanceState) {
HomeViewModel homeViewModel =
new ViewModelProvider(this).get(HomeViewModel.class);
binding = FragmentHomeBinding.inflate(inflater, container, false);
View root = binding.getRoot();
return root;
}
@Override
public void onDestroyView() {
super.onDestroyView();
binding = null;
}
}
@@ -0,0 +1,19 @@
package com.ppwwyyxx.wxgfdecoder.ui.home;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.ViewModel;
public class HomeViewModel extends ViewModel {
private final MutableLiveData<String> mText;
public HomeViewModel() {
mText = new MutableLiveData<>();
mText.setValue("This is home fragment");
}
public LiveData<String> getText() {
return mText;
}
}
@@ -0,0 +1,37 @@
package com.ppwwyyxx.wxgfdecoder.ui.notifications;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider;
import com.ppwwyyxx.wxgfdecoder.databinding.FragmentNotificationsBinding;
public class NotificationsFragment extends Fragment {
private FragmentNotificationsBinding binding;
public View onCreateView(@NonNull LayoutInflater inflater,
ViewGroup container, Bundle savedInstanceState) {
NotificationsViewModel notificationsViewModel =
new ViewModelProvider(this).get(NotificationsViewModel.class);
binding = FragmentNotificationsBinding.inflate(inflater, container, false);
View root = binding.getRoot();
final TextView textView = binding.textNotifications;
notificationsViewModel.getText().observe(getViewLifecycleOwner(), textView::setText);
return root;
}
@Override
public void onDestroyView() {
super.onDestroyView();
binding = null;
}
}
@@ -0,0 +1,19 @@
package com.ppwwyyxx.wxgfdecoder.ui.notifications;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.ViewModel;
public class NotificationsViewModel extends ViewModel {
private final MutableLiveData<String> mText;
public NotificationsViewModel() {
mText = new MutableLiveData<>();
mText.setValue("This is notifications fragment");
}
public LiveData<String> getText() {
return mText;
}
}
@@ -0,0 +1,14 @@
package com.tencent.mm.plugin.fts.jni;
public class FTSJNIUtils {
public static native int countTokens(String str);
public static native int icuInitialize(String str);
public static native String icuTokenizer(String str);
public static native void nativeInitFts(long j, byte[] bArr);
public static native int stringCompareUtfBinary(String str, String str2);
}
@@ -0,0 +1,101 @@
//File:
package com.tencent.mm.plugin.gif;
import android.graphics.Bitmap;
import java.io.File;
/*
*import com.tencent.matrix.trace.core.AppMethodBeat;
*import com.tencent.mm.loader.p558j.C11743b;
*import com.tencent.mm.sdk.platformtools.C52656ad;
*/
/* renamed from: com.tencent.mm.plugin.gif.MMWXGFJNI */
public class MMWXGFJNI {
public static final int PIC_TYPE_JPG = 0;
public static final int PIC_TYPE_PNG = 1;
private static final String TAG = "MicroMsg.JNI.MMWXGF";
static int mECode;
public static boolean mIsInit;
public static native int nativeAV2Gif(String str, String str2);
public static native int nativeAV2Gif(byte[] bArr, byte[] bArr2);
public static native int nativeAddGifEncodeRgbaFrame(long j, int i, int i2, byte[] bArr, long j2);
public static native int nativeAddWxAMEncodeRgbaFrame(long j, int i, int i2, byte[] bArr, long j2);
public static native int nativeDecodeBufferFrame(long j, byte[] bArr, int i, Bitmap bitmap, int[] iArr);
public static native int nativeDecodeBufferHeader(long j, byte[] bArr, int i);
public static native byte[] nativeFinishGifEncode(long j);
public static native byte[] nativeFinishWxAMEncode(long j);
public static native int nativeGetOption(long j, byte[] bArr, int i, int[] iArr);
public static native int nativeInit(String str);
public static native long nativeInitGifEncode(int i, int i2, long j);
public static native long nativeInitWxAMDecoder();
public static native long nativeInitWxAMEncoder(int i, int i2, long j, int i3);
public static native boolean nativeIsWXGF(byte[] bArr, int i);
public static native int nativePic2Wxam(String str, String str2, int i, int i2);
public static native int nativePic2WxamWithWH(String str, String str2, int i, int i2, int i3, int i4);
public static native int nativePic2WxamWithWH(String str, byte[] bArr, int i, int i2, int i3, int i4, int i5, int i6, int i7);
public static native int nativeRewindBuffer(long j);
public static native int nativeUninit(long j);
public static native int nativeWxam2Pic(String str, String str2);
public static native byte[] nativeWxam2PicBuf(byte[] bArr);
public static native byte[] nativeWxamToGif(byte[] bArr);
public static void initialize(String nativeDir) {
// init wxgf decoder
String soFilePath = nativeDir + "/libvoipCodec.so";
File soFile = new File(soFilePath);
if (!soFile.exists())
throw new RuntimeException("missing so file " + soFilePath);
int initSuccess = nativeInit(soFilePath); // return -1 if missing so
if (initSuccess < 0)
throw new RuntimeException("nativeInit failed " + initSuccess);
mIsInit = true;
}
/* // Original code below:
*static {
* AppMethodBeat.m13231i(104681);
* mIsInit = false;
* mECode = -1;
* C52656ad.m88211i(TAG, "static MMWXGFJNI");
* if (!mIsInit) {
* String str = C11743b.ahY() + "lib/libvoipCodec.so";
* int nativeInit = nativeInit(str);
* mECode = nativeInit;
* mIsInit = nativeInit >= 0;
* C52656ad.m88212i(TAG, "native init MMWXGF mECode:%d result:%b :%s", Integer.valueOf(mECode), Boolean.valueOf(mIsInit), str);
* AppMethodBeat.m13232o(104681);
* return;
* }
* C52656ad.m88205d(TAG, "MMWXGF has init.");
* AppMethodBeat.m13232o(104681);
*}
*/
public static int getErrorCode() {
return mECode;
}
}
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M3,13h8L11,3L3,3v10zM3,21h8v-6L3,15v6zM13,21h8L21,11h-8v10zM13,3v6h8L21,3h-8z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M10,20v-6h4v6h5v-8h3L12,3 2,12h3v8z" />
</vector>
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>
@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.89,2 2,2zM18,16v-5c0,-3.07 -1.64,-5.64 -4.5,-6.32L13.5,4c0,-0.83 -0.67,-1.5 -1.5,-1.5s-1.5,0.67 -1.5,1.5v0.68C7.63,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2z" />
</vector>
@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="match_parent"
>
<fragment
android:id="@+id/nav_host_fragment_activity_main"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:defaultNavHost="true"
app:layout_constraintTop_toTopOf="parent"
app:navGraph="@navigation/mobile_navigation" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp"
app:layout_constraintTop_toTopOf="parent">
<Space
android:layout_width="match_parent"
android:layout_height="48dp" />
<EditText
android:id="@+id/edit_text_port"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Enter Port Number (default 8080)"
android:inputType="number" />
<TextView
android:id="@+id/text_view_hostname"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Hostname: " />
<TextView
android:id="@+id/text_view_port"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Port: " />
<Button
android:id="@+id/start_server_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Start Server" />
<ScrollView
android:layout_width="match_parent"
android:layout_height="400dp"
android:layout_weight="1">
<TextView
android:id="@+id/log_text_area"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:scrollbars="vertical" />
</ScrollView>
</LinearLayout>
<!-- <com.google.android.material.bottomnavigation.BottomNavigationView-->
<!-- android:id="@+id/nav_view"-->
<!-- android:layout_width="match_parent"-->
<!-- android:layout_height="wrap_content"-->
<!-- android:layout_marginStart="0dp"-->
<!-- android:layout_marginEnd="0dp"-->
<!-- android:layout_gravity="bottom"-->
<!-- android:background="?android:attr/windowBackground"-->
<!-- app:menu="@menu/bottom_nav_menu" />-->
</androidx.coordinatorlayout.widget.CoordinatorLayout>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.dashboard.DashboardFragment">
<TextView
android:id="@+id/text_dashboard"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:textAlignment="center"
android:textSize="20sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.home.HomeFragment">
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.notifications.NotificationsFragment">
<TextView
android:id="@+id/text_notifications"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:textAlignment="center"
android:textSize="20sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/navigation_home"
android:icon="@drawable/ic_home_black_24dp"
android:title="@string/title_home" />
<item
android:id="@+id/navigation_dashboard"
android:icon="@drawable/ic_dashboard_black_24dp"
android:title="@string/title_dashboard" />
<item
android:id="@+id/navigation_notifications"
android:icon="@drawable/ic_notifications_black_24dp"
android:title="@string/title_notifications" />
</menu>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/mobile_navigation"
app:startDestination="@+id/navigation_home">
<fragment
android:id="@+id/navigation_home"
android:name="com.ppwwyyxx.wxgfdecoder.ui.home.HomeFragment"
android:label="@string/title_home"
tools:layout="@layout/fragment_home" />
<fragment
android:id="@+id/navigation_dashboard"
android:name="com.ppwwyyxx.wxgfdecoder.ui.dashboard.DashboardFragment"
android:label="@string/title_dashboard"
tools:layout="@layout/fragment_dashboard" />
<fragment
android:id="@+id/navigation_notifications"
android:name="com.ppwwyyxx.wxgfdecoder.ui.notifications.NotificationsFragment"
android:label="@string/title_notifications"
tools:layout="@layout/fragment_notifications" />
</navigation>
Binary file not shown.
@@ -0,0 +1,16 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.WXAMConverter" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_200</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/black</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_200</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
</style>
</resources>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
@@ -0,0 +1,5 @@
<resources>
<!-- Default screen margins, per the Android Design guidelines. -->
<dimen name="activity_horizontal_margin">16dp</dimen>
<dimen name="activity_vertical_margin">16dp</dimen>
</resources>
@@ -0,0 +1,6 @@
<resources>
<string name="app_name">WXGF Decoder</string>
<string name="title_home">Home</string>
<string name="title_dashboard">Dashboard</string>
<string name="title_notifications">Notifications</string>
</resources>
@@ -0,0 +1,16 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.WXAMConverter" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_500</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/white</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_700</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
</style>
</resources>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older that API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>
+4
View File
@@ -0,0 +1,4 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
}
+21
View File
@@ -0,0 +1,21 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
+30
View File
@@ -0,0 +1,30 @@
[versions]
agp = "8.8.0"
junit = "4.13.2"
junitVersion = "1.1.5"
espressoCore = "3.5.1"
appcompat = "1.6.1"
material = "1.10.0"
constraintlayout = "2.1.4"
lifecycleLivedataKtx = "2.6.1"
lifecycleViewmodelKtx = "2.6.1"
navigationFragment = "2.6.0"
navigationUi = "2.6.0"
media3Common = "1.5.1"
[libraries]
junit = { group = "junit", name = "junit", version.ref = "junit" }
ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "lifecycleLivedataKtx" }
lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycleViewmodelKtx" }
navigation-fragment = { group = "androidx.navigation", name = "navigation-fragment", version.ref = "navigationFragment" }
navigation-ui = { group = "androidx.navigation", name = "navigation-ui", version.ref = "navigationUi" }
media3-common = { group = "androidx.media3", name = "media3-common", version.ref = "media3Common" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
Binary file not shown.
+6
View File
@@ -0,0 +1,6 @@
#Wed Jan 22 21:22:48 PST 2025
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"
+89
View File
@@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

+23
View File
@@ -0,0 +1,23 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "WXGFDecoder"
include(":app")
+28 -22
View File
@@ -7,18 +7,12 @@ cd "$PROG_DIR"
# Please check that your path is the same, since this might be different among devices
# RES_DIR="/mnt/sdcard/tencent/MicroMsg" # old version of wechat use this path.
RES_DIR="/data/data/com.tencent.mm"
MM_DIR="/data/data/com.tencent.mm"
echo "Starting rooted adb server..."
adb root
RES_DIR="/data/data/com.tencent.mm/MicroMsg"
if [[ $1 == "db" || $1 == "res" ]]; then
echo "Looking for user dir name..."
sleep 1 # sometimes adb complains: device not found
# look for dirname which looks like md5 (32 alpha-numeric chars)
userList=$(adb ls $RES_DIR | cut -f 4 -d ' ' | sed 's/[^0-9a-z]//g' \
| awk '{if (length() == 32) print}')
userList=$(adb shell su -c "ls $RES_DIR" | awk '{if (length() == 32) print}')
numUser=$(echo "$userList" | wc -l)
# choose the first user.
chooseUser=$(echo "$userList" | head -n1)
@@ -35,20 +29,21 @@ if [[ $1 == "db" || $1 == "res" ]]; then
echo "Pulling resources... "
for d in avatar image2 voice2 emoji video sfs; do
echo "Trying to download $RES_DIR/$chooseUser/$d with busybox ..."
adb shell "cd $RES_DIR/$chooseUser &&
busybox tar czf - $d 2>/dev/null | busybox base64" |
base64 -di | tar xzf -
adb shell su -c "busybox tar czf - $RES_DIR/$chooseUser/$d 2>/dev/null |
busybox base64" | base64 -di | tar xzf - --strip-components 5
[[ -d $d ]] && continue
echo "Trying to download $RES_DIR/$chooseUser/$d with tar & base64 ..."
adb shell "cd $RES_DIR/$chooseUser &&
tar czf - $d 2>/dev/null | base64" | base64 -di | tar xzf -
adb shell su -c "tar czf - $RES_DIR/$chooseUser/$d 2>/dev/null | base64" |
base64 -di | tar xzf - --strip-components 5
[[ -d $d ]] && continue
echo "Trying to download $RES_DIR/$chooseUser/$d with adb pull (slow) ..."
mkdir -p $d
(
cd $d || exit
adb root
sleep 1 # sometimes adb complains: device not found
adb pull "$RES_DIR/$chooseUser/$d"
)
@@ -60,18 +55,29 @@ if [[ $1 == "db" || $1 == "res" ]]; then
echo "Total size: $(du -sh | cut -f1)"
)
else
echo "Pulling database and avatar index file..."
adb pull $MM_DIR/MicroMsg/$chooseUser/EnMicroMsg.db
[[ -f EnMicroMsg.db ]] && \
echo "Database successfully downloaded to EnMicroMsg.db" || {
>&2 echo "Failed to pull database by adb!"
exit 1
for f in EnMicroMsg.db sfs/avatar.index; do
echo "Trying to download $RES_DIR/$chooseUser/$f with busybox ..."
adb shell su -c "busybox tar czf - $RES_DIR/$chooseUser/$f 2>/dev/null |
busybox base64" | base64 -di | tar xzf - --strip-components 5
[[ -f $f ]] && continue
echo "Trying to download $RES_DIR/$chooseUser/$f with tar & base64 ..."
adb shell su -c "tar czf - $RES_DIR/$chooseUser/$f 2>/dev/null | base64" |
base64 -di | tar xzf - --strip-components 5
[[ -f $f ]] && continue
echo "Trying to download $RES_DIR/$chooseUser/$f with adb pull..."
adb root
sleep 1
adb pull $RES_DIR/$chooseUser/$f
[[ -f $f ]] || {
echo "Failed to download $RES_DIR/$chooseUser/$f"
}
adb pull $MM_DIR/MicroMsg/$chooseUser/sfs/avatar.index
[[ -f avatar.index ]] && echo "Avatar index successfully downloaded to avatar.index"
done
echo "Database and avatar index file successfully downloaded"
fi
else
echo "Usage: $0 <res|db>"
exit 1
fi
-194
View File
@@ -1,194 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import os
import shlex
import sys
import re
import struct
import argparse
import logging
import javaobj
from pyquery import PyQuery
from pysqlcipher3 import dbapi2 as sqlite
from hashlib import md5
from wechat.common.procutil import subproc_succ, subproc_call
logger = logging.getLogger("wechat")
RES_DIR = "/mnt/sdcard/tencent/MicroMsg"
MM_DIR = "/data/data/com.tencent.mm"
def adb_command(command):
return subproc_succ("adb shell su -c " + shlex.quote(command))
def get_uin():
candidates = []
try:
uin = None
out = adb_command(f"cat {MM_DIR}/shared_prefs/system_config_prefs.xml")
for line in out.decode('utf-8').split("\n"):
if "default_uin" in line:
line = PyQuery(line)
uin = line.attr["value"]
break
uin = int(uin)
except Exception:
logger.warning("uin not found in system_config_prefs.xml")
else:
candidates.append(uin)
logger.info(f"found uin={uin} in system_config_prefs.xml")
try:
uin = None
out = adb_command(f"cat {MM_DIR}/shared_prefs/com.tencent.mm_preferences.xml")
for line in out.decode('utf-8').split("\n"):
if "last_login_uin" in line:
line = PyQuery(line)
uin = line.text()
break
uin = int(uin)
except Exception:
logger.warning("uin not found in com.tencent.mm_preferences.xml")
else:
candidates.append(uin)
logger.info(f"found uin={uin} in com.tencent.mm_preferences.xml")
try:
uin = None
out = adb_command(f"cat {MM_DIR}/shared_prefs/auth_info_key_prefs.xml")
for line in out.decode('utf-8').split("\n"):
if "auth_uin" in line:
line = PyQuery(line)
uin = line.attr["value"]
break
uin = int(uin)
except Exception:
logger.warning("uin not found in auth_info_key_prefs.xml")
else:
candidates.append(uin)
logger.info(f"found uin={uin} in auth_info_key_prefs.xml")
try:
out = adb_command(f"cat {MM_DIR}/MicroMsg/systemInfo.cfg")
uin = int(javaobj.loads(out).get(1, 0))
except:
logger.warning("default uin not found in systemInfo.cfg")
else:
candidates.append(uin)
logger.info(f"found uin={uin} in systemInfo.cfg")
candidates = list({str(x) if isinstance(x, int) else x
for x in candidates if x != 0})
logger.info(f"Possible uin: {candidates}")
return candidates
def get_imei():
candidates = []
class Parcel(object):
# https://gist.github.com/ktnr74/60ac7bcc2cd17b43f2cb
def __init__(self, text):
if text.startswith(b'Result: Parcel(') and text.endswith(b'\')'):
self.data = b''.join([struct.pack('<L', int(x, 16)) for x in re.findall(b'([0-9a-f]{8}) ', text)])
self.resultcode = self.get_int(0)
else:
raise Exception('Unexpected input!')
def get_int(self, offset=4):
return int(struct.unpack('<L', self.data[offset:offset+4])[0])
def get_utf16(self, offset=4):
return (self.data[offset + 4: offset+4+self.get_int(offset) * 2]).decode('utf-16')
out = adb_command(f"service call iphonesubinfo 1")
imei = Parcel(out.strip()).get_utf16()
logger.info(f"found imei={imei} from iphonesubinfo")
candidates.append(imei)
try:
out = adb_command(f"cat {MM_DIR}/MicroMsg/CompatibleInfo.cfg")
# https://gist.github.com/ChiChou/36556fd412a9e3216abecf06e084e4d9
jobj = javaobj.loads(out)
imei = jobj[258]
except:
logger.warning("imei not found in CompatibleInfo.cfg")
else:
candidates.append(imei)
logger.info(f"found imei={imei} in CompatibleInfo.cfg")
candidates.append("1234567890ABCDEF") # https://github.com/ppwwyyxx/wechat-dump/issues/70
logger.info(f"Possible imei: {candidates}")
return list(set(candidates))
def get_key(imei, uin):
"""
Args:
imei, uin: str
"""
if isinstance(uin, str):
uin = uin.encode('ascii')
if isinstance(imei, str):
imei = imei.encode('ascii')
a = md5(imei + uin)
return a.hexdigest()[:7]
def do_decrypt(input, output, key):
conn = sqlite.connect(input)
c = conn.cursor()
version_str = list(conn.execute("PRAGMA cipher_version"))[0][0]
version = tuple([int(x) for x in version_str.split(".")[:2]])
assert version >= (4, 1), "Sqlcipher>=4.1 is required"
c.execute("PRAGMA key = '" + key + "';")
# https://github.com/sqlcipher/sqlcipher/commit/e4b66d6cc8a2b7547a32ff2c3ac52f148eba3516
c.execute("PRAGMA cipher_compatibility = 1;")
try:
c.execute("ATTACH DATABASE '" + output + "' AS db KEY '';")
except Exception as e:
logger.error(f"Decryption failed: '{e}'")
os.unlink(output)
raise
logger.info(f"Decryption succeeded! Writing database to {output} ...")
c.execute("SELECT sqlcipher_export('db');" )
c.execute("DETACH DATABASE db;" )
c.close()
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument('task', choices=['uin', 'imei', 'decrypt'])
parser.add_argument('--imei', help='overwrite imei')
parser.add_argument('--uin', help='overwrite uin')
parser.add_argument('--input', help='encrypted EnMicroMsg.db')
args = parser.parse_args()
subproc_succ("adb root")
if args.task == 'uin':
uin = get_uin()
elif args.task == 'imei':
imei = get_imei()
elif args.task == 'decrypt':
uins = [args.uin] if args.uin else get_uin()
imeis = [args.imei] if args.imei else get_imei()
output_file = args.input + ".decrypted"
assert not os.path.isfile(output_file), f"Output {output_file} exists!"
for uin in uins:
for imei in imeis:
key = get_key(imei, uin)
logger.info(f"Trying key {key} ...")
try:
do_decrypt(args.input, output_file, key)
except Exception as error:
logger.warning(f"An unexpected error occurred.{error}")
pass
else:
logger.info(f"Database dumped to {output_file}")
sys.exit()
+2 -2
View File
@@ -13,7 +13,7 @@ def get_args():
parser = argparse.ArgumentParser()
parser.add_argument('name', help='name of contact')
parser.add_argument('--output', help='output mp3 dir', default='/tmp')
parser.add_argument('--db', default='decrypted.db', help='path to decrypted database')
parser.add_argument('--db', default='decoded.db', help='path to decoded database')
parser.add_argument('--res', default='resource', help='reseource directory')
args = parser.parse_args()
return args
@@ -21,7 +21,7 @@ def get_args():
if __name__ == '__main__':
args = get_args()
parser = WeChatDBParser(args.db)
res = Resource(parser, args.res, '')
res = Resource(parser, args.res)
try:
chatid = parser.get_chat_id(args.name)
+16 -9
View File
@@ -1,13 +1,12 @@
#!/usr/bin/env python3
# -*- coding: UTF-8 -*-
import os
import sys
import argparse
import logging
from datetime import datetime
from wechat.parser import WeChatDBParser
from wechat.res import Resource
from wechat.common.textutil import ensure_unicode
from wechat.render import HTMLRender
logger = logging.getLogger("wechat")
@@ -15,17 +14,20 @@ logger = logging.getLogger("wechat")
def get_args():
parser = argparse.ArgumentParser()
parser.add_argument('name', help='name of contact')
parser.add_argument('--output', help='output html file', default='output.html')
parser.add_argument('--db', default='decrypted.db', help='path to decrypted database')
parser.add_argument('--avt', default='avatar.index', help='path to avatar.index file that only exists in old version of wechat')
parser.add_argument('--res', default='resource', help='reseource directory')
parser.add_argument('--output', help='output html file, e.g. output.html', default='output.html')
parser.add_argument('--db', default='EnMicroMsg.db.decoded',
help='path to the decoded database, e.g. EnMicroMsg.db.decoded')
parser.add_argument('--res', default='resource', help='the resource directory')
parser.add_argument('--wxgf-server', help='address of the wxgf image decoder server')
parser.add_argument('--avt', default='avatar.index', help='path to avatar.index file that only exists in old version of wechat. Ignore for new version of wechat.')
parser.add_argument('--start', help='start time in format of YYYY-MM-DD HH:MM:SS',
type=datetime.fromisoformat)
args = parser.parse_args()
return args
if __name__ == '__main__':
args = get_args()
name = ensure_unicode(args.name)
output_file = args.output
parser = WeChatDBParser(args.db)
@@ -35,13 +37,18 @@ if __name__ == '__main__':
except KeyError:
sys.stderr.write(u"Valid Contacts: {}\n".format(
u'\n'.join(parser.all_chat_nicknames)))
sys.stderr.write(u"Couldn't find the chat {}.".format(name));
sys.stderr.write(u"Couldn't find the chat {}.".format(args.name));
sys.exit(1)
res = Resource(parser, args.res, args.avt)
res = Resource(parser, args.res,
wxgf_server=args.wxgf_server,
avt_db=args.avt)
msgs = parser.msgs_by_chat[chatid]
logger.info(f"Number of Messages for chatid {chatid}: {len(msgs)}")
assert len(msgs) > 0
if args.start is not None:
msgs = [msg for msg in msgs if msg.createTime > args.start]
logger.info(f"Number of Messages after {args.start}: {len(msgs)}")
render = HTMLRender(parser, res)
htmls = render.render_msgs(msgs)
+1 -1
View File
@@ -10,7 +10,7 @@ logger = logging.getLogger("wechat")
if __name__ == '__main__':
if len(sys.argv) != 3:
sys.exit("Usage: {0} <path to decrypted_database.db> <output_dir>".format(sys.argv[0]))
sys.exit("Usage: {0} <path to decoded_database.db> <output_dir>".format(sys.argv[0]))
db_file = sys.argv[1]
output_dir = sys.argv[2]
-37
View File
@@ -1,37 +0,0 @@
#!/bin/bash -e
# File: decrypt-db.sh
# Date: Tue Jun 16 22:23:13 2015 +0800
source compatibility.sh
MSGDB=$1
imei=$2
uin=$3
output=decrypted.db
if [[ -z "$1" || -z "$2" || -z "$3" ]]; then
echo "Usage: $0 <path to EnMicroMsg.db> <imei> <uin>"
exit 1
fi
if [[ -f $output ]]; then
echo -n "$output already exists. removed? (y/n)"
read r
[[ $r == "y" ]] && rm -v $output || exit 1
fi
KEY=$(echo -n "$imei$uin" | $MD5SUM | cut -b 1-7)
echo "KEY: $KEY"
echo "Dump decrypted database... "
# https://github.com/sqlcipher/sqlcipher/commit/e4b66d6cc8a2b7547a32ff2c3ac52f148eba3516
sqlcipher "$MSGDB" << EOF
PRAGMA key='$KEY';
PRAGMA cipher_compatibility = 1;
ATTACH DATABASE "$output" AS db KEY "";
SELECT sqlcipher_export("db");
DETACH DATABASE db;
EOF
echo "Database successfully dumped to $output"
+27 -27
View File
@@ -2,45 +2,45 @@
# -*- coding: UTF-8 -*-
from wechat.parser import WeChatDBParser
from wechat.common.textutil import ensure_unicode
from datetime import timedelta, datetime
import numpy as np
import matplotlib.pyplot as plt
import sys, os
import sys
if len(sys.argv) != 3:
sys.exit("Usage: {0} <path to decrypted_database.db> <name>".format(sys.argv[0]))
db_file = sys.argv[1]
name = ensure_unicode(sys.argv[2])
every_k_days = 2
if __name__ == '__main__':
if len(sys.argv) != 3:
sys.exit("Usage: {0} <path to decoded_database.db> <name>".format(sys.argv[0]))
parser = WeChatDBParser(db_file)
msgs = parser.msgs_by_chat[name]
times = [x.createTime for x in msgs]
start_time = times[0]
diffs = [(x - start_time).days for x in times]
max_day = diffs[-1]
db_file = sys.argv[1]
name = sys.argv[2]
every_k_days = 2
width = 20
numbers = range((max_day / width + 1) * width + 1)[::width]
labels = [(start_time + timedelta(x)).strftime("%m/%d") for x in numbers]
plt.xticks(numbers, labels)
plt.xlabel("Date")
plt.ylabel("Number of msgs in k days")
plt.hist(diffs, bins=max_day / every_k_days)
plt.show()
parser = WeChatDBParser(db_file)
msgs = parser.msgs_by_chat[name]
times = [x.createTime for x in msgs]
start_time = times[0]
diffs = [(x - start_time).days for x in times]
max_day = diffs[-1]
width = 20
numbers = range((max_day / width + 1) * width + 1)[::width]
labels = [(start_time + timedelta(x)).strftime("%m/%d") for x in numbers]
plt.xticks(numbers, labels)
plt.xlabel("Date")
plt.ylabel("Number of msgs in k days")
plt.hist(diffs, bins=max_day / every_k_days)
plt.show()
# statistics by hour
# I'm in a different time zone in this period:
#TZ_DELTA = {(datetime(2014, 7, 13), datetime(2014, 10, 1)): -15}
#def real_hour(x):
#for k, v in TZ_DELTA.items():
#if x > k[0] and x < k[1]:
#print x
#return (x.hour + v + 24) % 24
#return x.hour
#for k, v in TZ_DELTA.items():
#if x > k[0] and x < k[1]:
#print x
#return (x.hour + v + 24) % 24
#return x.hour
#hours = [real_hour(x) for x in times]
#plt.ylabel("Number of msgs")
#plt.xlabel("Hour in a day")
+2
View File
@@ -6,3 +6,5 @@ requests
pysqlcipher3>=1.0.3
csscompressor
numpy
ipython
websocket-client
+1 -1
View File
@@ -21,7 +21,7 @@ def set_level_color(lvl, color):
logger.addHandler(handler)
set_level_color(logging.INFO, '\033[1;32m')
set_level_color(logging.WARN, '\033[1;31m')
set_level_color(logging.WARN, '\033[1;33m')
set_level_color(logging.ERROR, '\033[1;31m')
if __name__ == '__main__':
+12 -4
View File
@@ -10,7 +10,7 @@ import logging
import sqlite3
logger = logging.getLogger(__name__)
from .common.textutil import ensure_unicode, md5
from .common.textutil import md5
def _filename_priority(s):
@@ -49,7 +49,7 @@ class AvatarReader(object):
except Exception:
pass
def get_avatar_from_avtdir(self, avtid):
def get_avatar_from_avtdir(self, avtid) -> Image.Image | None:
dir1, dir2 = avtid[:2], avtid[2:4]
candidates = glob.glob(os.path.join(self.avt_dir, dir1, dir2, f"*{avtid}*"))
candidates = sorted(set(candidates), key=_filename_priority, reverse=True)
@@ -72,7 +72,6 @@ class AvatarReader(object):
""" username: `username` field in db.rcontact"""
if not self._use_avt:
return None
username = ensure_unicode(username)
avtid = md5(username.encode('utf-8'))
if self.avt_db is not None:
@@ -84,7 +83,16 @@ class AvatarReader(object):
ret = self.get_avatar_from_avtdir(avtid)
if ret is not None:
return ret
logger.warning("Avatar for {} not found anywhere.".format(username))
logger.warning("Avatar file for {} not found.".format(username))
def save_avatar_to_avtdir(self, username: str, im: Image.Image):
"""Save a downloaded avatar to avtdir so it can be reused next time."""
avtid = md5(username.encode('utf-8'))
dir1, dir2 = avtid[:2], avtid[2:4]
fname = os.path.join(self.avt_dir, dir1, dir2, f"user_{avtid}.png")
os.makedirs(os.path.dirname(fname), exist_ok=True)
logger.info(f"Caching downloaded avatar for {username} to {fname}.")
im.save(fname, 'PNG')
def read_img_from_block(self, filename, pos, size):
file_idx = pos >> 32
+51
View File
@@ -0,0 +1,51 @@
import io
from pathlib import Path
from PIL import Image
try:
import imghdr as _imghdr
except ModuleNotFoundError: # Python 3.13+
_imghdr = None
def what(file: str | Path | None = None, h: bytes | None = None) -> str | None:
"""
Compatibility wrapper for the removed stdlib `imghdr` module (Python 3.13+).
Behaves like `imghdr.what(file, h)` and returns a lowercase format string
like "jpeg", "png", "gif", or None if unknown.
"""
if _imghdr is not None:
return _imghdr.what(file, h)
if h is not None:
return _what_from_bytes(h)
if file is None:
return None
try:
with Image.open(str(file)) as im:
fmt = im.format
except Exception:
return None
return fmt.lower() if fmt else None
def _what_from_bytes(data: bytes) -> str | None:
if data.startswith(b"\xff\xd8\xff"):
return "jpeg"
if data.startswith(b"\x89PNG\r\n\x1a\n"):
return "png"
if data[:6] in (b"GIF87a", b"GIF89a"):
return "gif"
if data.startswith(b"BM"):
return "bmp"
if len(data) >= 12 and data.startswith(b"RIFF") and data[8:12] == b"WEBP":
return "webp"
if data.startswith(b"II*\x00") or data.startswith(b"MM\x00*"):
return "tiff"
try:
with Image.open(io.BytesIO(data)) as im:
fmt = im.format
except Exception:
return None
return fmt.lower() if fmt else None
+1 -9
View File
@@ -3,13 +3,6 @@
import hashlib
import base64
def ensure_unicode(s):
if type(s) == str:
return s
elif type(s) == bytes:
return s.decode('utf-8')
raise TypeError(f"type of string is {type(s)}")
def md5(s):
m = hashlib.md5()
@@ -25,6 +18,5 @@ def get_file_md5(fname):
return md5(f.read())
def safe_filename(fname):
filename = ensure_unicode(fname)
return "".join(
[c for c in filename if c.isalpha() or c.isdigit() or c==' ']).rstrip()
[c for c in fname if c.isalpha() or c.isdigit() or c ==' ']).rstrip()
+27 -19
View File
@@ -1,16 +1,16 @@
import os
from pathlib import Path
import logging
import tempfile
import io
import requests
import base64
import imghdr
from PIL import Image
import pickle
from Crypto.Cipher import AES
from .wxgf import WxgfAndroidDecoder, is_wxgf_buffer
from .parser import WeChatDBParser
from .common.imgutil import what as img_what
from .common.textutil import md5 as get_md5_hex, get_file_b64, get_file_md5
@@ -23,21 +23,20 @@ def _get_aes_key(md5):
# ascii representation of the first half of md5 is used as aes key
assert len(md5) == 32
return md5[:16].encode('ascii')
# ret = ""
# for ch in md5[:16]:
# ret += format(ord(ch), 'x')
# return ret
class EmojiReader:
def __init__(self,
resource_dir: str,
parser: WeChatDBParser,
*,
wxgf_decoder: WxgfAndroidDecoder,
cache_file: str=None):
"""
Args:
resource_dir: path to resource/
parser: Database parser
wxgf_decoder: Wxgf image decoder
cache_file: a cache file to store emoji downloaded from URLs.
default to a emoji.cache file under wechat-dump.
"""
@@ -48,6 +47,7 @@ class EmojiReader:
# mapping from md5 to the (cdnurl, encrypturl, aeskey)
# columns in EmojiInfo table.
self.cache_file = cache_file or DEFAULT_EMOJI_CACHE
self.wxgf_decoder = wxgf_decoder
# cache stores md5 -> (base64str, format)
if os.path.isfile(self.cache_file):
@@ -124,27 +124,32 @@ class EmojiReader:
candidates = []
def get_data_no_fallback(fname):
if imghdr.what(fname):
if img_what(fname):
data_md5 = get_file_md5(fname)
if data_md5 == md5:
return get_file_b64(fname), imghdr.what(fname)
return get_file_b64(fname), img_what(fname)
try:
content = self._decrypt_emoji(fname)
content = self._decode_emoji(fname)
data_md5 = get_md5_hex(content)
if data_md5 != md5:
if content.startswith(b"wxgf"):
raise ValueError("Unsupported mysterious image format: wxgf")
raise ValueError("Decrypted data mismatch md5!")
if is_wxgf_buffer(content):
content = self.wxgf_decoder.decode_with_cache(fname, content)
if content is None:
if not self.wxgf_decoder.has_server():
logger.warning("wxgf decoder server is not provided. Cannot decode wxgf emojis.")
raise ValueError("Failed to decode wxgf file.")
else:
raise ValueError("Decoded data mismatch md5!")
im = Image.open(io.BytesIO(content))
return (base64.b64encode(content).decode('ascii'), im.format.lower())
except Exception as e:
logger.error(f"Error decrypting emoji {fname} : {str(e)}")
logger.error(f"Error decoding emoji {fname} : {str(e)}")
def get_data_fallback(fname):
if not imghdr.what(fname):
if not img_what(fname):
return # fallback files are not encrypted
return get_file_b64(fname), imghdr.what(fname)
return get_file_b64(fname), img_what(fname)
get_data_func = get_data_fallback if allow_fallback else get_data_no_fallback
results = [(x, get_data_func(x)) for x in candidates]
@@ -154,7 +159,7 @@ class EmojiReader:
return results[0][1]
return (None, None)
def _decrypt_emoji(self, fname):
def _decode_emoji(self, fname):
cipher = AES.new(self.encryption_key, AES.MODE_ECB)
with open(fname, 'rb') as f:
head = f.read(1024)
@@ -183,12 +188,15 @@ class EmojiReader:
try:
logger.info("Requesting encrypted emoji {} from {} ...".format(md5, encrypturl))
buf = requests.get(encrypturl).content
if buf == b'':
logger.error(f"Failed to download emoji {md5}")
return None, None
aeskey = bytes.fromhex(aeskey)
cipher = AES.new(aeskey, AES.MODE_CBC, iv=aeskey)
decrypted_buf = cipher.decrypt(buf)
decoded_buf = cipher.decrypt(buf)
im = Image.open(io.BytesIO(decrypted_buf))
ret = (base64.b64encode(decrypted_buf).decode('ascii'), im.format.lower())
im = Image.open(io.BytesIO(decoded_buf))
ret = (base64.b64encode(decoded_buf).decode('ascii'), im.format.lower())
self._cache_add(md5, ret)
return ret
except Exception:
+111 -12
View File
@@ -15,19 +15,21 @@ TYPE_REDENVELOPE = 436207665
TYPE_MONEY_TRANSFER = 419430449 # 微信转账
TYPE_LOCATION_SHARING = -1879048186
TYPE_REPLY = 822083633 # 回复的消息.
TYPE_FILE = 1090519089
TYPE_QQMUSIC = 1040187441
TYPE_APP_MSG = 16777265
_KNOWN_TYPES = [eval(k) for k in dir() if k.startswith('TYPE_')]
_KNOWN_TYPES = tuple([eval(k) for k in dir() if k.startswith('TYPE_')])
import re
import json
import io
import html
from pyquery import PyQuery
import xml.etree.ElementTree as ET
import logging
logger = logging.getLogger(__name__)
from .common.textutil import ensure_unicode
class WeChatMsg(object):
@@ -40,12 +42,13 @@ class WeChatMsg(object):
def __init__(self, values):
for k, v in values.items():
setattr(self, k, v)
if self.type not in _KNOWN_TYPES:
logger.warn("Unhandled message type: {}".format(self.type))
# only to supress repeated warning:
_KNOWN_TYPES.append(self.type)
self.known_type = self.type in _KNOWN_TYPES
def msg_str(self):
if self.type == TYPE_IMG:
return "Image"
elif self.type == TYPE_SPEAK:
return "Voice"
if self.type == TYPE_LOCATION:
try:
pq = PyQuery(self.content_xml_ready, parser='xml')
@@ -66,7 +69,7 @@ class WeChatMsg(object):
title = pq('title').text()
if title: # may not be correct
return "FILE:{}".format(title)
return "NOT IMPLEMENTED: " + self.content_xml_ready
return "Link"
return "URL:{}".format(url)
elif self.type == TYPE_NAMECARD:
pq = PyQuery(self.content_xml_ready, parser='xml')
@@ -90,7 +93,13 @@ class WeChatMsg(object):
return "LOCATION SHARING"
elif self.type == TYPE_EMOJI:
# TODO add emoji name
if self.content.lstrip().startswith("<"):
return "Emoji"
if not self.content:
return "Emoji"
return self.content
elif self.type == TYPE_CUSTOM_EMOJI:
return "Emoji"
elif self.type == TYPE_REDENVELOPE:
data_to_parse = io.BytesIO(self.content.encode('utf-8'))
try:
@@ -113,13 +122,98 @@ class WeChatMsg(object):
return "[Money Transfer]"
elif self.type == TYPE_REPLY:
pq = PyQuery(self.content_xml_ready)
msg = pq('title').text()
titles = pq('title')
if len(titles) == 0:
return self.content_xml_ready
msg = titles[0].text
# TODO parse reply.
return msg
elif self.type == TYPE_FILE:
pq = PyQuery(self.content_xml_ready)
titles = pq('title')
if len(titles) == 0:
return self.content_xml_ready
return "FILE:" + titles[0].text
elif self.type == TYPE_QQMUSIC:
pq = PyQuery(self.content_xml_ready)
title = pq('title')[0].text
singer = pq('des')[0].text
url = html.unescape(pq('url')[0].text)
return json.dumps(dict(
title=title, singer=singer, url=url
))
else:
# TODO replace smiley with text
return self.content
def reply_info(self):
"""Parse TYPE_REPLY payload.
Returns: {title, ref_name, ref_content, ref_type, ref_svrid}.
"""
if self.type != TYPE_REPLY:
return None
def _one_line(text: str, *, max_len: int) -> str:
text = re.sub(r"\s+", " ", (text or "")).strip()
if len(text) > max_len:
return text[: max_len - 1] + ""
return text
xml = self.content_xml_ready
idx = xml.find("<msg")
if idx != -1:
xml = xml[idx:]
try:
pq = PyQuery(xml, parser="xml")
except Exception:
return None
title = html.unescape(pq("appmsg > title").text() or pq("title").eq(0).text() or "")
ref_name_raw = pq("refermsg displayname").text() or pq("refermsg fromusr").text() or ""
ref_name = _one_line(html.unescape(ref_name_raw), max_len=80)
ref_content_raw = pq("refermsg content").text() or ""
ref_svrid_i = None
ref_svrid = pq("refermsg svrid").text() or pq("refermsg svrId").text()
if ref_svrid:
try:
ref_svrid_i = int(ref_svrid)
except Exception:
ref_svrid_i = None
ref_type_i = None
ref_type = pq("refermsg type").text()
if ref_type:
try:
ref_type_i = int(ref_type)
except Exception:
ref_type_i = None
ref_content_fallback = html.unescape(ref_content_raw or "")
if ref_type_i is None:
ref_content = ref_content_fallback
else:
try:
ref_content = WeChatMsg({"type": ref_type_i, "content": ref_content_fallback}).msg_str()
except Exception:
ref_content = ref_content_fallback
ref_content = _one_line(ref_content, max_len=200)
if not title and not ref_name and not ref_content:
return None
return {
"title": title.strip(),
"ref_name": ref_name,
"ref_content": ref_content,
"ref_type": ref_type_i,
"ref_svrid": ref_svrid_i,
}
@property
def content_xml_ready(self):
# remove xml headers to avoid possible errors it may create
@@ -132,13 +226,19 @@ class WeChatMsg(object):
self.type,
self.talker_nickname if not self.isSend else 'me',
self.createTime,
ensure_unicode(self.msg_str()))
self.msg_str())
if self.imgPath:
ret = "{}|img:{}".format(ensure_unicode(ret.strip()), self.imgPath)
ret = "{}|img:{}".format(ret.strip(), self.imgPath)
return ret
else:
return ret
def __eq__(self, r):
return self.createTime == r.createTime and \
self.talker == r.talker and \
self.isSend == r.isSend
# imgPath might change after migration.
def __lt__(self, r):
return self.createTime < r.createTime
@@ -158,4 +258,3 @@ class WeChatMsg(object):
if not emoji:
return None
return emoji.attrs['productid']
+44 -10
View File
@@ -1,14 +1,13 @@
# -*- coding: UTF-8 -*-
import sqlite3
from collections import defaultdict
from collections import defaultdict, Counter
import itertools
from datetime import datetime
import logging
logger = logging.getLogger(__name__)
from .msg import WeChatMsg, TYPE_SYSTEM
from .common.textutil import ensure_unicode
""" tables in concern:
emojiinfo
@@ -17,22 +16,28 @@ addr_upload2
chatroom
message
rcontact
img_flag
"""
class WeChatDBParser(object):
FIELDS = ["msgSvrId","type","isSend","createTime","talker","content","imgPath"]
def __init__(self, db_fname):
""" db_fname: a decrypted EnMicroMsg.db"""
""" db_fname: a decoded EnMicroMsg.db"""
self.db_fname = db_fname
self.db_conn = sqlite3.connect(self.db_fname)
self.db_conn_bytes = sqlite3.connect(self.db_fname)
# https://stackoverflow.com/questions/22751363/sqlite3-operationalerror-could-not-decode-to-utf-8-column
self.db_conn_bytes.text_factory = lambda b: b
self.cc = self.db_conn.cursor()
self.contacts = {} # username -> nickname
self.contacts_rev = defaultdict(list)
self.msgs_by_chat = defaultdict(list)
self.emoji_groups = {}
self.emoji_info = {}
self.emoji_encryption_key = None
self.avatar_urls = {}
self._parse()
def _parse_contact(self):
@@ -43,9 +48,9 @@ SELECT username,conRemark,nickname FROM rcontact
for row in contacts:
username, remark, nickname = row
if remark:
self.contacts[username] = ensure_unicode(remark)
self.contacts[username] = remark
else:
self.contacts[username] = ensure_unicode(nickname)
self.contacts[username] = nickname
for k, v in self.contacts.items():
self.contacts_rev[v].append(k)
@@ -53,10 +58,11 @@ SELECT username,conRemark,nickname FROM rcontact
def _parse_msg(self):
msgs_tot_cnt = 0
db_msgs = self.cc.execute(
db_msgs = self.db_conn_bytes.cursor().execute(
"""
SELECT {} FROM message
""".format(','.join(WeChatDBParser.FIELDS)))
unknown_type_cnt = Counter()
for row in db_msgs:
values = self._parse_msg_row(row)
if not values:
@@ -65,6 +71,9 @@ SELECT {} FROM message
# TODO keep system message?
if not WeChatMsg.filter_type(msg.type):
self.msgs_by_chat[msg.chat].append(msg)
if not msg.known_type:
unknown_type_cnt[msg.type] += 1
logger.warning("[Parser] Unhandled messages (type->cnt): {}".format(unknown_type_cnt))
for k, v in self.msgs_by_chat.items():
self.msgs_by_chat[k] = sorted(v, key=lambda x: x.createTime)
@@ -75,9 +84,14 @@ SELECT {} FROM message
userinfo_q = self.cc.execute(""" SELECT id, value FROM userinfo """)
userinfo = dict(userinfo_q)
self.username = userinfo.get(2, None)
if self.username is None:
nickname = userinfo.get(4, None)
if nickname is not None:
self.username = self.contacts_rev.get(nickname, [None])[0]
if self.username is None:
logger.error("Cannot find username in userinfo table!")
self.username = input("Please enter your username:")
assert isinstance(self.username, str), self.username
logger.info("Your username is: {}".format(self.username))
def _parse_imginfo(self):
@@ -114,13 +128,22 @@ SELECT {} FROM message
if cdnUrl or encrypturl:
self.emoji_info[md5] = (catalog, cdnUrl, encrypturl, aeskey)
def _parse_img_flag(self):
"""Parse the img_flag table which stores avatar for each id."""
query = self.cc.execute(
""" SELECT username, reserved1 FROM img_flag """)
for row in query:
username, url = row
if url:
self.avatar_urls[username] = url
def _parse(self):
self._parse_userinfo()
self._parse_contact()
self._parse_userinfo() # depend on self.contacts
self._parse_msg()
self._parse_imginfo()
self._parse_emoji()
self._parse_img_flag()
def get_emoji_encryption_key(self):
# obtain local encryption key in a special entry in the database
@@ -134,13 +157,24 @@ SELECT {} FROM message
# process the values in a row
def _parse_msg_row(self, row):
""" parse a record of message into my format"""
"""Parse a record of message into my format.
Note that message are read in binary format.
"""
values = dict(zip(WeChatDBParser.FIELDS, row))
values['createTime'] = datetime.fromtimestamp(values['createTime']/ 1000)
if values['content']:
values['content'] = ensure_unicode(values['content'])
try:
values['content'] = values['content'].decode()
except:
logger.warning(f"Invalid byte sequence in message content (type={values['type']}, createTime={values['createTime']})")
values['content'] = 'FAILED TO DECODE'
else:
values['content'] = ''
values['createTime'] = datetime.fromtimestamp(values['createTime']/ 1000)
values['talker'] = values['talker'].decode()
if values['imgPath']:
values['imgPath'] = values['imgPath'].decode()
values['chat'] = values['talker']
try:
if values['chat'].endswith('@chatroom'):
+134 -29
View File
@@ -2,7 +2,8 @@
# -*- coding: UTF-8 -*-
import os
import base64
from collections import Counter
from functools import lru_cache
import glob
from pyquery import PyQuery
import logging
@@ -20,7 +21,7 @@ except ImportError:
css_compress = lambda x: x
from .msg import *
from .common.textutil import ensure_unicode, get_file_b64
from .common.textutil import get_file_b64
from .common.progress import ProgressReporter
from .common.timer import timing
from .smiley import SmileyProvider
@@ -32,17 +33,30 @@ TEMPLATES_FILES = {TYPE_MSG: "TP_MSG",
TYPE_EMOJI: "TP_EMOJI",
TYPE_CUSTOM_EMOJI: "TP_EMOJI",
TYPE_LINK: "TP_MSG",
TYPE_VIDEO_FILE: "TP_VIDEO_FILE"
TYPE_REPLY: "TP_REPLY",
TYPE_VIDEO_FILE: "TP_VIDEO_FILE",
TYPE_QQMUSIC: "TP_QQMUSIC",
}
TEMPLATES = {
k: open(os.path.join(STATIC_PATH, '{}.html'.format(v))).read()
for k, v in TEMPLATES_FILES.items()
}
@lru_cache()
def get_template(name: str | int) -> str | None:
"""Return the html template given a file name or msg type."""
if isinstance(name, int):
name = TEMPLATES_FILES.get(name, None)
if name is None:
return None
html_path = os.path.join(STATIC_PATH, f"{name}.html")
with open(html_path) as f:
return f.read()
class HTMLRender(object):
def __init__(self, parser, res=None):
self.html = ensure_unicode(open(HTML_FILE).read())
self.time_html = open(TIME_HTML_FILE).read()
with open(HTML_FILE) as f:
self.html = f.read()
with open(TIME_HTML_FILE) as f:
self.time_html = f.read()
self.parser = parser
self.res = res
assert self.res is not None, \
@@ -53,8 +67,8 @@ class HTMLRender(object):
self.css_string = [] # css to add
for css in css_files:
logger.info("Loading {}".format(os.path.basename(css)))
css = ensure_unicode((open(css).read()))
self.css_string.append(css)
with open(css) as f:
self.css_string.append(f.read())
js_files = glob.glob(os.path.join(LIB_PATH, 'static/*.js'))
# to load jquery before other js
@@ -62,8 +76,10 @@ class HTMLRender(object):
self.js_string = []
for js in js_files:
logger.info("Loading {}".format(os.path.basename(js)))
js = ensure_unicode(open(js).read())
self.js_string.append(js)
with open(js) as f:
self.js_string.append(f.read())
self.unknown_type_cnt = Counter()
@property
def all_css(self):
@@ -90,24 +106,29 @@ class HTMLRender(object):
return self.final_js
#@timing(total=True)
def render_msg(self, msg):
def render_msg(self, msg: WeChatMsg):
""" render a message, return the html block"""
# TODO for chatroom, add nickname on avatar
sender = u'you ' + msg.talker if not msg.isSend else 'me'
format_dict = {'sender_label': sender,
'time': msg.createTime }
if not msg.known_type:
self.unknown_type_cnt[msg.type] += 1
if(not msg.isSend and msg.is_chatroom()):
format_dict['nickname'] = '>\n <pre align=\'left\'>'+msg.talker_nickname+'</pre'
else:
format_dict['nickname'] = ' '
def fallback():
template = TEMPLATES[TYPE_MSG]
template = get_template(TYPE_MSG)
content = msg.msg_str()
format_dict['content'] = self.smiley.replace_smileycode(content)
return template.format(**format_dict)
content = self.smiley.replace_smileycode(content)
if not msg.known_type:
# Show raw (usually xml) content if unknown.
content = html.escape(content)
return template.format(content=content, **format_dict)
template = TEMPLATES.get(msg.type)
template = get_template(msg.type)
if msg.type == TYPE_SPEAK:
audio_str, duration = self.res.get_voice_mp3(msg.imgPath)
format_dict['voice_duration'] = duration
@@ -128,6 +149,80 @@ class HTMLRender(object):
# TODO do not show fancybox when no bigimg found
format_dict['img'] = (img, 'jpeg')
return template.format(**format_dict)
elif msg.type == TYPE_QQMUSIC:
jobj = json.loads(msg.msg_str())
content = f"{jobj['title']} - {jobj['singer']}"
if msg.imgPath is not None:
# imgPath was original THUMBNAIL_DIRPATH://th_xxxxxxxxx
imgpath = msg.imgPath.split('_')[-1]
img = self.res.get_img([imgpath])
format_dict['img'] = (img, 'jpeg')
else:
template = get_template("TP_QQMUSIC_NOIMG")
return template.format(url=jobj['url'], content=content, **format_dict)
elif msg.type == TYPE_REPLY:
info = msg.reply_info()
if not info:
return fallback()
def _escape_fmt(s: str) -> str:
return s.replace("{", "{{").replace("}", "}}")
title = info.get("title") or ""
reply_to = info.get("ref_name") or "unknown"
reply_quote = info.get("ref_content") or ""
ref_svrid = info.get("ref_svrid")
if not title and not reply_quote:
return fallback()
format_dict["content"] = _escape_fmt(self.smiley.replace_smileycode(title))
format_dict["reply_to"] = _escape_fmt(reply_to)
reply_thumb_html = ""
ref_msg = getattr(self, "_msg_by_svrid", {}).get(ref_svrid) if ref_svrid is not None else None
if ref_msg is not None:
try:
if ref_msg.type == TYPE_IMG and ref_msg.imgPath:
imgpath = ref_msg.imgPath.split("_")[-1]
bigimgpath = self.parser.imginfo.get(ref_msg.msgSvrId)
fnames = [k for k in [imgpath, bigimgpath] if k]
b64 = self.res.get_img_thumb(fnames, max_size=64)
if b64:
reply_thumb_html = f'<img class="replyThumb" src="data:image/jpeg;base64,{b64}" />'
elif ref_msg.type in (TYPE_VIDEO_FILE, TYPE_WX_VIDEO) and ref_msg.imgPath:
b64 = self.res.get_video_thumb(ref_msg.imgPath, max_size=64)
if b64:
reply_thumb_html = f'<img class="replyThumb" src="data:image/jpeg;base64,{b64}" />'
elif ref_msg.type in (TYPE_EMOJI, TYPE_CUSTOM_EMOJI):
if "emoticonmd5" in ref_msg.content:
pq = PyQuery(ref_msg.content)
md5 = pq("emoticonmd5").text()
else:
md5 = ref_msg.imgPath
if md5:
emoji_img, fmt = self.res.get_emoji_by_md5(md5)
if emoji_img and fmt:
fmt = fmt.lower()
if fmt == "jpg":
fmt = "jpeg"
reply_thumb_html = (
f'<img class="replyThumb replyThumbEmoji" '
f'src="data:image/{fmt};base64,{emoji_img}" />'
)
except Exception:
logger.exception("Failed to render reply thumbnail (%s).", ref_svrid)
if reply_thumb_html:
reply_quote_html = reply_thumb_html
else:
quote_text = self.smiley.replace_smileycode(reply_quote)
reply_quote_html = f'<span class="replyText">{quote_text}</span>'
format_dict["reply_quote_html"] = _escape_fmt(reply_quote_html)
template = template or get_template(TYPE_MSG)
return template.format(**format_dict)
elif msg.type == TYPE_EMOJI or msg.type == TYPE_CUSTOM_EMOJI:
if 'emoticonmd5' in msg.content:
pq = PyQuery(msg.content)
@@ -145,16 +240,25 @@ class HTMLRender(object):
import IPython as IP; IP.embed()
return template.format(**format_dict)
elif msg.type == TYPE_LINK:
content = msg.msg_str()
# TODO show a short link with long href, if link too long
if content.startswith(u'URL:'):
url = content[4:]
content = u'URL:<a target="_blank" href="{0}">{0}</a>'.format(url)
pq = PyQuery(msg.content_xml_ready)
url = pq('url').text()
if url:
try:
title = pq('title')[0].text
except Exception as e:
logger.warning('No title found in LINK message: ' + str(e))
title = url
content = '<a target="_blank" href="{0}">{1}</a>'.format(url, title)
format_dict['content'] = content
return template.format(**format_dict)
elif msg.type == TYPE_VIDEO_FILE:
video = self.res.get_video(msg.imgPath)
if video.endswith(".mp4"):
if video is None:
logger.warning(f"Cannot find video {msg.imgPath} ({msg.createTime})")
# fallback
format_dict['content'] = f"VIDEO FILE {msg.imgPath}"
return get_template(TYPE_MSG).format(**format_dict)
elif video.endswith(".mp4"):
video_str = get_file_b64(video)
format_dict["video_str"] = video_str
return template.format(**format_dict)
@@ -162,10 +266,7 @@ class HTMLRender(object):
# only has thumbnail
image_str = get_file_b64(video)
format_dict["img"] = (image_str, 'jpeg')
return TEMPLATES[TYPE_IMG].format(**format_dict)
# fallback
format_dict['content'] = f"VIDEO FILE {msg.imgPath}"
return TEMPLATES_FILES[TYPE_MSG].format(**format_dict)
return get_template(TYPE_IMG).format(**format_dict)
elif msg.type == TYPE_WX_VIDEO:
# TODO: fetch video from resource
return fallback()
@@ -197,7 +298,8 @@ class HTMLRender(object):
)
def prepare_avatar_css(self, talkers):
avatar_tpl= ensure_unicode(open(FRIEND_AVATAR_CSS_FILE).read())
with open(FRIEND_AVATAR_CSS_FILE) as f:
avatar_tpl = f.read()
my_avatar = self.res.get_avatar(self.parser.username)
css = avatar_tpl.format(name='me', avatar=my_avatar)
@@ -208,6 +310,8 @@ class HTMLRender(object):
def render_msgs(self, msgs):
""" render msgs of one chat, return a list of html"""
chatid = msgs[0].chat
self._msg_by_svrid = {m.msgSvrId: m for m in self.parser.msgs_by_chat.get(chatid, msgs)}
if msgs[0].is_chatroom():
talkers = set([m.talker for m in msgs])
else:
@@ -224,6 +328,7 @@ class HTMLRender(object):
slice_by_size = MessageSlicerBySize().slice(msgs)
ret = [self._render_partial_msgs(s) for s in slice_by_size]
self.prgs.finish()
logger.warning("[HTMLRenderer] Unhandled messages (type->cnt): {}".format(self.unknown_type_cnt))
return ret
if __name__ == '__main__':
+95 -34
View File
@@ -1,23 +1,23 @@
# -*- coding: UTF-8 -*-
import glob
import os
import re
from PIL import Image
import requests
import time
import io
import base64
import logging
logger = logging.getLogger(__name__)
import imghdr
from multiprocessing import Pool
import atexit
from .emoji import EmojiReader
from .avatar import AvatarReader
from .common.imgutil import what as img_what
from .common.textutil import md5 as get_md5_hex, get_file_b64
from .common.timer import timing
from .msg import TYPE_SPEAK
from .audio import parse_wechat_audio_file
from .wxgf import WxgfAndroidDecoder, is_wxgf_file
LIB_PATH = os.path.dirname(os.path.abspath(__file__))
VOICE_DIRNAME = 'voice2'
@@ -26,13 +26,19 @@ EMOJI_DIRNAME = 'emoji'
VIDEO_DIRNAME = 'video'
JPEG_QUALITY = 50
THUMB_JPEG_QUALITY = 35
class Resource(object):
""" multimedia resources in chat"""
def __init__(self, parser, res_dir, avt_db):
""" Multimedia resources parser."""
def __init__(self, parser,
res_dir: str,
*,
wxgf_server: str | None = None,
avt_db: str | None = None):
"""
Args:
res_dir: path to the resource directory
wxgf_server: "hostname:port" that points to the wxgf converter android app
avt_db: "avatar.index" file that only exists in old versions of wechat
"""
def check(subdir):
@@ -47,7 +53,8 @@ class Resource(object):
self.voice_dir = os.path.join(res_dir, VOICE_DIRNAME)
self.video_dir = os.path.join(res_dir, VIDEO_DIRNAME)
self.avt_reader = AvatarReader(res_dir, avt_db)
self.emoji_reader = EmojiReader(res_dir, self.parser)
self.wxgf_decoder = WxgfAndroidDecoder(wxgf_server)
self.emoji_reader = EmojiReader(res_dir, self.parser, wxgf_decoder=self.wxgf_decoder)
def _get_voice_filename(self, imgpath):
fname = get_md5_hex(imgpath.encode('ascii'))
@@ -78,12 +85,24 @@ class Resource(object):
self.voice_cache = [pool.apply_async(parse_wechat_audio_file,
(self._get_voice_filename(k),)) for k in voice_paths]
def get_avatar(self, username):
def get_avatar(self, username) -> str:
""" return base64 unicode string"""
im = self.avt_reader.get_avatar(username)
if im is None:
logger.warning(f"Cannot find avatar for {username}.")
return ""
# Try downloading the avatar directly.
avatar_url = self.parser.avatar_urls.get(username)
if avatar_url is None:
return ""
logger.info(f"Requesting avatar of {username} from {avatar_url} ...")
try:
r = requests.get(avatar_url).content
im = Image.open(io.BytesIO(r))
except Exception:
logger.exception(f"Failed to fetch avatar of {username}.")
return ""
else:
self.avt_reader.save_avatar_to_avtdir(username, im)
buf = io.BytesIO()
try:
im.save(buf, 'JPEG', quality=JPEG_QUALITY)
@@ -142,40 +161,82 @@ class Resource(object):
"""
fnames = [k for k in fnames if k] # filter out empty string
big_file, small_file = self._get_img_file(fnames)
def get_jpg_b64(img_file):
if not img_file:
return None
if not img_file.endswith('jpg') and \
imghdr.what(img_file) != 'jpeg':
try:
im = Image.open(open(img_file, 'rb'))
except:
return None
buf = io.BytesIO()
im.convert('RGB').save(buf, 'JPEG', quality=JPEG_QUALITY)
return base64.b64encode(buf.getvalue()).decode('ascii')
with open(img_file, 'rb') as f:
if f.read(4) == b'wxgf':
logger.warning(f"Don't know how to decode wxgf image {img_file}")
return None
return get_file_b64(img_file)
big_file = get_jpg_b64(big_file)
big_file = self._img_file_to_jpg_b64(big_file)
if big_file:
return big_file
return get_jpg_b64(small_file)
return self._img_file_to_jpg_b64(small_file)
def _img_file_to_jpg_b64(self, img_file: str, *, max_size: int | None = None, quality: int = JPEG_QUALITY) -> str | None:
if not img_file:
return None
# True jpeg. Simplest case. Avoid re-compressing.
if max_size is None and img_file.endswith('jpg') and img_what(img_file) == 'jpeg':
return get_file_b64(img_file)
if is_wxgf_file(img_file):
start = time.time()
buf = self.wxgf_decoder.decode_with_cache(img_file, None)
if buf is None:
if not self.wxgf_decoder.has_server():
logger.warning("wxgf decoder server is not provided. Cannot decode wxgf images. Please follow instructions to create wxgf decoder server if these images need to be decoded.")
else:
logger.error("Failed to decode wxgf file: {}".format(img_file))
return None
else:
elapsed = time.time() - start
if elapsed > 0.01 and self.wxgf_decoder.has_server():
logger.info(f"Decoded {img_file} in {elapsed:.2f} seconds")
else:
with open(img_file, "rb") as f:
buf = f.read()
# If we don't need resize/convert and it's already jpeg, avoid re-compressing.
if max_size is None and img_what(file=None, h=buf) == 'jpeg':
return base64.b64encode(buf).decode('ascii')
try:
im = Image.open(io.BytesIO(buf))
except Exception:
return None
try:
im = im.convert("RGB")
if max_size:
im.thumbnail((max_size, max_size))
bufio = io.BytesIO()
im.save(bufio, "JPEG", quality=quality)
return base64.b64encode(bufio.getvalue()).decode("ascii")
except Exception:
return None
def get_img_thumb(self, fnames, *, max_size: int = 64) -> str | None:
"""Return a small JPEG thumbnail (b64) for an image message."""
fnames = [k for k in fnames if k]
big_file, small_file = self._get_img_file(fnames)
return (
self._img_file_to_jpg_b64(small_file, max_size=max_size, quality=THUMB_JPEG_QUALITY)
or self._img_file_to_jpg_b64(big_file, max_size=max_size, quality=THUMB_JPEG_QUALITY)
)
def get_video_thumb(self, videoid: str, *, max_size: int = 64) -> str | None:
"""Return a small JPEG thumbnail (b64) for a video message, if available."""
if not videoid:
return None
video_thumbnail_file = os.path.join(self.video_dir, videoid + ".jpg")
if not os.path.exists(video_thumbnail_file):
return None
return self._img_file_to_jpg_b64(video_thumbnail_file, max_size=max_size, quality=THUMB_JPEG_QUALITY)
def get_emoji_by_md5(self, md5):
""" Returns: (b64 encoded img string, format) """
return self.emoji_reader.get_emoji(md5)
def get_video(self, videoid):
def get_video(self, videoid) -> str | None:
video_file = os.path.join(self.video_dir, videoid + ".mp4")
video_thumbnail_file = os.path.join(self.video_dir, videoid + ".jpg")
if os.path.exists(video_file):
return video_file
elif os.path.exists(video_thumbnail_file):
return video_thumbnail_file
logger.warning(f"Cannot find video {videoid}")
return ""
return None
+19 -9
View File
@@ -2,10 +2,9 @@
# -*- coding: UTF-8 -*-
import os
import functools
import re
import re
import json
import struct
from .common.textutil import get_file_b64
@@ -40,10 +39,20 @@ TEMPLATE = """.{name} {{
background-size: 24px 24px;
}}"""
def _css_class_name(s):
s = s.replace("/", "_")
s = s.replace(".", "_")
return "smiley_" + s
def _css_class_name(name: str) -> str:
"""
Sanitizes a string to be a valid CSS class name by replacing invalid characters with underscores.
"""
# CSS class names must start with a letter or underscore, followed by letters, digits, hyphens, or underscores
name = re.sub(r'[^a-zA-Z0-9_-]', '_', name)
# Ensure the first character is a valid start character (letter or underscore)
if not name[0].isalpha() and name[0] != '_':
name = '_' + name
return "smiley_" + name
class SmileyProvider(object):
def __init__(self, html_replace=True):
@@ -76,7 +85,6 @@ class SmileyProvider(object):
if k in msg:
msg = msg.replace(k, self.gen_replace_elem(v))
return msg
return msg
def gen_used_smiley_css(self):
ret = HEAD
@@ -88,6 +96,8 @@ class SmileyProvider(object):
if __name__ == '__main__':
smiley = SmileyProvider()
msg = u"[挥手]哈哈呵呵hihi\U0001f684\u2728\u0001 /::<\ue415"
msg = u"[天啊]哈哈呵呵hihi\U0001f684\u2728\u0001 /::<\ue415"
msg = smiley.replace_smileycode(msg)
smiley.gen_used_smiley_css()
print(msg)
print()
print(smiley.gen_used_smiley_css())
+18
View File
@@ -0,0 +1,18 @@
<div class="chatItem {sender_label}">
<div class="chatItemContent">
<span class="avatar"></span>
<div class="cloud cloudText">
<div class="cloudPannel" title="{time}" {nickname}>
<div class="cloudBody">
<div class="cloudContent">
<pre style="white-space:pre-wrap">
QQ Music:<a href="{url}" target="_blank">{content}</a> </pre>
<span class="img_wrap">
<img class="imageBorder" src="data:image/{img[1]};base64,{img[0]}"/>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
+15
View File
@@ -0,0 +1,15 @@
<div class="chatItem {sender_label}">
<div class="chatItemContent">
<span class="avatar"></span>
<div class="cloud cloudText">
<div class="cloudPannel" title="{time}" {nickname}>
<div class="cloudBody">
<div class="cloudContent">
<pre style="white-space:pre-wrap">
QQ Music:<a href="{url}" target="_blank">{content}</a> </pre>
</div>
</div>
</div>
</div>
</div>
</div>
+20
View File
@@ -0,0 +1,20 @@
<div class="chatItem {sender_label}">
<div class="chatItemContent">
<span class="avatar"></span>
<div class="cloud cloudText">
<div class="cloudPannel" title="{time}" {nickname}>
<div class="cloudBody">
<div class="cloudContent">
<pre style="white-space:pre-wrap">{content}</pre>
</div>
</div>
</div>
<div class="replyWrap">
<div class="replyQuote">
<span class="replyName">{reply_to}:</span>
{reply_quote_html}
</div>
</div>
</div>
</div>
</div>
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

+1
View File
@@ -0,0 +1 @@
Copied from /data/data/com.tencent.mm/files/public/emoji/newemoji

Some files were not shown because too many files have changed in this diff Show More