Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca9fc96b98 | ||
|
|
30eef32ae6 | ||
|
|
11d60a7487 | ||
|
|
928b92ade1 | ||
|
|
643bdd8984 | ||
|
|
a38c12fe4b | ||
|
|
a0d362d9f6 | ||
|
|
eedaa9c9e0 | ||
|
|
e1a1b0b946 | ||
|
|
b271469b94 | ||
|
|
c782325cfa | ||
|
|
837a9ef2f7 | ||
|
|
a508e29003 | ||
|
|
83bbb0a1fd | ||
|
|
1e754f229a | ||
|
|
6d9e4a00a9 | ||
|
|
20708ef755 | ||
|
|
cb76a41edc | ||
|
|
4e7593f3b2 | ||
|
|
3e86880913 | ||
|
|
52fb966dc5 | ||
|
|
a841e342cb | ||
|
|
5b7d293b06 | ||
|
|
541f6aa5ee | ||
|
|
b818b6e2aa | ||
|
|
6f86622c93 | ||
|
|
f61b9d59b7 | ||
|
|
78fffc6d33 | ||
|
|
8b5a28c39c | ||
|
|
f2ba97117c | ||
|
|
7eb7c60a81 | ||
|
|
b12cd93f0d | ||
|
|
e314b9d815 | ||
|
|
e46378aa25 | ||
|
|
64e5e5eb3f | ||
|
|
1f1a28e605 | ||
|
|
eeba4b7e6c | ||
|
|
f6acd0b0bf | ||
|
|
973993723b | ||
|
|
55784bd1bf | ||
|
|
7571334eca | ||
|
|
bfb2d0c914 | ||
|
|
aa9a1c9129 | ||
|
|
86224a8151 | ||
|
|
1395351755 | ||
|
|
628bc024c8 | ||
|
|
d6d2aaea55 | ||
|
|
4dc13a16f7 | ||
|
|
95826392df | ||
|
|
503a43c953 | ||
|
|
0f3fd34b54 | ||
|
|
0f97ecc9f0 | ||
|
|
a82bd281a7 | ||
|
|
249b2cf229 | ||
|
|
7fc6632907 | ||
|
|
e5240137ee | ||
|
|
ed21c3b630 | ||
|
|
8e68c59893 | ||
|
|
b82bcc7673 | ||
|
|
5893e7c6e6 | ||
|
|
20ae09848b | ||
|
|
9e776ca38e | ||
|
|
f8ede162c0 | ||
|
|
b2abfa7309 | ||
|
|
9f1ae9363d | ||
|
|
613f856662 | ||
|
|
4ee06766fd | ||
|
|
06217dbfb8 | ||
|
|
748cccb745 | ||
|
|
54dc3156dd | ||
|
|
c4c20633ea | ||
|
|
9a45dfaf35 | ||
|
|
b4d374cbe2 | ||
|
|
29115b104f | ||
|
|
3731443254 | ||
|
|
11081b2ba8 | ||
|
|
49a4187b46 | ||
|
|
ac958c32db | ||
|
|
1f2f7560bc | ||
|
|
91ac3126f8 | ||
|
|
212caa37cf | ||
|
|
a8777b9c1b | ||
|
|
3c28406935 | ||
|
|
cae58fb5ec | ||
|
|
260ae7ed28 | ||
|
|
e644246311 | ||
|
|
268544183d | ||
|
|
1135c81cc5 | ||
|
|
23a1c09797 | ||
|
|
876b41e3eb | ||
|
|
1e5f15f506 | ||
|
|
2a3d37dd98 | ||
|
|
fd64ea52d7 | ||
|
|
5eb6e71515 | ||
|
|
fcde19dc23 | ||
|
|
f3956fa50a | ||
|
|
1cfc8b707b | ||
|
|
28c7d3d599 | ||
|
|
683bdc30d0 | ||
|
|
85061e9bd5 | ||
|
|
76962d0ac1 | ||
|
|
b0b8a3e867 | ||
|
|
3f80e56365 | ||
|
|
6ab40e92ff | ||
|
|
0875e4d97a | ||
|
|
86619339a5 | ||
|
|
4d13d287d0 | ||
|
|
42ee381c7e | ||
|
|
13e3d456e8 | ||
|
|
ffcfa9237a | ||
|
|
b6066065ed | ||
|
|
62e66b8769 | ||
|
|
3f40bec3ef | ||
|
|
26115c48df | ||
|
|
82240095b6 | ||
|
|
ab1ea56242 | ||
|
|
4eae9cd7c1 | ||
|
|
c156a3142b | ||
|
|
5ca0e4e90e | ||
|
|
88003355c2 | ||
|
|
ce9e2550d0 | ||
|
|
5f176e3bd1 | ||
|
|
7015793abe | ||
|
|
f27476fa1c | ||
|
|
022e439cbe | ||
|
|
f0dc8fb3af | ||
|
|
79fbe7889f | ||
|
|
3b16f40ec2 | ||
|
|
d00c0e5cc1 | ||
|
|
9cc70cec79 | ||
|
|
7457e23829 | ||
|
|
25092daa46 | ||
|
|
e6208102e4 | ||
|
|
15c8fd6168 | ||
|
|
b3ac340072 | ||
|
|
85247692c6 | ||
|
|
ed851845fa | ||
|
|
c60296c6dd | ||
|
|
f399643bb9 | ||
|
|
c6044221d7 | ||
|
|
3358a89faf | ||
|
|
b3acacddfd | ||
|
|
06b7e631b3 | ||
|
|
7348dabbb3 | ||
|
|
41feb7c180 | ||
|
|
7492366d85 | ||
|
|
ca6334e48e | ||
|
|
8ae9a81283 | ||
|
|
cdb0272770 | ||
|
|
987d8bda78 | ||
|
|
5cc0cefb2b | ||
|
|
99faea5637 | ||
|
|
4572cb045e | ||
|
|
9b3dbb2665 | ||
|
|
e7b41487db | ||
|
|
b1dd50e08a | ||
|
|
869bc15e1f | ||
|
|
b85b515137 | ||
|
|
c8c64d9142 | ||
|
|
9656bb3ffc | ||
|
|
a60fa569a9 | ||
|
|
142caa80fc | ||
|
|
49ff512437 | ||
|
|
ca63c5a190 | ||
|
|
7c5a4a3df8 | ||
|
|
651c5dda1c | ||
|
|
9c7ec015ab | ||
|
|
69fb2c5dc9 | ||
|
|
442a3fc6fc | ||
|
|
34a3a7accc | ||
|
|
26e2881f66 | ||
|
|
0baca87c3f | ||
|
|
7468f7db35 | ||
|
|
8eea048660 | ||
|
|
6476338832 | ||
|
|
39dfcf677b | ||
|
|
b35d96418b | ||
|
|
7e28d5e221 | ||
|
|
cddca228b0 | ||
|
|
c2c9de8fcb | ||
|
|
add87ef670 | ||
|
|
1a655c37e7 | ||
|
|
29cb4037e2 | ||
|
|
c4b497145c | ||
|
|
a3f3cedf7a | ||
|
|
555ba90930 | ||
|
|
f477db0c84 | ||
|
|
b892e519c1 | ||
|
|
138ebf63ee | ||
|
|
93b68015a7 | ||
|
|
91c8b63875 | ||
|
|
f35e4248f6 | ||
|
|
c23caa9ad8 | ||
|
|
9cb981749a | ||
|
|
8c17349a2c | ||
|
|
9888506420 | ||
|
|
bcaa7dcae6 | ||
|
|
7ee52e32f5 | ||
|
|
f03eb9b5bd | ||
|
|
e32547421a | ||
|
|
b8f7a15213 | ||
|
|
d03b353173 | ||
|
|
937fa8b88f | ||
|
|
e2d2f5166f | ||
|
|
4065a9bbac | ||
|
|
ea0204fad7 | ||
|
|
4dd5c3ac36 | ||
|
|
0622f64f16 | ||
|
|
c41bdf3815 | ||
|
|
0d219538a6 | ||
|
|
5b97d3d95f | ||
|
|
4bf6d00000 | ||
|
|
378ad73751 | ||
|
|
f91316a9fa | ||
|
|
4b847d0c3b | ||
|
|
0e89e304c3 | ||
|
|
6ba264130c | ||
|
|
74c58a3b9c | ||
|
|
543c79dfc1 | ||
|
|
9c5b9afa12 | ||
|
|
fb05d2722f | ||
|
|
4006bd39df | ||
|
|
d614eaf454 | ||
|
|
5f39a09da3 | ||
|
|
3fcfd43921 | ||
|
|
b15c0f0d79 | ||
|
|
9793382814 | ||
|
|
b7d97d4cb2 | ||
|
|
680ee0e962 | ||
|
|
35dbf6fcda | ||
|
|
a65c9280b5 | ||
|
|
9b55b7a303 | ||
|
|
436717da10 | ||
|
|
83040b8a8d | ||
|
|
68c0e92aac |
@@ -3,7 +3,7 @@
|
||||
output/
|
||||
resource
|
||||
*.db
|
||||
*.db.decrypted
|
||||
*.db.decoded
|
||||
*2015*
|
||||
*2017*
|
||||
2020*
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
*.iml
|
||||
.gradle
|
||||
local.properties
|
||||
.idea
|
||||
app/release
|
||||
.DS_Store
|
||||
build
|
||||
captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
build
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 982 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
@@ -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" "$@"
|
||||
@@ -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
|
||||
|
After Width: | Height: | Size: 85 KiB |
@@ -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")
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
@@ -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")
|
||||
|
||||
@@ -6,3 +6,5 @@ requests
|
||||
pysqlcipher3>=1.0.3
|
||||
csscompressor
|
||||
numpy
|
||||
ipython
|
||||
websocket-client
|
||||
|
||||
@@ -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__':
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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']
|
||||
|
||||
|
||||
@@ -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'):
|
||||
|
||||
@@ -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__':
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1 @@
|
||||
Copied from /data/data/com.tencent.mm/files/public/emoji/newemoji
|
||||