├── .gitignore ├── README.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ └── main │ ├── AndroidManifest.xml │ ├── assets │ └── xposed_init │ ├── java │ └── top │ │ ├── linl │ │ └── qstorycloud │ │ │ ├── activity │ │ │ ├── MainActivity.java │ │ │ └── base │ │ │ │ └── BaseActivity.java │ │ │ ├── config │ │ │ ├── LocalModuleData.java │ │ │ └── UpdateInfoData.kt │ │ │ ├── db │ │ │ └── helper │ │ │ │ └── CommonDBHelper.java │ │ │ ├── dialog │ │ │ └── UpdateLogDialog.java │ │ │ ├── fragement │ │ │ └── MainFragment.java │ │ │ ├── hook │ │ │ ├── HookEnv.java │ │ │ ├── HookInit.java │ │ │ ├── HookInject.java │ │ │ ├── PathTool.java │ │ │ ├── UpdateLogDisplay.java │ │ │ ├── moduleloader │ │ │ │ └── ModuleLoader.java │ │ │ ├── update │ │ │ │ ├── DownloadTask.java │ │ │ │ ├── UpdateChecker.kt │ │ │ │ └── UpdateObserver.kt │ │ │ └── util │ │ │ │ ├── ActivityTools.java │ │ │ │ └── ToastTool.java │ │ │ ├── log │ │ │ └── QSLog.java │ │ │ ├── model │ │ │ ├── LocalModuleInfo.java │ │ │ └── UpdateInfo.java │ │ │ ├── provider │ │ │ └── AppContentProvider.java │ │ │ └── util │ │ │ ├── SpHelper.kt │ │ │ └── TaskManager.java │ │ └── sacz │ │ └── qstory │ │ ├── config │ │ └── ModuleConfig.kt │ │ ├── hook │ │ ├── UpdateTask.kt │ │ └── ui │ │ │ └── UpdateLogDisplay.java │ │ └── net │ │ ├── DownloadTask.java │ │ ├── Update.kt │ │ └── bean │ │ ├── ModuleInfo.kt │ │ └── QSResult.java │ └── res │ ├── drawable │ ├── github.png │ └── telegram_logo.webp │ ├── layout │ ├── activity_main.xml │ └── fragment_main.xml │ ├── mipmap │ └── ic_launcher_round.png │ ├── values-night │ └── themes.xml │ └── values │ ├── arrays.xml │ ├── colors.xml │ ├── strings.xml │ └── themes.xml ├── build.gradle ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | .DS_Store 11 | /build 12 | /captures 13 | .externalNativeBuild 14 | .cxx 15 | local.properties 16 | /.idea -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # QStoryCloud 自动云更新 2 | 3 | # 目前项目处于无法使用阶段 请等待一段时间 我正在安排时间维护此项目 (也许半年或者一年内) 4 | 5 | ### 项目介绍 6 | 该项目主要用于快捷的为QStory进行内置自动更新,减少用户手动更新模块的麻烦 7 | 注:此项目不包含QStory本体的源码 8 | --- 9 | ### 功能 10 | 自动检测QStory的更新,并且自动更新,加载到QQ 11 | --- 12 | ### 实现原理 13 | 通过检测在线版本和本地版本是否匹配,不匹配则拉取在线版本的APK 14 | 再通过Dex/Apk热加载进行加载模块 15 | --- 16 | ### 使用的技术栈 17 | - ~~SQLite~~ ~~改为使用MMKV~~,使用FastKv,简单数据用SQLite会增加数据库维护成本 18 | - XPosed Hook 19 | - OkHttp,~~RxJava~~ Kotlin协程Flow更加轻量,FastJSON 20 | - 跨进程通信(跨应用)ContentProvider 21 | - 热更新(基于DexClassLoader) 22 | - 设计模式:观察者,异步回调等 23 | --- 24 | ### 项目主要知识要点 25 | - 如何动态加载模块并进行Hook:[ModuleLoader](./app/src/main/java/top/linl/qstorycloud/hook/moduleloader/ModuleLoader.java) 26 | - 如何进行模块的更新,主要通过观察者模式实现定时拉取更新:[update](./app/src/main/java/top/linl/qstorycloud/hook/update) ,[更新检测](.app/src/main/java/top/linl/qstorycloud/hook/update/UpdateObserver.kt),[观察和处理检测结果](./app/src/main/java/top/linl/qstorycloud/hook/update/UpdateChecker.kt) 27 | - 处理下载的任务和通知 [DownloadTask](./app/src/main/java/top/linl/qstorycloud/hook/update/util/DownloadTask.java) 28 | - 数据存储配置MMKV相关 [Config](.app/src/main/java/top/linl/qstorycloud/config) 29 | - 如何使模块和QQ进行跨进程通讯采用的是[ContentProvider](./app/src/main/java/top/linl/qstorycloud/provider/AppContentProvider.java) 30 | --- 31 | 32 | 33 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | /release 3 | /debug -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.application' 3 | id 'org.jetbrains.kotlin.android' 4 | } 5 | 6 | android { 7 | namespace 'top.linl.qstorycloud' 8 | compileSdk 35 9 | 10 | buildFeatures { 11 | buildConfig = true 12 | } 13 | 14 | defaultConfig { 15 | applicationId "top.linl.qstorycloud" 16 | minSdk 26 17 | targetSdk 33 18 | versionCode 4 19 | versionName "4.0" 20 | } 21 | 22 | buildTypes { 23 | release { 24 | minifyEnabled true 25 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 26 | } 27 | } 28 | 29 | android.applicationVariants.configureEach { variant -> 30 | variant.outputs.configureEach { 31 | 32 | if (getBuildType().getName() == "debug") return 33 | 34 | outputFileName = "QStory自动云更新_${versionName}-" + getBuildType().getName() + ".apk" 35 | } 36 | } 37 | 38 | aaptOptions { 39 | additionalParameters '--allow-reserved-package-id', '--package-id', '0x18' 40 | } 41 | compileOptions { 42 | sourceCompatibility JavaVersion.VERSION_17 43 | targetCompatibility JavaVersion.VERSION_17 44 | } 45 | kotlinOptions { 46 | jvmTarget = '17' 47 | } 48 | } 49 | 50 | dependencies { 51 | implementation 'androidx.core:core-ktx:1.15.0' 52 | compileOnly("de.robv.android.xposed:api:82") 53 | 54 | implementation 'androidx.appcompat:appcompat:1.7.0' 55 | implementation 'com.google.android.material:material:1.12.0' 56 | 57 | implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.14' 58 | 59 | 60 | implementation 'io.github.billywei01:fastkv:2.6.0' 61 | 62 | implementation 'com.alibaba.fastjson2:fastjson2:2.0.53' 63 | 64 | } -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | 23 | -keep class * extends android.app.Activity {*;} 24 | -keep class * extends android.content.ContentProvider {*;} 25 | -keep class top.linl.qstorycloud.hook.HookInject {*;} 26 | -keep class top.sacz.qstory.** {*;} 27 | -keepclassmembers enum * { 28 | public static **[] values(); 29 | public static ** valueOf(java.lang.String); 30 | } 31 | 32 | -keep class * implements android.os.Parcelable { 33 | public static final android.os.Parcelable$Creator *; 34 | } 35 | 36 | -keep class * implements java.io.Serializable { *; } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 29 | 30 | 31 | 34 | 37 | 40 | 43 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /app/src/main/assets/xposed_init: -------------------------------------------------------------------------------- 1 | top.linl.qstorycloud.hook.HookInject -------------------------------------------------------------------------------- /app/src/main/java/top/linl/qstorycloud/activity/MainActivity.java: -------------------------------------------------------------------------------- 1 | package top.linl.qstorycloud.activity; 2 | 3 | import android.os.Bundle; 4 | 5 | import androidx.annotation.Nullable; 6 | import androidx.fragment.app.Fragment; 7 | import androidx.fragment.app.FragmentManager; 8 | import androidx.fragment.app.FragmentTransaction; 9 | 10 | import top.linl.qstorycloud.R; 11 | import top.linl.qstorycloud.activity.base.BaseActivity; 12 | import top.linl.qstorycloud.fragement.MainFragment; 13 | 14 | public class MainActivity extends BaseActivity { 15 | @Override 16 | protected void onCreate(@Nullable Bundle savedInstanceState) { 17 | super.onCreate(savedInstanceState); 18 | requestTranslucentStatusBar(); 19 | setContentView(R.layout.activity_main); 20 | titleBarAdaptsToStatusBar(findViewById(R.id.title_bar)); 21 | FragmentManager fragmentManager = getSupportFragmentManager(); 22 | FragmentTransaction transaction = fragmentManager.beginTransaction(); 23 | Fragment fragment = new MainFragment(); 24 | transaction.replace(R.id.fragment_container, fragment); 25 | transaction.commit(); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/src/main/java/top/linl/qstorycloud/activity/base/BaseActivity.java: -------------------------------------------------------------------------------- 1 | package top.linl.qstorycloud.activity.base; 2 | 3 | import android.annotation.SuppressLint; 4 | import android.content.Context; 5 | import android.graphics.Color; 6 | import android.view.View; 7 | import android.view.ViewGroup; 8 | import android.view.Window; 9 | import android.view.WindowManager; 10 | 11 | import androidx.appcompat.app.AppCompatActivity; 12 | 13 | public class BaseActivity extends AppCompatActivity { 14 | protected void titleBarAdaptsToStatusBar(ViewGroup titleBar) { 15 | Context context = titleBar.getContext(); 16 | //获取状态栏高度 17 | int statusBarHeight = 0; 18 | @SuppressLint({"DiscouragedApi", "InternalInsetResource"}) 19 | int resourceId = context.getResources().getIdentifier("status_bar_height", "dimen", "android"); 20 | if (resourceId > 0) { 21 | statusBarHeight = context.getResources().getDimensionPixelSize(resourceId); 22 | } 23 | //适配高度 24 | ViewGroup.LayoutParams params = titleBar.getLayoutParams(); 25 | params.height += statusBarHeight; 26 | //模拟setFitsSystemWindows(ture)填充 27 | titleBar.setPadding(titleBar.getPaddingLeft(), titleBar.getPaddingTop() + statusBarHeight, titleBar.getPaddingRight(), titleBar.getPaddingBottom()); 28 | } 29 | 30 | protected void requestTranslucentStatusBar() { 31 | Window window = getWindow(); 32 | window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS | WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION); 33 | window.getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_LAYOUT_STABLE); 34 | window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS); 35 | window.setStatusBarColor(Color.TRANSPARENT); 36 | window.setNavigationBarColor(Color.TRANSPARENT); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /app/src/main/java/top/linl/qstorycloud/config/LocalModuleData.java: -------------------------------------------------------------------------------- 1 | package top.linl.qstorycloud.config; 2 | 3 | import com.alibaba.fastjson2.TypeReference; 4 | 5 | import java.util.ArrayList; 6 | import java.util.List; 7 | 8 | import top.linl.qstorycloud.model.LocalModuleInfo; 9 | import top.linl.qstorycloud.util.SpHelper; 10 | 11 | public class LocalModuleData { 12 | 13 | 14 | private static final SpHelper spHelper =new SpHelper("ModuleData"); 15 | 16 | private LocalModuleData() { 17 | } 18 | 19 | public static LocalModuleInfo getLastModuleInfo() { 20 | //获取尾部数据 21 | List dataList = getDataList(); 22 | if (dataList.isEmpty()) { 23 | return null; 24 | } 25 | return dataList.get(dataList.size() - 1); 26 | } 27 | 28 | public static void addModuleInfo(LocalModuleInfo localModuleInfo) { 29 | List dataList = getDataList(); 30 | dataList.add(localModuleInfo); 31 | spHelper.put("dataList", dataList); 32 | } 33 | 34 | public static void clear() { 35 | spHelper.put("dataList", new ArrayList()); 36 | } 37 | 38 | private static List getDataList() { 39 | List result = spHelper.getType("dataList", new TypeReference>() { 40 | }); 41 | if (result == null) { 42 | result = new ArrayList<>(); 43 | } 44 | return result; 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /app/src/main/java/top/linl/qstorycloud/config/UpdateInfoData.kt: -------------------------------------------------------------------------------- 1 | package top.linl.qstorycloud.config 2 | 3 | import com.alibaba.fastjson2.TypeReference 4 | import top.linl.qstorycloud.model.LocalModuleInfo 5 | import top.linl.qstorycloud.model.UpdateInfo 6 | import top.linl.qstorycloud.util.SpHelper 7 | 8 | object UpdateInfoData { 9 | private val spHelper = SpHelper("UpdateInfoData") 10 | 11 | fun getLastUpdateInfo(): UpdateInfo? { 12 | val data = getDataList() 13 | if (data.isEmpty()) { 14 | return null 15 | } 16 | return data.last() 17 | } 18 | 19 | fun updateLastUpdateInfo(updateInfo: UpdateInfo) { 20 | val data = getDataList() 21 | data.removeAt(data.size - 1) 22 | data.add(updateInfo) 23 | spHelper.put("dataList", data) 24 | } 25 | 26 | fun addUpdateInfo(updateInfo: UpdateInfo) { 27 | val data = getDataList() 28 | data.add(updateInfo) 29 | spHelper.put("dataList", data) 30 | } 31 | 32 | fun clear() { 33 | spHelper.put("dataList", ArrayList()) 34 | } 35 | 36 | private fun getDataList(): MutableList { 37 | return spHelper.getType("dataList", object : TypeReference>() {}) 38 | ?: mutableListOf() 39 | } 40 | } -------------------------------------------------------------------------------- /app/src/main/java/top/linl/qstorycloud/db/helper/CommonDBHelper.java: -------------------------------------------------------------------------------- 1 | package top.linl.qstorycloud.db.helper; 2 | 3 | import android.content.Context; 4 | import android.database.Cursor; 5 | import android.database.sqlite.SQLiteDatabase; 6 | import android.database.sqlite.SQLiteOpenHelper; 7 | 8 | import androidx.annotation.Nullable; 9 | 10 | /** 11 | * 模块本体使用的 12 | */ 13 | public class CommonDBHelper extends SQLiteOpenHelper { 14 | 15 | public static final String DATABASE = "qstory_cloud.db"; 16 | 17 | public static final String COMMON_TABLE = "common"; 18 | 19 | public CommonDBHelper(@Nullable Context context) { 20 | super(context, DATABASE, null, 1); 21 | } 22 | 23 | public static CommonDBHelper getInstance(Context context) { 24 | return new CommonDBHelper(context); 25 | } 26 | 27 | @Override 28 | public void onCreate(SQLiteDatabase db) { 29 | //建立常用表 30 | db.execSQL("CREATE TABLE " + COMMON_TABLE + " (id INTEGER PRIMARY KEY AUTOINCREMENT,name TEXT,value TEXT)"); 31 | //为name列建立唯一索引 32 | db.execSQL("CREATE UNIQUE INDEX name_index ON " + COMMON_TABLE + "(name)"); 33 | } 34 | 35 | @Override 36 | public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { 37 | 38 | } 39 | 40 | //增删改查 41 | public void insert(String name, String value) { 42 | SQLiteDatabase db = getWritableDatabase(); 43 | db.execSQL("INSERT INTO " + COMMON_TABLE + "(name,value) VALUES (?,?)", new String[]{name, value}); 44 | db.close(); 45 | } 46 | 47 | public void update(String name, String value) { 48 | SQLiteDatabase db = getWritableDatabase(); 49 | db.execSQL("UPDATE " + COMMON_TABLE + " SET value = ? WHERE name = ?", new String[]{value, name}); 50 | db.close(); 51 | } 52 | public String query(String name) { 53 | SQLiteDatabase db = getReadableDatabase(); 54 | String value = null; 55 | Cursor cursor =db.rawQuery("SELECT value FROM " + COMMON_TABLE + " WHERE name = ?", new String[]{name}); 56 | if (cursor.moveToNext()) { 57 | value = cursor.getString(0); 58 | } 59 | cursor.close(); 60 | db.close(); 61 | return value; 62 | } 63 | 64 | public void delete(String name) { 65 | SQLiteDatabase db = getWritableDatabase(); 66 | db.execSQL("DELETE FROM " + COMMON_TABLE + " WHERE name = ?", new String[]{name}); 67 | db.close(); 68 | } 69 | 70 | } 71 | -------------------------------------------------------------------------------- /app/src/main/java/top/linl/qstorycloud/dialog/UpdateLogDialog.java: -------------------------------------------------------------------------------- 1 | package top.linl.qstorycloud.dialog; 2 | 3 | public class UpdateLogDialog { 4 | public void show() { 5 | 6 | 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /app/src/main/java/top/linl/qstorycloud/fragement/MainFragment.java: -------------------------------------------------------------------------------- 1 | package top.linl.qstorycloud.fragement; 2 | 3 | import android.content.Context; 4 | import android.content.Intent; 5 | import android.net.Uri; 6 | import android.os.Bundle; 7 | import android.view.LayoutInflater; 8 | import android.view.View; 9 | import android.view.ViewGroup; 10 | import android.widget.CheckBox; 11 | import android.widget.TextView; 12 | 13 | import androidx.annotation.NonNull; 14 | import androidx.annotation.Nullable; 15 | import androidx.cardview.widget.CardView; 16 | import androidx.fragment.app.Fragment; 17 | 18 | import com.alibaba.fastjson2.JSON; 19 | import com.alibaba.fastjson2.TypeReference; 20 | 21 | import java.io.IOException; 22 | import java.util.List; 23 | 24 | import okhttp3.Call; 25 | import okhttp3.Callback; 26 | import okhttp3.FormBody; 27 | import okhttp3.OkHttpClient; 28 | import okhttp3.Request; 29 | import okhttp3.Response; 30 | import top.linl.qstorycloud.BuildConfig; 31 | import top.linl.qstorycloud.R; 32 | import top.linl.qstorycloud.db.helper.CommonDBHelper; 33 | import top.linl.qstorycloud.log.QSLog; 34 | import top.linl.qstorycloud.util.TaskManager; 35 | import top.sacz.qstory.net.UpdateInfo; 36 | import top.sacz.qstory.net.bean.QSResult; 37 | 38 | 39 | public class MainFragment extends Fragment { 40 | public static final String SAFE_MODE = "safe_mode"; 41 | public static final String CLEAN_DATA = "clean_data"; 42 | private TextView tvAppLabel; 43 | private CardView cardViewGithub; 44 | private CardView cardTutorial; 45 | private CheckBox cbSafeMode; 46 | 47 | private CommonDBHelper dbHelper; 48 | private CheckBox cbCleanData; 49 | private CardView cardAddTelegramChannel; 50 | private TextView tvBuildVersion; 51 | 52 | @Nullable 53 | @Override 54 | public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { 55 | View rootView = inflater.inflate(R.layout.fragment_main, container, false); 56 | initDB(); 57 | initView(rootView); 58 | initUI(); 59 | initListener(); 60 | return rootView; 61 | } 62 | 63 | @Override 64 | public void onResume() { 65 | super.onResume(); 66 | initUI(); 67 | } 68 | 69 | private void initDB() { 70 | dbHelper = CommonDBHelper.getInstance(getContext()); 71 | String safeMode = dbHelper.query(SAFE_MODE); 72 | if (safeMode == null) { 73 | dbHelper.insert(SAFE_MODE, String.valueOf(false)); 74 | } 75 | 76 | } 77 | 78 | private void initView(View root) { 79 | tvAppLabel = root.findViewById(R.id.tv_app_label); 80 | cardViewGithub = root.findViewById(R.id.card_view_github); 81 | cardTutorial = root.findViewById(R.id.card_tutorial); 82 | cbSafeMode = root.findViewById(R.id.cb_safe_mode); 83 | cbCleanData = root.findViewById(R.id.cb_clean_data); 84 | cardAddTelegramChannel = root.findViewById(R.id.card_add_telegram_channel); 85 | tvBuildVersion = root.findViewById(R.id.tv_build_version); 86 | } 87 | 88 | private void initUI() { 89 | String buildVersion = String.format(getString(R.string.build_version), BuildConfig.VERSION_NAME, "正在获取"); 90 | tvBuildVersion.setText(buildVersion); 91 | requestLatestVersionName(); 92 | cbSafeMode.setChecked(Boolean.parseBoolean(dbHelper.query(SAFE_MODE))); 93 | String cleanData = dbHelper.query(CLEAN_DATA); 94 | cbCleanData.setChecked(cleanData != null); 95 | } 96 | 97 | /** 98 | * 请求和刷新版本信息 99 | */ 100 | private void requestLatestVersionName() { 101 | OkHttpClient client = new OkHttpClient.Builder().build(); 102 | FormBody formBody = new FormBody.Builder() 103 | .add("version", "0") 104 | .build(); 105 | Request request = new Request 106 | .Builder() 107 | .url("https://qstory.sacz.top/update/getUpdateLog") 108 | .post(formBody) 109 | .addHeader("User-Agent", "Android") 110 | .addHeader("Content-Type", "text/plain") 111 | .addHeader("Accept", "*/*") 112 | .addHeader("Connection", "keep-alive") 113 | .build(); 114 | client.newCall(request).enqueue(new Callback() { 115 | @Override 116 | public void onFailure(@NonNull Call call, @NonNull IOException e) { 117 | QSLog.e("MainFragment", e); 118 | } 119 | 120 | @Override 121 | public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException { 122 | TaskManager.addTask(() -> { 123 | try { 124 | TypeReference>> typeReference = new TypeReference<>() { 125 | }; 126 | UpdateInfo updateInfo = JSON.parseObject(response.body().string(), typeReference).getData().get(0); 127 | Context context = getContext(); 128 | if (context == null) return; 129 | String buildVersionFormat = context.getString(R.string.build_version); 130 | tvBuildVersion.setText(String.format(buildVersionFormat, BuildConfig.VERSION_NAME, updateInfo.getVersionName())); 131 | } catch (IOException e) { 132 | QSLog.e("MainFragment", e); 133 | } 134 | } 135 | ); 136 | 137 | } 138 | }); 139 | } 140 | 141 | private void initListener() { 142 | cardViewGithub.setOnClickListener(v -> { 143 | //跳转到浏览器打开 144 | Intent intent = new Intent(); 145 | intent.setAction("android.intent.action.VIEW"); 146 | intent.setData(Uri.parse("https://github.com/Suzhelan/QStoryCloud")); 147 | startActivity(intent); 148 | }); 149 | cardAddTelegramChannel.setOnClickListener(v -> { 150 | Intent intent = new Intent(); 151 | intent.setAction("android.intent.action.VIEW"); 152 | intent.setData(Uri.parse("https://t.me/WhenFlowersAreInBloom")); 153 | startActivity(intent); 154 | }); 155 | cbSafeMode.setOnCheckedChangeListener((buttonView, isChecked) -> dbHelper.update(SAFE_MODE, String.valueOf(isChecked))); 156 | cbCleanData.setOnCheckedChangeListener((buttonView, isChecked) -> { 157 | if (isChecked) { 158 | //插入清空数据的标记 159 | dbHelper.insert(CLEAN_DATA, "true"); 160 | } else { 161 | dbHelper.delete(CLEAN_DATA); 162 | } 163 | }); 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /app/src/main/java/top/linl/qstorycloud/hook/HookEnv.java: -------------------------------------------------------------------------------- 1 | package top.linl.qstorycloud.hook; 2 | 3 | import android.content.Context; 4 | import android.content.pm.PackageInfo; 5 | import android.content.pm.PackageManager; 6 | 7 | import de.robv.android.xposed.callbacks.XC_LoadPackage; 8 | 9 | public class HookEnv { 10 | //目标包名 如果通用填.+ 11 | private static final String targetPackageName = "com.tencent.mobileqq|com.tencent.tim"; 12 | 13 | public static XC_LoadPackage.LoadPackageParam getLoadPackageParam() { 14 | return loadPackageParam; 15 | } 16 | 17 | public static void setLoadPackageParam(XC_LoadPackage.LoadPackageParam loadPackageParam) { 18 | HookEnv.loadPackageParam = loadPackageParam; 19 | } 20 | 21 | private static XC_LoadPackage.LoadPackageParam loadPackageParam; 22 | 23 | /** 24 | * 当前宿主包名 25 | */ 26 | private static String currentHostAppPackageName; 27 | /** 28 | * 当前宿主进程名称 29 | */ 30 | private static String processName; 31 | /** 32 | * 模块路径 33 | */ 34 | private static String moduleApkPath; 35 | /** 36 | * 宿主apk路径 37 | */ 38 | private static String hostApkPath; 39 | /** 40 | * 宿主版本名称 41 | */ 42 | private static String versionName; 43 | /** 44 | * 宿主版本号 45 | */ 46 | private static int versionCode; 47 | /** 48 | * 全局的Context 49 | */ 50 | private static Context hostAppContext; 51 | 52 | public static String getTargetPackageName() { 53 | return targetPackageName; 54 | } 55 | 56 | public static Context getHostAppContext() { 57 | return hostAppContext; 58 | } 59 | 60 | public static void setHostAppContext(Context hostAppContext) { 61 | HookEnv.hostAppContext = hostAppContext; 62 | PackageManager packageManager = hostAppContext.getPackageManager(); 63 | try { 64 | PackageInfo packageInfo = packageManager.getPackageInfo(hostAppContext.getPackageName(), 0); 65 | setVersionCode(packageInfo.versionCode); 66 | setVersionName(packageInfo.versionName); 67 | } catch (Exception e) { 68 | throw new RuntimeException(e); 69 | } 70 | } 71 | 72 | public static String getCurrentHostAppPackageName() { 73 | return currentHostAppPackageName; 74 | } 75 | 76 | public static void setCurrentHostAppPackageName(String currentHostAppPackageName) { 77 | HookEnv.currentHostAppPackageName = currentHostAppPackageName; 78 | } 79 | 80 | public static String getModuleApkPath() { 81 | return moduleApkPath; 82 | } 83 | 84 | public static void setModuleApkPath(String moduleApkPath) { 85 | HookEnv.moduleApkPath = moduleApkPath; 86 | } 87 | 88 | public static String getHostApkPath() { 89 | return hostApkPath; 90 | } 91 | 92 | public static void setHostApkPath(String hostApkPath) { 93 | HookEnv.hostApkPath = hostApkPath; 94 | } 95 | 96 | public static String getVersionName() { 97 | return versionName; 98 | } 99 | 100 | public static void setVersionName(String versionName) { 101 | HookEnv.versionName = versionName; 102 | } 103 | 104 | public static int getVersionCode() { 105 | return versionCode; 106 | } 107 | 108 | public static void setVersionCode(int versionCode) { 109 | HookEnv.versionCode = versionCode; 110 | } 111 | 112 | public static boolean isMainProcess() { 113 | return getProcessName().equals("com.tencent.mobileqq") || getProcessName().equals("com.tencent.tim"); 114 | } 115 | 116 | public static String getProcessName() { 117 | return processName; 118 | } 119 | 120 | public static void setProcessName(String processName) { 121 | HookEnv.processName = processName; 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /app/src/main/java/top/linl/qstorycloud/hook/HookInit.java: -------------------------------------------------------------------------------- 1 | package top.linl.qstorycloud.hook; 2 | 3 | 4 | import top.linl.qstorycloud.hook.moduleloader.ModuleLoader; 5 | import top.linl.qstorycloud.hook.update.UpdateObserver; 6 | import top.sacz.qstory.hook.UpdateTask; 7 | import top.sacz.qstory.hook.ui.UpdateLogDisplay; 8 | 9 | public class HookInit { 10 | 11 | public void init() { 12 | //模块加载器,加载模块 13 | ModuleLoader moduleLoader = new ModuleLoader(); 14 | moduleLoader.readyToLoad(); 15 | 16 | if (HookEnv.isMainProcess()) { 17 | //展示更新日志 18 | UpdateLogDisplay updateLogDisplay = new UpdateLogDisplay(); 19 | updateLogDisplay.hook(); 20 | //监听更新 21 | UpdateTask updateTask = new UpdateTask(); 22 | updateTask.init(); 23 | } 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /app/src/main/java/top/linl/qstorycloud/hook/HookInject.java: -------------------------------------------------------------------------------- 1 | package top.linl.qstorycloud.hook; 2 | 3 | import android.content.Context; 4 | import android.content.ContextWrapper; 5 | import android.util.Log; 6 | 7 | 8 | import java.lang.reflect.Method; 9 | import java.util.concurrent.atomic.AtomicBoolean; 10 | 11 | import de.robv.android.xposed.IXposedHookLoadPackage; 12 | import de.robv.android.xposed.IXposedHookZygoteInit; 13 | import de.robv.android.xposed.XC_MethodHook; 14 | import de.robv.android.xposed.XposedBridge; 15 | import de.robv.android.xposed.callbacks.XC_LoadPackage; 16 | import top.linl.qstorycloud.hook.util.ActivityTools; 17 | import top.linl.qstorycloud.util.SpHelper; 18 | 19 | public class HookInject implements IXposedHookLoadPackage, IXposedHookZygoteInit { 20 | 21 | private static final AtomicBoolean IS_INJECT = new AtomicBoolean(); 22 | 23 | @Override 24 | public void initZygote(StartupParam startupParam) throws Throwable { 25 | HookEnv.setModuleApkPath(startupParam.modulePath); 26 | } 27 | 28 | private void initAppContext(Context applicationContext) { 29 | //获取和设置全局上下文和类加载器 30 | HookEnv.setHostAppContext(applicationContext);//context 31 | HookEnv.setHostApkPath(applicationContext.getApplicationInfo().sourceDir);//apk path 32 | ActivityTools.injectResourcesToContext(applicationContext); 33 | //初始化mmkv和自定义路径 34 | String dataDir = applicationContext.getDataDir().getAbsolutePath() + "/qstory_cloud_config"; 35 | SpHelper.Companion.initialize(dataDir); 36 | //使用这个类加载器 因为框架可能会提供不正确的类加载器,我没有反射工具包装类 所以就不写了 37 | ClassLoader hostClassLoader = applicationContext.getClassLoader(); 38 | HookInit hookInit = new HookInit(); 39 | hookInit.init(); 40 | } 41 | 42 | @Override 43 | public void handleLoadPackage(XC_LoadPackage.LoadPackageParam loadPackageParam) throws Throwable { 44 | String packageName = loadPackageParam.packageName; 45 | if (!loadPackageParam.isFirstApplication) return; 46 | if (!packageName.matches(HookEnv.getTargetPackageName())) return; 47 | HookEnv.setLoadPackageParam(loadPackageParam); 48 | //设置当前应用包名 49 | HookEnv.setCurrentHostAppPackageName(packageName); 50 | //设置进程名 方便在判断当前运行的是否主进程 51 | HookEnv.setProcessName(loadPackageParam.processName); 52 | 53 | XposedBridge.hookMethod(getAppContextInitMethod(loadPackageParam), new XC_MethodHook() { 54 | @Override 55 | protected void afterHookedMethod(MethodHookParam param) { 56 | if (IS_INJECT.getAndSet(true)) return; 57 | ContextWrapper wrapper = (ContextWrapper) param.thisObject; 58 | initAppContext(wrapper.getBaseContext()); 59 | } 60 | }); 61 | } 62 | 63 | /** 64 | * 获取application的onCreate方法 65 | */ 66 | private Method getAppContextInitMethod(XC_LoadPackage.LoadPackageParam loadParam) { 67 | try { 68 | if (loadParam.appInfo.name != null) { 69 | Class clz = loadParam.classLoader.loadClass(loadParam.appInfo.name); 70 | try { 71 | return clz.getDeclaredMethod("attachBaseContext", Context.class); 72 | } catch (Throwable i) { 73 | try { 74 | return clz.getDeclaredMethod("onCreate"); 75 | } catch (Throwable e) { 76 | try { 77 | return clz.getSuperclass().getDeclaredMethod("attachBaseContext", Context.class); 78 | } catch (Throwable m) { 79 | return clz.getSuperclass().getDeclaredMethod("onCreate"); 80 | } 81 | } 82 | } 83 | 84 | } 85 | } catch (Throwable o) { 86 | XposedBridge.log("[error]" + Log.getStackTraceString(o)); 87 | } 88 | try { 89 | return ContextWrapper.class.getDeclaredMethod("attachBaseContext", Context.class); 90 | } catch (Throwable u) { 91 | XposedBridge.log("[error]" + Log.getStackTraceString(u)); 92 | return null; 93 | } 94 | } 95 | } -------------------------------------------------------------------------------- /app/src/main/java/top/linl/qstorycloud/hook/PathTool.java: -------------------------------------------------------------------------------- 1 | package top.linl.qstorycloud.hook; 2 | 3 | import android.content.Context; 4 | 5 | public class PathTool { 6 | 7 | public static String getDataPath() { 8 | //路径需要是data/data/包名/app_qstory_cloud_data的才能被DexClassLoader释放资源 9 | return HookEnv.getHostAppContext().getDir("qstory_cloud_data", Context.MODE_PRIVATE).getAbsolutePath(); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /app/src/main/java/top/linl/qstorycloud/hook/UpdateLogDisplay.java: -------------------------------------------------------------------------------- 1 | package top.linl.qstorycloud.hook; 2 | 3 | import android.app.Activity; 4 | import android.app.AlertDialog; 5 | import android.os.Bundle; 6 | 7 | import java.lang.reflect.Method; 8 | 9 | import de.robv.android.xposed.XC_MethodHook; 10 | import de.robv.android.xposed.XposedBridge; 11 | import top.linl.qstorycloud.R; 12 | import top.linl.qstorycloud.config.LocalModuleData; 13 | import top.linl.qstorycloud.config.UpdateInfoData; 14 | import top.linl.qstorycloud.hook.util.ActivityTools; 15 | import top.linl.qstorycloud.model.LocalModuleInfo; 16 | import top.linl.qstorycloud.model.UpdateInfo; 17 | 18 | /** 19 | * 展示更新日志 20 | *

21 | * 这个比较简单 从数据库中查询更新日志和是否已读 22 | */ 23 | public class UpdateLogDisplay { 24 | 25 | public boolean isShow; 26 | public void hook() { 27 | try { 28 | Method onCreateMethod = Activity.class.getDeclaredMethod("onCreate", Bundle.class); 29 | XposedBridge.hookMethod(onCreateMethod, new XC_MethodHook() { 30 | @Override 31 | protected void afterHookedMethod(MethodHookParam param) throws Throwable { 32 | UpdateInfo updateInfo = UpdateInfoData.INSTANCE.getLastUpdateInfo(); 33 | LocalModuleInfo moduleInfo = LocalModuleData.getLastModuleInfo(); 34 | if (updateInfo == null || moduleInfo == null) { 35 | return; 36 | } 37 | if (updateInfo.getLatestVersionCode() != moduleInfo.getModuleVersionCode()) { 38 | return; 39 | } 40 | if (updateInfo.isHaveRead()) { 41 | return; 42 | } 43 | showUpdateLog((Activity) param.thisObject, updateInfo); 44 | } 45 | }); 46 | } catch (NoSuchMethodException e) { 47 | throw new RuntimeException(e); 48 | } 49 | } 50 | 51 | public void showUpdateLog(Activity activity, UpdateInfo updateInfo) { 52 | //防止用户选择了左按钮‘喵’ 然后每次打开activity时都会弹更新日志 53 | if (isShow) { 54 | return; 55 | } 56 | isShow = true; 57 | ActivityTools.injectResourcesToContext(activity); 58 | String updateTitle = String.format(activity.getString(R.string.update_log_dialog_title), updateInfo.getLatestVersionName()); 59 | new AlertDialog.Builder(activity,R.style.theme_dialog) 60 | .setTitle(updateTitle) 61 | .setCancelable(false) 62 | .setMessage("更新日志:\n"+updateInfo.getUpdateLog()) 63 | .setPositiveButton( 64 | "确定", (dialog, which) -> { 65 | updateInfo.setHaveRead(true); 66 | UpdateInfoData.INSTANCE.updateLastUpdateInfo(updateInfo); 67 | dialog.dismiss(); 68 | }) 69 | .setNeutralButton("喵", (dialog, which) -> { 70 | dialog.dismiss(); 71 | }) 72 | .show(); 73 | } 74 | 75 | 76 | } 77 | -------------------------------------------------------------------------------- /app/src/main/java/top/linl/qstorycloud/hook/moduleloader/ModuleLoader.java: -------------------------------------------------------------------------------- 1 | package top.linl.qstorycloud.hook.moduleloader; 2 | 3 | import android.content.ContentResolver; 4 | import android.database.Cursor; 5 | import android.net.Uri; 6 | 7 | import java.io.File; 8 | import java.lang.reflect.Constructor; 9 | import java.lang.reflect.Method; 10 | 11 | import dalvik.system.DexClassLoader; 12 | import de.robv.android.xposed.IXposedHookZygoteInit; 13 | import de.robv.android.xposed.XposedBridge; 14 | import de.robv.android.xposed.callbacks.XC_LoadPackage; 15 | import top.linl.qstorycloud.hook.HookEnv; 16 | import top.linl.qstorycloud.hook.PathTool; 17 | import top.linl.qstorycloud.log.QSLog; 18 | import top.sacz.qstory.config.ModuleConfig; 19 | import top.sacz.qstory.net.bean.ModuleInfo; 20 | 21 | /** 22 | * 模块加载器,真正加载模块apk的地方 23 | */ 24 | public class ModuleLoader { 25 | 26 | /** 27 | * 判断是否有条件加载本地的模块 28 | */ 29 | private boolean hasConditionLoading() { 30 | //模块信息为空 31 | ModuleInfo localModuleInfo = ModuleConfig.INSTANCE.getModuleInfo(); 32 | if (localModuleInfo.getVersionCode() == 0) { 33 | return false; 34 | } 35 | //模块文件为空 36 | File moduleApkFile = new File(localModuleInfo.getPath()); 37 | return moduleApkFile.exists(); 38 | } 39 | 40 | /** 41 | * 判断是否开启了安全模式 42 | */ 43 | private boolean isOpenSafeMode() { 44 | //这里用到了跨进程通讯 45 | //获取内容提供者 46 | ContentResolver contentResolver = HookEnv.getHostAppContext().getContentResolver(); 47 | //查询是否开启了安全模式 48 | Uri uri = Uri.parse("content://qstorycloud.linl.top/common/query"); 49 | Cursor cursor = contentResolver.query(uri, new String[]{"value"}, "name=?", new String[]{"safe_mode"}, null); 50 | String openState = "false"; 51 | if (cursor != null && cursor.moveToNext()) { 52 | openState = cursor.getString(0); 53 | cursor.close(); 54 | } 55 | return Boolean.parseBoolean(openState); 56 | } 57 | 58 | /** 59 | * 尝试加载模块 60 | */ 61 | public void readyToLoad() { 62 | //打开了安全模式不加载模块 63 | if (isOpenSafeMode()) return; 64 | if (hasConditionLoading()) { 65 | ModuleInfo moduleInfo = ModuleConfig.INSTANCE.getModuleInfo(); 66 | //加载模块 67 | loadModuleAPKAndHook(moduleInfo.getPath()); 68 | } 69 | } 70 | 71 | /** 72 | * 加载模块并执行模块的Hook 73 | * 74 | * @param pluginApkPath 模块apk路径 75 | */ 76 | public void loadModuleAPKAndHook(String pluginApkPath) { 77 | File optimizedDirectoryFile = new File(PathTool.getDataPath()); 78 | // 构建插件的DexClassLoader类加载器,参数: 79 | // 1、包含dex的apk文件或jar文件的路径, 80 | // 2、apk、jar解压缩生成dex存储的目录需要是data/data/包名/app_xxx的路径 一般通过context.getDir("dirName", Context.MODE_PRIVATE)获取 81 | // 3、本地library库目录,一般为null, 82 | // 4、父ClassLoader, 如果是模块要用XposedBridge.class.getClassLoader(),不能用其他的 83 | try { 84 | DexClassLoader dexClassLoader = new DexClassLoader(pluginApkPath, optimizedDirectoryFile.getPath(), 85 | null, XposedBridge.class.getClassLoader()); 86 | //获取插件的入口类 也就是实现了IXposedHookLoadPackage, IXposedHookZygoteInit的类 87 | Class entryHookClass = dexClassLoader.loadClass("lin.xposed.hook.InitInject"); 88 | Object hookInjectInstance = entryHookClass.newInstance(); 89 | //初始化zygote 一定是比handleLoadPackage先调用的 90 | Method initZygoteMethod = entryHookClass.getMethod("initZygote", IXposedHookZygoteInit.StartupParam.class); 91 | //反射new实例 92 | Constructor constructor = IXposedHookZygoteInit.StartupParam.class.getDeclaredConstructor(); 93 | constructor.setAccessible(true); 94 | IXposedHookZygoteInit.StartupParam startupParam = constructor.newInstance(); 95 | startupParam.modulePath = pluginApkPath; 96 | startupParam.startsSystemServer = false; 97 | initZygoteMethod.invoke(hookInjectInstance, startupParam); 98 | //正常的hook初始化流程 99 | Method entryHookMethod = entryHookClass.getMethod("handleLoadPackage", XC_LoadPackage.LoadPackageParam.class); 100 | entryHookMethod.invoke(hookInjectInstance, HookEnv.getLoadPackageParam()); 101 | } catch (Exception e) { 102 | QSLog.e("ModuleLoader", e); 103 | } 104 | } 105 | 106 | } 107 | -------------------------------------------------------------------------------- /app/src/main/java/top/linl/qstorycloud/hook/update/DownloadTask.java: -------------------------------------------------------------------------------- 1 | package top.linl.qstorycloud.hook.update; 2 | 3 | import android.app.ActivityManager; 4 | import android.app.Notification; 5 | import android.app.NotificationChannel; 6 | import android.app.NotificationManager; 7 | import android.app.Service; 8 | import android.content.Context; 9 | 10 | import androidx.core.app.NotificationCompat; 11 | 12 | import java.io.BufferedInputStream; 13 | import java.io.BufferedOutputStream; 14 | import java.io.File; 15 | import java.io.FileOutputStream; 16 | import java.io.IOException; 17 | import java.text.DecimalFormat; 18 | import java.util.List; 19 | 20 | import okhttp3.Call; 21 | import okhttp3.OkHttpClient; 22 | import okhttp3.Request; 23 | import okhttp3.Response; 24 | import top.linl.qstorycloud.R; 25 | import top.linl.qstorycloud.hook.util.ActivityTools; 26 | import top.linl.qstorycloud.model.UpdateInfo; 27 | 28 | public class DownloadTask { 29 | 30 | private static final String channelId = "QStoryCloud"; 31 | private final int notificationFlag = (int) (System.currentTimeMillis() / 2); 32 | private final Context context; 33 | private final UpdateInfo updateInfo; 34 | private NotificationCompat.Builder builder; 35 | private NotificationManager notificationManager; 36 | 37 | public DownloadTask(Context context,UpdateInfo updateInfo) { 38 | this.context = context; 39 | this.updateInfo = updateInfo; 40 | initializeNotification(); 41 | } 42 | 43 | private boolean isAppForeground(Context context) { 44 | ActivityManager activityManager = 45 | (ActivityManager) context.getSystemService(Service.ACTIVITY_SERVICE); 46 | List runningAppProcessInfoList = 47 | activityManager.getRunningAppProcesses(); 48 | if (runningAppProcessInfoList == null) { 49 | return false; 50 | } 51 | 52 | for (ActivityManager.RunningAppProcessInfo processInfo : runningAppProcessInfoList) { 53 | if (processInfo.processName.equals(context.getPackageName()) 54 | && (processInfo.importance == 55 | ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND)) { 56 | return true; 57 | } 58 | } 59 | return false; 60 | } 61 | 62 | private void initializeNotification() { 63 | // 创建一个通知频道 NotificationChannel 64 | NotificationChannel channel = new NotificationChannel(channelId, "QStoryCloud", NotificationManager.IMPORTANCE_DEFAULT); 65 | //桌面小红点 66 | channel.enableLights(false); 67 | //通知显示 68 | channel.setLockscreenVisibility(Notification.VISIBILITY_PUBLIC); 69 | notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); 70 | notificationManager.createNotificationChannel(channel); 71 | } 72 | 73 | private void sendProgressNotification(int size) { 74 | if (updateInfo == null) { 75 | return; 76 | } 77 | builder = new NotificationCompat.Builder(context, channelId); 78 | ActivityTools.injectResourcesToContext(context); 79 | String contentText = "下载中 大小" + getNetFileSizeDescription(size) + " 切换QQ到后台可查看具体进度"; 80 | if (size == 0) { 81 | contentText = "准备开始下载"; 82 | } 83 | builder.setContentTitle("QStory正在云更新到" + updateInfo.getLatestVersionName()) //设置标题 84 | .setSmallIcon(R.mipmap.ic_launcher_round) //设置小图标 85 | .setPriority(NotificationCompat.PRIORITY_MAX) //设置通知的优先级 86 | .setWhen(System.currentTimeMillis()) 87 | .setAutoCancel(false) //设置通知被点击一次不自动取消 88 | .setOngoing(true) 89 | .setSound(null) 90 | .setContentText(contentText); //设置内容; 91 | notificationManager.notify(notificationFlag, builder.build()); 92 | } 93 | 94 | private void updateNotification(int max, int progress) { 95 | //qq在前台时通知被查看后会自动消失 因此只让qq在后台时通知进度 96 | if (builder == null) { 97 | return; 98 | } 99 | //应用在前台不更新进度 100 | if (isAppForeground(context)) return; 101 | 102 | if (progress >= 0) { 103 | builder.setContentText("进度:" + getNetFileSizeDescription(progress) + "/" + getNetFileSizeDescription(max)); 104 | builder.setProgress(max, progress, false); 105 | } 106 | if (progress == max) { 107 | builder.setContentText("下载完成"); 108 | builder.setAutoCancel(true); 109 | builder.setOngoing(false); 110 | } 111 | notificationManager.notify(notificationFlag, builder.build()); 112 | } 113 | 114 | private void sendDownloadSuccessNotification() { 115 | NotificationCompat.Builder builder = new NotificationCompat.Builder(context, channelId) 116 | .setContentTitle("QStory已更新到" + updateInfo.getLatestVersionName()) //设置标题 117 | .setSmallIcon(R.mipmap.ic_launcher_round) //设置小图标 118 | .setPriority(NotificationCompat.PRIORITY_MAX) //设置通知的优先级 119 | .setAutoCancel(false) //设置通知被点击一次不自动取消 120 | .setOngoing(true) 121 | .setContentText("请手动重启QQ,模块将会生效") //设置内容 122 | ; 123 | notificationManager.notify(notificationFlag + 0xFF, builder.build()); 124 | } 125 | 126 | private String getNetFileSizeDescription(long size) { 127 | StringBuffer bytes = new StringBuffer(); 128 | DecimalFormat format = new DecimalFormat("###.0"); 129 | if (size >= 1024 * 1024 * 1024) { 130 | double i = (size / (1024.0 * 1024.0 * 1024.0)); 131 | bytes.append(format.format(i)).append("GB"); 132 | } else if (size >= 1024 * 1024) { 133 | double i = (size / (1024.0 * 1024.0)); 134 | bytes.append(format.format(i)).append("MB"); 135 | } else if (size >= 1024) { 136 | double i = (size / (1024.0)); 137 | bytes.append(format.format(i)).append("KB"); 138 | } else { 139 | if (size <= 0) { 140 | bytes.append("0B"); 141 | } else { 142 | bytes.append((int) size).append("B"); 143 | } 144 | } 145 | return bytes.toString(); 146 | } 147 | 148 | 149 | /** 150 | * 真正被外部调用的下载方法 151 | * 152 | * @param url 下载链接 153 | * @param path 路径 154 | */ 155 | public void download(String url, String path) throws IOException { 156 | File downloadPath = new File(path); 157 | if (!downloadPath.getParentFile().exists()) { 158 | downloadPath.getParentFile().mkdirs(); 159 | } 160 | if (downloadPath.exists()) { 161 | downloadPath.delete(); 162 | } 163 | if (!downloadPath.exists()) { 164 | downloadPath.createNewFile(); 165 | } 166 | sendProgressNotification(0); 167 | 168 | OkHttpClient client = new OkHttpClient.Builder().build(); 169 | Request request = new Request 170 | .Builder() 171 | .url(url) 172 | .addHeader("User-Agent", "Android") 173 | .addHeader("Accept", "*/*") 174 | .addHeader("Connection", "keep-alive") 175 | .get() 176 | .build(); 177 | Call call = client.newCall(request); 178 | try (Response response = call.execute(); 179 | BufferedInputStream bufIn = new BufferedInputStream(response.body().byteStream()); 180 | BufferedOutputStream bufOut = new BufferedOutputStream(new FileOutputStream(downloadPath))) { 181 | //总字节数 182 | long size = response.body().contentLength(); 183 | sendProgressNotification((int) size); 184 | //发送通知 185 | long downloadSize = 0; 186 | int len; 187 | byte[] buf = new byte[2048];//2k 188 | while ((len = bufIn.read(buf)) != -1) { 189 | bufOut.write(buf, 0, len); 190 | downloadSize += len; 191 | updateNotification((int) size, (int) downloadSize); 192 | } 193 | bufOut.flush(); 194 | } 195 | sendDownloadSuccessNotification(); 196 | } 197 | 198 | } 199 | -------------------------------------------------------------------------------- /app/src/main/java/top/linl/qstorycloud/hook/update/UpdateChecker.kt: -------------------------------------------------------------------------------- 1 | package top.linl.qstorycloud.hook.update 2 | 3 | import kotlinx.coroutines.flow.FlowCollector 4 | import top.linl.qstorycloud.config.LocalModuleData 5 | import top.linl.qstorycloud.config.UpdateInfoData 6 | import top.linl.qstorycloud.hook.HookEnv 7 | import top.linl.qstorycloud.hook.PathTool 8 | import top.linl.qstorycloud.hook.util.ToastTool 9 | import top.linl.qstorycloud.log.QSLog 10 | import top.linl.qstorycloud.model.LocalModuleInfo 11 | import top.linl.qstorycloud.model.UpdateInfo 12 | import top.linl.qstorycloud.util.SpHelper 13 | import java.io.File 14 | 15 | class UpdateChecker : FlowCollector { 16 | 17 | val spHelper = SpHelper("UpdateChecker") 18 | 19 | override suspend fun emit(value: UpdateInfo) { 20 | QSLog.d(this, "UpdateInfo: $value") 21 | 22 | val lastModuleInfo = LocalModuleData.getLastModuleInfo() 23 | val lastUpdateInfo = UpdateInfoData.getLastUpdateInfo() 24 | //如果从未初始化过 25 | if (lastModuleInfo == null || lastUpdateInfo == null || !File(lastModuleInfo.moduleApkPath).exists()) { 26 | QSLog.d(this, "未初始化过last module:$lastModuleInfo, update info: $lastUpdateInfo") 27 | download(value) 28 | return 29 | } 30 | 31 | //如果云端版本大于本地更新版本 32 | if (value.latestVersionCode > lastUpdateInfo.latestVersionCode) { 33 | QSLog.d(this, "云端版本大于本地版本") 34 | download(value) 35 | return 36 | } 37 | 38 | //如果云端版本大于 本地版本 39 | if (lastUpdateInfo.latestVersionCode > lastModuleInfo.moduleVersionCode) { 40 | download(value) 41 | return 42 | } 43 | } 44 | 45 | private fun download(updateInfo: UpdateInfo) { 46 | UpdateInfoData.addUpdateInfo(updateInfo) 47 | QSLog.d(this, "开始下载") 48 | ToastTool.show("QStory开始更新") 49 | //下载 50 | val downloadTask = DownloadTask( 51 | HookEnv.getHostAppContext(), 52 | updateInfo 53 | ) 54 | val downloadPath = PathTool.getDataPath() + "/" + updateInfo.latestVersionName 55 | downloadTask.download(updateInfo.updateUrl, downloadPath) 56 | //下载完成 初始化bean 57 | val localModuleInfo = LocalModuleInfo().apply { 58 | moduleApkPath = downloadPath 59 | moduleName = updateInfo.latestVersionName 60 | moduleVersionCode = updateInfo.latestVersionCode 61 | moduleVersionName = updateInfo.latestVersionName 62 | } 63 | LocalModuleData.addModuleInfo(localModuleInfo) 64 | ToastTool.show("QStory已更新完成,请重启QQ") 65 | QSLog.d(this, "下载完成") 66 | } 67 | } -------------------------------------------------------------------------------- /app/src/main/java/top/linl/qstorycloud/hook/update/UpdateObserver.kt: -------------------------------------------------------------------------------- 1 | package top.linl.qstorycloud.hook.update 2 | 3 | import android.net.Uri 4 | import com.alibaba.fastjson2.JSON 5 | import kotlinx.coroutines.DelicateCoroutinesApi 6 | import kotlinx.coroutines.Dispatchers 7 | import kotlinx.coroutines.GlobalScope 8 | import kotlinx.coroutines.delay 9 | import kotlinx.coroutines.flow.Flow 10 | import kotlinx.coroutines.flow.flow 11 | import kotlinx.coroutines.flow.flowOn 12 | import kotlinx.coroutines.launch 13 | import okhttp3.FormBody 14 | import okhttp3.OkHttpClient 15 | import okhttp3.Request 16 | import top.linl.qstorycloud.config.LocalModuleData 17 | import top.linl.qstorycloud.config.UpdateInfoData 18 | import top.linl.qstorycloud.hook.HookEnv 19 | import top.linl.qstorycloud.hook.util.ToastTool 20 | import top.linl.qstorycloud.log.QSLog 21 | import top.linl.qstorycloud.model.UpdateInfo 22 | 23 | class UpdateObserver { 24 | 25 | private val detectUpdatesUrl = "https://qstory.linl.top/update/detectUpdates" 26 | 27 | 28 | private fun cleanData() { 29 | //这里用到了跨进程通讯 30 | //获取内容提供者 31 | val contentResolver = HookEnv.getHostAppContext().contentResolver 32 | val uri = Uri.parse("content://qstorycloud.linl.top/cleanData") 33 | val cursor = 34 | contentResolver.query(uri, arrayOf("value"), "name=?", arrayOf("clean_data"), null) 35 | var isCleanData: String? = null 36 | if (cursor != null && cursor.moveToNext()) { 37 | isCleanData = cursor.getString(0) 38 | cursor.close() 39 | } 40 | if (isCleanData != null) { 41 | LocalModuleData.clear() 42 | UpdateInfoData.clear() 43 | ToastTool.show("收到重置指令") 44 | } 45 | } 46 | 47 | @OptIn(DelicateCoroutinesApi::class) 48 | fun runObserver() { 49 | GlobalScope.launch { 50 | cleanData() 51 | getUpdateTaskFlow().collect(UpdateChecker()) 52 | } 53 | } 54 | 55 | private fun getUpdateTaskFlow(): Flow { 56 | return flow { 57 | while (true) { 58 | //循环检测更新 59 | val updateInfo = getUpdateInfo() 60 | if (updateInfo != null && !updateInfo.updateUrl.isNullOrEmpty()) { 61 | emit(updateInfo) 62 | } 63 | //延迟10分钟 64 | delay(60 * 1000 * 10) 65 | } 66 | }.flowOn(Dispatchers.IO)//在io线程运行协程 67 | } 68 | 69 | /** 70 | * 请求最新版本 71 | */ 72 | private fun getUpdateInfo(): UpdateInfo? { 73 | val localModuleInfo = LocalModuleData.getLastModuleInfo() 74 | var moduleVersionCode = 0 75 | if (localModuleInfo != null) { 76 | moduleVersionCode = localModuleInfo.moduleVersionCode 77 | } 78 | val client: OkHttpClient = OkHttpClient.Builder().build() 79 | val formBody: FormBody = FormBody.Builder() 80 | .add("versionCode", moduleVersionCode.toString()) 81 | .build() 82 | val request: Request = Request.Builder() 83 | .url(detectUpdatesUrl) 84 | .post(formBody) 85 | .addHeader("User-Agent", "Android") 86 | .addHeader("Content-Type", "text/plain") 87 | .addHeader("Accept", "*/*") 88 | .addHeader("Connection", "keep-alive") 89 | .build() 90 | try { 91 | client.newCall(request).execute().use { response -> 92 | val data = response.body.string() 93 | return JSON.parseObject(data, UpdateInfo::class.java) 94 | } 95 | } catch (e: Exception) { 96 | QSLog.e("DetectUpdates", e) 97 | return null 98 | } 99 | } 100 | } -------------------------------------------------------------------------------- /app/src/main/java/top/linl/qstorycloud/hook/util/ActivityTools.java: -------------------------------------------------------------------------------- 1 | package top.linl.qstorycloud.hook.util; 2 | 3 | import android.app.Activity; 4 | import android.app.ActivityManager; 5 | import android.content.Context; 6 | import android.content.res.AssetManager; 7 | import android.content.res.Resources; 8 | import android.view.View; 9 | import android.view.ViewGroup; 10 | 11 | import java.lang.reflect.Method; 12 | import java.util.ArrayList; 13 | import java.util.List; 14 | 15 | import top.linl.qstorycloud.R; 16 | import top.linl.qstorycloud.hook.HookEnv; 17 | 18 | 19 | public class ActivityTools { 20 | 21 | /** 22 | * 获取该activity所有view 23 | */ 24 | 25 | public static List getAllChildViews(Activity activity) { 26 | View view = activity.getWindow().getDecorView(); 27 | return getAllChildViews(view); 28 | 29 | } 30 | 31 | public static List getAllChildViews(View view) { 32 | List allChildren = new ArrayList<>(); 33 | if (view instanceof ViewGroup vp) { 34 | for (int i = 0; i < vp.getChildCount(); i++) { 35 | View views = vp.getChildAt(i); 36 | allChildren.add(views); 37 | //递归调用 38 | allChildren.addAll(getAllChildViews(views)); 39 | } 40 | } 41 | return allChildren; 42 | } 43 | /* 44 | * 注入Res资源到上下文 45 | */ 46 | public static void injectResourcesToContext(Context context) { 47 | Resources resources = context.getResources(); 48 | try { 49 | //如果能获取到自己的资源说明是自己的Activity或已经注入过了 50 | resources.getString(R.string.app_name); 51 | } catch (Exception e) { 52 | try { 53 | AssetManager assetManager = resources.getAssets(); 54 | Method method = AssetManager.class.getDeclaredMethod("addAssetPath", String.class); 55 | method.invoke(assetManager, HookEnv.getModuleApkPath()); 56 | //再次尝试读自己的资源 57 | resources.getString(R.string.app_name); 58 | } catch (Exception ex) { 59 | } 60 | } 61 | } 62 | 63 | /* 64 | * 结束进程 65 | */ 66 | public static void killAppProcess(Context context) { 67 | //注意:不能先杀掉主进程,否则逻辑代码无法继续执行,需先杀掉相关进程最后杀掉主进程 68 | ActivityManager mActivityManager = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); 69 | List mList = mActivityManager.getRunningAppProcesses(); 70 | for (ActivityManager.RunningAppProcessInfo runningAppProcessInfo : mList) { 71 | if (runningAppProcessInfo.pid != android.os.Process.myPid()) { 72 | android.os.Process.killProcess(runningAppProcessInfo.pid); 73 | } 74 | } 75 | android.os.Process.killProcess(android.os.Process.myPid()); 76 | System.exit(0); 77 | } 78 | 79 | public static void exitAPP(Context context) { 80 | ActivityManager activityManager = (ActivityManager) context.getApplicationContext().getSystemService(Context.ACTIVITY_SERVICE); 81 | List appTaskList = activityManager.getAppTasks(); 82 | for (ActivityManager.AppTask appTask : appTaskList) { 83 | appTask.finishAndRemoveTask(); 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /app/src/main/java/top/linl/qstorycloud/hook/util/ToastTool.java: -------------------------------------------------------------------------------- 1 | package top.linl.qstorycloud.hook.util; 2 | 3 | import android.content.Context; 4 | import android.os.Handler; 5 | import android.os.Looper; 6 | import android.widget.Toast; 7 | 8 | import top.linl.qstorycloud.hook.HookEnv; 9 | 10 | 11 | public class ToastTool { 12 | public static void show(Object content) { 13 | new Handler(Looper.getMainLooper()).post(new Runnable() { 14 | @Override 15 | public void run() { 16 | Context activity = HookEnv.getHostAppContext(); 17 | try { 18 | Toast.makeText(activity, String.valueOf(content), Toast.LENGTH_LONG).show(); 19 | } catch (Exception e) { 20 | try { 21 | Toast.makeText(activity, String.valueOf(content), Toast.LENGTH_LONG).show(); 22 | } catch (Exception ex) { 23 | } 24 | } 25 | } 26 | }); 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /app/src/main/java/top/linl/qstorycloud/log/QSLog.java: -------------------------------------------------------------------------------- 1 | package top.linl.qstorycloud.log; 2 | 3 | import android.util.Log; 4 | 5 | public class QSLog { 6 | public static int MODE = 0; 7 | 8 | public static void d(Object tag, String msg) { 9 | Log.d("QSLog-" + tag.getClass().getSimpleName(), msg); 10 | } 11 | 12 | public static void d(String tag, String msg) { 13 | if (MODE == 0) { 14 | Log.d(tag, msg); 15 | } 16 | } 17 | 18 | public static void e(String tag, Throwable msg) { 19 | if (MODE == 0) { 20 | Log.e("QSLog-" +tag, Log.getStackTraceString(msg)); 21 | } 22 | } 23 | 24 | public static void i(String tag, String msg) { 25 | if (MODE == 0) { 26 | Log.i(tag, msg); 27 | } 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /app/src/main/java/top/linl/qstorycloud/model/LocalModuleInfo.java: -------------------------------------------------------------------------------- 1 | package top.linl.qstorycloud.model; 2 | 3 | import java.io.Serializable; 4 | 5 | public class LocalModuleInfo implements Serializable { 6 | private String moduleName; 7 | private String moduleVersionName; 8 | private int moduleVersionCode; 9 | private String moduleApkPath; 10 | 11 | private long updateTime = System.currentTimeMillis(); 12 | 13 | public long getUpdateTime() { 14 | return updateTime; 15 | } 16 | 17 | public void setUpdateTime(long updateTime) { 18 | this.updateTime = updateTime; 19 | } 20 | public String getModuleName() { 21 | return moduleName; 22 | } 23 | 24 | public void setModuleName(String moduleName) { 25 | this.moduleName = moduleName; 26 | } 27 | 28 | public String getModuleVersionName() { 29 | return moduleVersionName; 30 | } 31 | 32 | public void setModuleVersionName(String moduleVersionName) { 33 | this.moduleVersionName = moduleVersionName; 34 | } 35 | 36 | public int getModuleVersionCode() { 37 | return moduleVersionCode; 38 | } 39 | 40 | public void setModuleVersionCode(int moduleVersionCode) { 41 | this.moduleVersionCode = moduleVersionCode; 42 | } 43 | 44 | public String getModuleApkPath() { 45 | return moduleApkPath; 46 | } 47 | 48 | public void setModuleApkPath(String moduleApkPath) { 49 | this.moduleApkPath = moduleApkPath; 50 | } 51 | 52 | 53 | 54 | } 55 | -------------------------------------------------------------------------------- /app/src/main/java/top/linl/qstorycloud/model/UpdateInfo.java: -------------------------------------------------------------------------------- 1 | package top.linl.qstorycloud.model; 2 | 3 | import java.io.Serializable; 4 | import java.util.Objects; 5 | 6 | public class UpdateInfo implements Serializable { 7 | 8 | private Boolean hasUpdate; 9 | private String latestVersionName; 10 | private Integer latestVersionCode; 11 | private String sender; 12 | private String updateUrl; 13 | private String updateLog; 14 | private Boolean mandatoryUpdate; 15 | private long updateTime = System.currentTimeMillis(); 16 | private boolean haveRead = false; 17 | 18 | public boolean isHaveRead() { 19 | return haveRead; 20 | } 21 | 22 | public void setHaveRead(boolean haveRead) { 23 | this.haveRead = haveRead; 24 | } 25 | 26 | public long getUpdateTime() { 27 | return updateTime; 28 | } 29 | 30 | public void setUpdateTime(long updateTime) { 31 | this.updateTime = updateTime; 32 | } 33 | 34 | public Boolean getHasUpdate() { 35 | return hasUpdate; 36 | } 37 | 38 | public void setHasUpdate(Boolean hasUpdate) { 39 | this.hasUpdate = hasUpdate; 40 | } 41 | 42 | public String getLatestVersionName() { 43 | return latestVersionName; 44 | } 45 | 46 | public void setLatestVersionName(String latestVersionName) { 47 | this.latestVersionName = latestVersionName; 48 | } 49 | 50 | public String getSender() { 51 | return sender; 52 | } 53 | 54 | public void setSender(String sender) { 55 | this.sender = sender; 56 | } 57 | 58 | public Integer getLatestVersionCode() { 59 | return latestVersionCode; 60 | } 61 | 62 | public void setLatestVersionCode(Integer latestVersionCode) { 63 | this.latestVersionCode = latestVersionCode; 64 | } 65 | 66 | public String getUpdateUrl() { 67 | return updateUrl; 68 | } 69 | 70 | public void setUpdateUrl(String updateUrl) { 71 | this.updateUrl = updateUrl; 72 | } 73 | 74 | 75 | public String getUpdateLog() { 76 | return updateLog; 77 | } 78 | 79 | public void setUpdateLog(String updateLog) { 80 | this.updateLog = updateLog; 81 | } 82 | 83 | 84 | public Boolean getMandatoryUpdate() { 85 | return mandatoryUpdate; 86 | } 87 | 88 | public void setMandatoryUpdate(Boolean mandatoryUpdate) { 89 | this.mandatoryUpdate = mandatoryUpdate; 90 | } 91 | 92 | @Override 93 | public boolean equals(Object o) { 94 | if (this == o) return true; 95 | if (o == null || getClass() != o.getClass()) return false; 96 | UpdateInfo that = (UpdateInfo) o; 97 | return hashCode() == that.hashCode(); 98 | } 99 | 100 | @Override 101 | public int hashCode() { 102 | return Objects.hash(hasUpdate, latestVersionName, latestVersionCode, sender, updateUrl, updateLog, mandatoryUpdate); 103 | } 104 | 105 | @Override 106 | public String toString() { 107 | return "UpdateInfo{" + 108 | "hasUpdate=" + hasUpdate + 109 | ", latestVersionName='" + latestVersionName + '\'' + 110 | ", latestVersionCode=" + latestVersionCode + 111 | ", sender='" + sender + '\'' + 112 | ", updateUrl='" + updateUrl + '\'' + 113 | ", updateLog='" + updateLog + '\'' + 114 | ", mandatoryUpdate=" + mandatoryUpdate + 115 | '}'; 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /app/src/main/java/top/linl/qstorycloud/provider/AppContentProvider.java: -------------------------------------------------------------------------------- 1 | package top.linl.qstorycloud.provider; 2 | 3 | import android.content.ContentProvider; 4 | import android.content.ContentValues; 5 | import android.content.UriMatcher; 6 | import android.database.Cursor; 7 | import android.database.sqlite.SQLiteDatabase; 8 | import android.net.Uri; 9 | import android.os.Handler; 10 | import android.os.Looper; 11 | 12 | import androidx.annotation.NonNull; 13 | import androidx.annotation.Nullable; 14 | 15 | import top.linl.qstorycloud.db.helper.CommonDBHelper; 16 | 17 | 18 | /** 19 | * 服务提供者 20 | */ 21 | public class AppContentProvider extends ContentProvider { 22 | 23 | private static final String AUTHORITES = "qstorycloud.linl.top"; 24 | private static final int QUERY = 0x01; //查询操作编码 25 | private static final int CLEAN_DATA = 0x03; 26 | private static final int INSERT = 0x02; //插入操作编码 27 | private static final UriMatcher sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH); //用于匹配URI,并返回对应的操作编码 28 | 29 | static { //添加有效的 URI 及其编码 30 | sUriMatcher.addURI(AUTHORITES, "/common/query", QUERY); 31 | sUriMatcher.addURI(AUTHORITES, "/cleanData", CLEAN_DATA); 32 | sUriMatcher.addURI(AUTHORITES, "/insert", INSERT); 33 | } 34 | 35 | private CommonDBHelper dbHelper; 36 | 37 | @Override 38 | public boolean onCreate() { 39 | dbHelper = new CommonDBHelper(getContext()); 40 | return false; 41 | } 42 | 43 | @Nullable 44 | @Override 45 | public Cursor query(@NonNull Uri uri, @Nullable String[] projection, @Nullable String selection, @Nullable String[] selectionArgs, @Nullable String sortOrder) { 46 | int code = sUriMatcher.match(uri); 47 | //匹配URI 48 | if (code == QUERY && selectionArgs != null && selectionArgs.length > 0) { 49 | SQLiteDatabase db = dbHelper.getReadableDatabase(); 50 | //查询value 51 | return db.query(CommonDBHelper.COMMON_TABLE, projection, selection, selectionArgs, null, null, null); 52 | } 53 | if (code == CLEAN_DATA) { 54 | SQLiteDatabase db = dbHelper.getReadableDatabase(); 55 | Cursor cursor = db.query(CommonDBHelper.COMMON_TABLE, projection, selection, selectionArgs, null, null, null); 56 | new Handler(Looper.getMainLooper()).postDelayed(() -> { 57 | String name = selectionArgs[0]; 58 | dbHelper.delete(name); 59 | }, 200); 60 | return cursor; 61 | } 62 | return null; 63 | } 64 | 65 | 66 | @Nullable 67 | @Override 68 | public String getType(@NonNull Uri uri) { 69 | return null; 70 | } 71 | 72 | @Nullable 73 | @Override 74 | public Uri insert(@NonNull Uri uri, @Nullable ContentValues values) { 75 | int code = sUriMatcher.match(uri); 76 | if (code == INSERT) { 77 | // dbHelper.insert(values); 78 | } 79 | // getContext().getContentResolver().notifyChange(uri, null); 80 | return null; 81 | } 82 | 83 | @Override 84 | public int delete(@NonNull Uri uri, @Nullable String selection, @Nullable String[] selectionArgs) { 85 | return 0; 86 | } 87 | 88 | @Override 89 | public int update(@NonNull Uri uri, @Nullable ContentValues values, @Nullable String selection, @Nullable String[] selectionArgs) { 90 | return 0; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /app/src/main/java/top/linl/qstorycloud/util/SpHelper.kt: -------------------------------------------------------------------------------- 1 | package top.linl.qstorycloud.util 2 | 3 | import com.alibaba.fastjson2.JSON 4 | import com.alibaba.fastjson2.TypeReference 5 | import io.fastkv.FastKV 6 | 7 | 8 | /** 9 | * mmkv封装类 10 | */ 11 | class SpHelper(id: String = "default") { 12 | private var kv = FastKV.Builder(storePath, id).build() 13 | 14 | companion object { 15 | private var storePath = "" 16 | fun initialize(path: String) { 17 | storePath = path 18 | } 19 | } 20 | 21 | /** 22 | * 保存数据的方法 23 | * @param key 24 | * @param value 25 | */ 26 | fun put(key: String, value: Any) { 27 | when (value) { 28 | is String -> { 29 | kv.putString(key, value) 30 | } 31 | 32 | is Int -> { 33 | kv.putInt(key, value) 34 | } 35 | 36 | is Boolean -> { 37 | kv.putBoolean(key, value) 38 | } 39 | 40 | is Float -> { 41 | kv.putFloat(key, value) 42 | } 43 | 44 | is Long -> { 45 | kv.putLong(key, value) 46 | } 47 | 48 | is Double -> { 49 | kv.putDouble(key, value) 50 | } 51 | 52 | is ByteArray -> { 53 | kv.putArray(key, value) 54 | } 55 | 56 | else -> { 57 | kv.putString(key, JSON.toJSONString(value)) 58 | } 59 | } 60 | } 61 | 62 | 63 | /** 64 | * 得到保存数据的方法,我们根据默认值得到保存的数据的具体类型,然后调用相对于的方法获取值 65 | */ 66 | fun getInt(key: String, def: Int = 0): Int { 67 | return kv.getInt(key, def) 68 | } 69 | 70 | fun getDouble(key: String, def: Double = 0.00): Double { 71 | return kv.getDouble(key, def) 72 | } 73 | 74 | fun getLong(key: String, def: Long = 0L): Long { 75 | return kv.getLong(key, def) 76 | } 77 | 78 | fun getBoolean(key: String, def: Boolean): Boolean { 79 | return kv.getBoolean(key, def) 80 | } 81 | 82 | fun getFloat(key: String, def: Float = 0f): Float { 83 | return kv.getFloat(key, def) 84 | } 85 | 86 | fun getBytes(key: String, def: ByteArray = byteArrayOf()): ByteArray { 87 | return kv.getArray(key, def) 88 | } 89 | 90 | fun getString(key: String, def: String = ""): String { 91 | return kv.getString(key, def) ?: "" 92 | } 93 | 94 | 95 | fun getObject(key: String, type: Class): T? { 96 | val data = kv.getString(key) 97 | if (data.isNullOrEmpty()) { 98 | return null 99 | } 100 | return JSON.parseObject(data, type) 101 | } 102 | 103 | fun getType(key: String, def: T): T { 104 | val data = kv.getString(key) 105 | if (data.isNullOrEmpty()) { 106 | return def 107 | } 108 | val type = object : TypeReference() {} 109 | return JSON.parseObject(data, type) 110 | } 111 | 112 | fun getType(key: String, type: TypeReference): T? { 113 | val data = kv.getString(key) 114 | if (data.isNullOrEmpty()) { 115 | return null 116 | } 117 | return JSON.parseObject(data, type) 118 | } 119 | 120 | /** 121 | * 清除所有key 122 | */ 123 | fun clearAll() { 124 | kv.clear() 125 | } 126 | 127 | fun remove(key: String) { 128 | kv.remove(key) 129 | } 130 | 131 | /** 132 | * 获取所有key 133 | */ 134 | fun getAllKeys(): MutableSet { 135 | return kv.all.keys 136 | } 137 | 138 | /** 139 | * 是否包含某个key 140 | */ 141 | fun containKey(key: String): Boolean { 142 | return kv.contains(key) 143 | } 144 | 145 | } -------------------------------------------------------------------------------- /app/src/main/java/top/linl/qstorycloud/util/TaskManager.java: -------------------------------------------------------------------------------- 1 | package top.linl.qstorycloud.util; 2 | 3 | import android.os.Handler; 4 | import android.os.Looper; 5 | 6 | public class TaskManager { 7 | public static void addTask(Runnable runnable) 8 | { 9 | new Handler(Looper.getMainLooper()) 10 | .post(runnable); 11 | } 12 | //添加延时任务 13 | public static void addDelayTask(Runnable runnable, long delay) 14 | { 15 | new Handler(Looper.getMainLooper()) 16 | .postDelayed(runnable, delay); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/src/main/java/top/sacz/qstory/config/ModuleConfig.kt: -------------------------------------------------------------------------------- 1 | package top.sacz.qstory.config 2 | 3 | import com.alibaba.fastjson2.TypeReference 4 | import top.linl.qstorycloud.util.SpHelper 5 | import top.sacz.qstory.net.UpdateInfo 6 | import top.sacz.qstory.net.bean.ModuleInfo 7 | 8 | object ModuleConfig { 9 | private val sp = SpHelper("UpdateInfoConfig") 10 | 11 | 12 | fun getUpdateInfoList(): List { 13 | val type = object : TypeReference>() {} 14 | return sp.getType("updateInfoList", type) ?: listOf() 15 | } 16 | 17 | fun setUpdateInfoList(updateInfoList: List) { 18 | sp.put("updateInfoList", updateInfoList) 19 | } 20 | 21 | fun clear() { 22 | sp.clearAll() 23 | } 24 | 25 | fun getModuleInfo(): ModuleInfo { 26 | return sp.getObject("moduleInfo", ModuleInfo::class.java) ?: ModuleInfo().apply { 27 | versionCode = 0 28 | } 29 | } 30 | 31 | fun setModuleInfo(moduleInfo: ModuleInfo) { 32 | sp.put("moduleInfo", moduleInfo) 33 | } 34 | 35 | fun isReadUpdateLog(): Boolean { 36 | return sp.getBoolean("isReadUpdateLog:${getModuleInfo().versionCode}", false) 37 | } 38 | 39 | fun setReadUpdateLog() { 40 | sp.put("isReadUpdateLog:${getModuleInfo().versionCode}", true) 41 | } 42 | 43 | } -------------------------------------------------------------------------------- /app/src/main/java/top/sacz/qstory/hook/UpdateTask.kt: -------------------------------------------------------------------------------- 1 | package top.sacz.qstory.hook 2 | 3 | import com.alibaba.fastjson2.JSON 4 | import com.alibaba.fastjson2.TypeReference 5 | import kotlinx.coroutines.DelicateCoroutinesApi 6 | import kotlinx.coroutines.GlobalScope 7 | import kotlinx.coroutines.delay 8 | import kotlinx.coroutines.launch 9 | import okhttp3.FormBody 10 | import okhttp3.OkHttpClient 11 | import okhttp3.Request 12 | import top.linl.qstorycloud.hook.HookEnv 13 | import top.linl.qstorycloud.hook.PathTool 14 | import top.linl.qstorycloud.hook.util.ToastTool 15 | import top.linl.qstorycloud.log.QSLog 16 | import top.sacz.qstory.config.ModuleConfig 17 | import top.sacz.qstory.net.DownloadTask 18 | import top.sacz.qstory.net.HasUpdate 19 | import top.sacz.qstory.net.bean.ModuleInfo 20 | import top.sacz.qstory.net.UpdateInfo 21 | import top.sacz.qstory.net.bean.QSResult 22 | 23 | 24 | class UpdateTask { 25 | 26 | fun init() { 27 | start() 28 | } 29 | 30 | @OptIn(DelicateCoroutinesApi::class) 31 | private fun start() { 32 | GlobalScope.launch { 33 | while (true) { 34 | //是否有更新 35 | hasUpdate()?.apply { 36 | 37 | //有更新或强制更新 38 | if (hasUpdate || isForceUpdate) { 39 | ToastTool.show("[QStory]检测到有新版本") 40 | doUpdate() 41 | } 42 | } 43 | //延迟5分钟 44 | delay(10 * 60 * 1000) 45 | } 46 | } 47 | } 48 | 49 | /** 50 | * 执行更新操作 51 | */ 52 | private fun doUpdate() { 53 | val updateInfoList = getUpdateInfoList() 54 | if (updateInfoList.isEmpty()) { 55 | return 56 | } 57 | //更新到本地 58 | ModuleConfig.setUpdateInfoList(updateInfoList) 59 | //获取最新一条 60 | val updateInfo = updateInfoList.first() 61 | val versionCode = updateInfo.versionCode 62 | //更新链接 63 | val downloadUrl = "https://qstory.sacz.top/update/download?version=$versionCode" 64 | //下载路径 65 | val downloadPath = PathTool.getDataPath() + "/" + updateInfo.fileName 66 | //构造更新 67 | val downloadTask = DownloadTask(HookEnv.getHostAppContext(),updateInfo) 68 | downloadTask.download(downloadUrl, downloadPath) 69 | 70 | //构造信息并存入本地对象 71 | val moduleInfo = ModuleInfo() 72 | moduleInfo.versionCode = versionCode!! 73 | moduleInfo.path = downloadPath 74 | 75 | ModuleConfig.setModuleInfo(moduleInfo) 76 | ToastTool.show("QStory已更新完成,请重启QQ") 77 | } 78 | 79 | private fun getUpdateInfoList(): List { 80 | try { 81 | val client = OkHttpClient().newBuilder() 82 | .build() 83 | val formBody: FormBody = FormBody.Builder() 84 | .add("version", ModuleConfig.getModuleInfo().versionCode.toString()) 85 | .build() 86 | val request: Request = Request.Builder() 87 | .url("https://qstory.sacz.top/update/getUpdateLog") 88 | .method("POST", formBody) 89 | .addHeader("User-Agent", "QStoryCloud/Android") 90 | .addHeader("Accept", "*/*") 91 | .addHeader("Connection", "keep-alive") 92 | .build() 93 | val response = client.newCall(request).execute() 94 | val type = object : TypeReference>>() {} 95 | return JSON.parseObject( 96 | response.body.string(), 97 | type 98 | ).data 99 | } catch (e: Exception) { 100 | ToastTool.show("更新失败$e") 101 | 102 | return mutableListOf() 103 | } 104 | } 105 | 106 | private fun hasUpdate(): HasUpdate? { 107 | try { 108 | val client = OkHttpClient().newBuilder() 109 | .build() 110 | val formBody: FormBody = FormBody.Builder() 111 | .add("version", ModuleConfig.getModuleInfo().versionCode.toString()) 112 | .build() 113 | val request: Request = Request.Builder() 114 | .url("https://qstory.sacz.top/update/hasUpdate") 115 | .method("POST", formBody) 116 | .addHeader("User-Agent", "QStoryCloud/Android") 117 | .addHeader("Accept", "*/*") 118 | .addHeader("Connection", "keep-alive") 119 | .build() 120 | val response = client.newCall(request).execute() 121 | val result = JSON.parseObject(response.body.string()) 122 | response.close() 123 | val data =result.getJSONObject("data") 124 | return JSON.parseObject(data.toString(), HasUpdate::class.java) 125 | } catch (e: Exception) { 126 | return null 127 | } 128 | } 129 | } -------------------------------------------------------------------------------- /app/src/main/java/top/sacz/qstory/hook/ui/UpdateLogDisplay.java: -------------------------------------------------------------------------------- 1 | package top.sacz.qstory.hook.ui; 2 | 3 | import android.app.Activity; 4 | import android.app.AlertDialog; 5 | import android.os.Bundle; 6 | 7 | import java.lang.reflect.Method; 8 | import java.util.List; 9 | 10 | import de.robv.android.xposed.XC_MethodHook; 11 | import de.robv.android.xposed.XposedBridge; 12 | import top.linl.qstorycloud.R; 13 | import top.linl.qstorycloud.hook.util.ActivityTools; 14 | import top.sacz.qstory.config.ModuleConfig; 15 | import top.sacz.qstory.net.UpdateInfo; 16 | 17 | 18 | /** 19 | * 展示更新日志 20 | *

21 | * 这个比较简单 从数据库中查询更新日志和是否已读 22 | */ 23 | public class UpdateLogDisplay { 24 | 25 | public boolean isShow; 26 | 27 | public void hook() { 28 | try { 29 | Method onCreateMethod = Activity.class.getDeclaredMethod("onCreate", Bundle.class); 30 | XposedBridge.hookMethod(onCreateMethod, new XC_MethodHook() { 31 | @Override 32 | protected void afterHookedMethod(MethodHookParam param) throws Throwable { 33 | if (ModuleConfig.INSTANCE.getModuleInfo().getVersionCode() == 0) { 34 | return; 35 | } 36 | if (ModuleConfig.INSTANCE.isReadUpdateLog()) { 37 | return; 38 | } 39 | List updateInfoList = ModuleConfig.INSTANCE.getUpdateInfoList(); 40 | if (updateInfoList.isEmpty()) { 41 | return; 42 | } 43 | showUpdateLog((Activity) param.thisObject, updateInfoList.get(0)); 44 | } 45 | }); 46 | } catch (NoSuchMethodException e) { 47 | throw new RuntimeException(e); 48 | } 49 | } 50 | 51 | public void showUpdateLog(Activity activity, UpdateInfo updateInfo) { 52 | //防止用户选择了左按钮‘喵’ 然后每次打开activity时都会弹更新日志 53 | if (isShow) { 54 | return; 55 | } 56 | isShow = true; 57 | ActivityTools.injectResourcesToContext(activity); 58 | String updateTitle = String.format(activity.getString(R.string.update_log_dialog_title), updateInfo.getVersionName()); 59 | new AlertDialog.Builder(activity, R.style.theme_dialog) 60 | .setTitle(updateTitle) 61 | .setCancelable(false) 62 | .setMessage("更新日志:\n" + updateInfo.getUpdateLog()) 63 | .setPositiveButton( 64 | "确定", (dialog, which) -> { 65 | ModuleConfig.INSTANCE.setReadUpdateLog(); 66 | dialog.dismiss(); 67 | }) 68 | .setNeutralButton("喵", (dialog, which) -> { 69 | dialog.dismiss(); 70 | }) 71 | .show(); 72 | } 73 | 74 | } 75 | -------------------------------------------------------------------------------- /app/src/main/java/top/sacz/qstory/net/DownloadTask.java: -------------------------------------------------------------------------------- 1 | package top.sacz.qstory.net; 2 | 3 | import android.app.ActivityManager; 4 | import android.app.Notification; 5 | import android.app.NotificationChannel; 6 | import android.app.NotificationManager; 7 | import android.app.Service; 8 | import android.content.Context; 9 | 10 | import androidx.core.app.NotificationCompat; 11 | 12 | import java.io.BufferedInputStream; 13 | import java.io.BufferedOutputStream; 14 | import java.io.File; 15 | import java.io.FileOutputStream; 16 | import java.io.IOException; 17 | import java.text.DecimalFormat; 18 | import java.util.List; 19 | 20 | import okhttp3.Call; 21 | import okhttp3.OkHttpClient; 22 | import okhttp3.Request; 23 | import okhttp3.Response; 24 | import top.linl.qstorycloud.R; 25 | import top.linl.qstorycloud.hook.util.ActivityTools; 26 | 27 | 28 | public class DownloadTask { 29 | 30 | private static final String channelId = "QStoryCloud"; 31 | private final int notificationFlag = (int) (System.currentTimeMillis() / 2); 32 | private final Context context; 33 | private final UpdateInfo updateInfo; 34 | private NotificationCompat.Builder builder; 35 | private NotificationManager notificationManager; 36 | 37 | public DownloadTask(Context context, UpdateInfo updateInfo) { 38 | this.context = context; 39 | this.updateInfo = updateInfo; 40 | initializeNotification(); 41 | } 42 | 43 | private boolean isAppForeground(Context context) { 44 | ActivityManager activityManager = 45 | (ActivityManager) context.getSystemService(Service.ACTIVITY_SERVICE); 46 | List runningAppProcessInfoList = 47 | activityManager.getRunningAppProcesses(); 48 | if (runningAppProcessInfoList == null) { 49 | return false; 50 | } 51 | 52 | for (ActivityManager.RunningAppProcessInfo processInfo : runningAppProcessInfoList) { 53 | if (processInfo.processName.equals(context.getPackageName()) 54 | && (processInfo.importance == 55 | ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND)) { 56 | return true; 57 | } 58 | } 59 | return false; 60 | } 61 | 62 | private void initializeNotification() { 63 | // 创建一个通知频道 NotificationChannel 64 | NotificationChannel channel = new NotificationChannel(channelId, "QStoryCloud", NotificationManager.IMPORTANCE_DEFAULT); 65 | //桌面小红点 66 | channel.enableLights(false); 67 | //通知显示 68 | channel.setLockscreenVisibility(Notification.VISIBILITY_PUBLIC); 69 | notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); 70 | notificationManager.createNotificationChannel(channel); 71 | } 72 | 73 | private void sendProgressNotification(int size) { 74 | if (updateInfo == null) { 75 | return; 76 | } 77 | builder = new NotificationCompat.Builder(context, channelId); 78 | ActivityTools.injectResourcesToContext(context); 79 | String contentText = "下载中 大小" + getNetFileSizeDescription(size) + " 切换QQ到后台可查看具体进度"; 80 | if (size == 0) { 81 | contentText = "准备开始下载"; 82 | } 83 | builder.setContentTitle("QStory正在云更新到" + updateInfo.getVersionName()) //设置标题 84 | .setSmallIcon(R.mipmap.ic_launcher_round) //设置小图标 85 | .setPriority(NotificationCompat.PRIORITY_MAX) //设置通知的优先级 86 | .setWhen(System.currentTimeMillis()) 87 | .setAutoCancel(false) //设置通知被点击一次不自动取消 88 | .setOngoing(true) 89 | .setSound(null) 90 | .setContentText(contentText); //设置内容; 91 | notificationManager.notify(notificationFlag, builder.build()); 92 | } 93 | 94 | private void updateNotification(int max, int progress) { 95 | //qq在前台时通知被查看后会自动消失 因此只让qq在后台时通知进度 96 | if (builder == null) { 97 | return; 98 | } 99 | //应用在前台不更新进度 100 | if (isAppForeground(context)) return; 101 | 102 | if (progress >= 0) { 103 | builder.setContentText("进度:" + getNetFileSizeDescription(progress) + "/" + getNetFileSizeDescription(max)); 104 | builder.setProgress(max, progress, false); 105 | } 106 | if (progress == max) { 107 | builder.setContentText("下载完成"); 108 | builder.setAutoCancel(true); 109 | builder.setOngoing(false); 110 | } 111 | notificationManager.notify(notificationFlag, builder.build()); 112 | } 113 | 114 | private void sendDownloadSuccessNotification() { 115 | NotificationCompat.Builder builder = new NotificationCompat.Builder(context, channelId) 116 | .setContentTitle("QStory已更新到" + updateInfo.getVersionName()) //设置标题 117 | .setSmallIcon(R.mipmap.ic_launcher_round) //设置小图标 118 | .setPriority(NotificationCompat.PRIORITY_MAX) //设置通知的优先级 119 | .setAutoCancel(false) //设置通知被点击一次不自动取消 120 | .setOngoing(true) 121 | .setContentText("请手动重启QQ,模块将会生效") //设置内容 122 | ; 123 | notificationManager.notify(notificationFlag + 0xFF, builder.build()); 124 | } 125 | 126 | private String getNetFileSizeDescription(long size) { 127 | StringBuffer bytes = new StringBuffer(); 128 | DecimalFormat format = new DecimalFormat("###.0"); 129 | if (size >= 1024 * 1024 * 1024) { 130 | double i = (size / (1024.0 * 1024.0 * 1024.0)); 131 | bytes.append(format.format(i)).append("GB"); 132 | } else if (size >= 1024 * 1024) { 133 | double i = (size / (1024.0 * 1024.0)); 134 | bytes.append(format.format(i)).append("MB"); 135 | } else if (size >= 1024) { 136 | double i = (size / (1024.0)); 137 | bytes.append(format.format(i)).append("KB"); 138 | } else { 139 | if (size <= 0) { 140 | bytes.append("0B"); 141 | } else { 142 | bytes.append((int) size).append("B"); 143 | } 144 | } 145 | return bytes.toString(); 146 | } 147 | 148 | 149 | /** 150 | * 真正被外部调用的下载方法 151 | * 152 | * @param url 下载链接 153 | * @param path 路径 154 | */ 155 | public void download(String url, String path) throws IOException { 156 | File downloadPath = new File(path); 157 | if (!downloadPath.getParentFile().exists()) { 158 | downloadPath.getParentFile().mkdirs(); 159 | } 160 | if (downloadPath.exists()) { 161 | downloadPath.delete(); 162 | } 163 | if (!downloadPath.exists()) { 164 | downloadPath.createNewFile(); 165 | } 166 | sendProgressNotification(0); 167 | 168 | OkHttpClient client = new OkHttpClient.Builder().build(); 169 | Request request = new Request 170 | .Builder() 171 | .url(url) 172 | .addHeader("User-Agent", "Android") 173 | .addHeader("Accept", "*/*") 174 | .addHeader("Connection", "keep-alive") 175 | .get() 176 | .build(); 177 | Call call = client.newCall(request); 178 | try (Response response = call.execute(); 179 | BufferedInputStream bufIn = new BufferedInputStream(response.body().byteStream()); 180 | BufferedOutputStream bufOut = new BufferedOutputStream(new FileOutputStream(downloadPath))) { 181 | //总字节数 182 | long size = response.body().contentLength(); 183 | sendProgressNotification((int) size); 184 | //发送通知 185 | long downloadSize = 0; 186 | int len; 187 | byte[] buf = new byte[2048];//2k 188 | while ((len = bufIn.read(buf)) != -1) { 189 | bufOut.write(buf, 0, len); 190 | downloadSize += len; 191 | updateNotification((int) size, (int) downloadSize); 192 | } 193 | bufOut.flush(); 194 | } 195 | sendDownloadSuccessNotification(); 196 | } 197 | 198 | } 199 | -------------------------------------------------------------------------------- /app/src/main/java/top/sacz/qstory/net/Update.kt: -------------------------------------------------------------------------------- 1 | package top.sacz.qstory.net 2 | 3 | import java.util.Date 4 | 5 | 6 | data class HasUpdate( 7 | var hasUpdate: Boolean, 8 | var isForceUpdate: Boolean, 9 | var version: Int 10 | ) 11 | 12 | data class UpdateInfo( 13 | var fileName: String?=null, 14 | var updateLog: String?=null, 15 | var versionCode: Int?=null, 16 | var versionName: String?=null, 17 | var time: Date = Date(), 18 | var forceUpdate: Boolean = false 19 | ) -------------------------------------------------------------------------------- /app/src/main/java/top/sacz/qstory/net/bean/ModuleInfo.kt: -------------------------------------------------------------------------------- 1 | package top.sacz.qstory.net.bean 2 | 3 | class ModuleInfo { 4 | var path: String? = null 5 | var versionCode: Int = 0 6 | } -------------------------------------------------------------------------------- /app/src/main/java/top/sacz/qstory/net/bean/QSResult.java: -------------------------------------------------------------------------------- 1 | package top.sacz.qstory.net.bean; 2 | 3 | 4 | import androidx.annotation.NonNull; 5 | 6 | import com.alibaba.fastjson2.JSON; 7 | 8 | import java.io.Serializable; 9 | 10 | public class QSResult implements Serializable { 11 | 12 | private int code; 13 | private String msg; 14 | private int action; 15 | private T data; 16 | 17 | 18 | public int getCode() { 19 | return code; 20 | } 21 | 22 | public QSResult setCode(int code) { 23 | this.code = code; 24 | return this; 25 | } 26 | 27 | public String getMsg() { 28 | return msg; 29 | } 30 | 31 | public QSResult setMsg(String msg) { 32 | this.msg = msg; 33 | return this; 34 | } 35 | 36 | public int getAction() { 37 | return action; 38 | } 39 | 40 | public QSResult setAction(int action) { 41 | this.action = action; 42 | return this; 43 | } 44 | 45 | public T getData() { 46 | return data; 47 | } 48 | 49 | public QSResult setData(T data) { 50 | this.data = data; 51 | return this; 52 | } 53 | 54 | 55 | public boolean isSuccess() { 56 | //打印调用站 57 | return this.getCode() == 200; 58 | } 59 | 60 | 61 | @NonNull 62 | @Override 63 | public String toString() { 64 | return JSON.toJSONString(this); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/suzhelan/QStoryCloud/8b91ae87bbe659ae34be8db60a8b89ac2ed0aabb/app/src/main/res/drawable/github.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/telegram_logo.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/suzhelan/QStoryCloud/8b91ae87bbe659ae34be8db60a8b89ac2ed0aabb/app/src/main/res/drawable/telegram_logo.webp -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 12 | 13 | 19 | 20 | -------------------------------------------------------------------------------- /app/src/main/res/layout/fragment_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 18 | 19 | 32 | 33 | 38 | 39 | 45 | 46 | 54 | 55 | 63 | 64 | 65 | 66 | 67 | 80 | 81 | 86 | 87 | 93 | 94 | 101 | 102 | 103 | 104 | 105 | 118 | 119 | 123 | 124 | 125 | 133 | 134 | 143 | 144 | 151 | 152 | 159 | 160 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/suzhelan/QStoryCloud/8b91ae87bbe659ae34be8db60a8b89ac2ed0aabb/app/src/main/res/mipmap/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/values-night/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/values/arrays.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.tencent.mobileqq 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | //灰色 4 | #93939D 5 | 6 | #ABABAB 7 | #66A4D7 8 | #3F51B5 9 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | QStory-自动云更新 3 | QStoryCloud Github 4 | Telegram频道 5 | 6 | 1.使用教程:将模块通过任意方式挂载到LSPosed,LSPatch内置/本地模式作用于QQ\n\n 7 | 2.如果你不了解上述的框架,可以先进行搜索LSPosed,LSPatch了解其作用\n\n 8 | 3.选择作用至QQ时,会自动拉取云端模块更新,显示在QQ通知栏中,下载完成重启即可 9 | 10 | 安全模式(开启后QQ不会挂载QStory模块,需要重启QQ) 11 | 重置更新数据,重新获取更新(执行后此开关会自动关闭) 12 | 13 | ps:本界面所有功能需要确保本模块处于后台运行状态\n\n 14 | 留言:此模块仅用作示例学习,可点击查看上方Github卡片查看源码(需要科学上网)\n 15 | @github Suzhelan\n 16 | @github Suzhelan/QStoryCloud 17 | 18 | 19 | 自动检测QStory的更新,并且自动更新加载到QQ,减少用户手动更新的麻烦 20 | QStory加载器版本为:%s , 云端模块版本为:%s 21 | QStory已更新到%s 22 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 | plugins { 3 | id 'com.android.application' version '8.6.1' apply false 4 | id 'org.jetbrains.kotlin.android' version '2.0.20' apply false 5 | } -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app's APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Enables namespacing of each library's R class so that its R class includes only the 19 | # resources declared in the library itself and none from the library's dependencies, 20 | # thereby reducing the size of the R class for that library 21 | android.nonTransitiveRClass=true -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/suzhelan/QStoryCloud/8b91ae87bbe659ae34be8db60a8b89ac2ed0aabb/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Mon May 15 03:48:19 CST 2023 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip 5 | zipStoreBase=GRADLE_USER_HOME 6 | zipStorePath=wrapper/dists 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | google() 4 | mavenCentral() 5 | mavenLocal() 6 | gradlePluginPortal() 7 | maven { url 'https://maven.aliyun.com/repository/public/' } 8 | maven { url 'https://maven.aliyun.com/repository/google/' } 9 | maven { url 'https://maven.aliyun.com/repository/gradle-plugin/' } 10 | maven { url 'https://dl.bintray.com/ppartisan/maven/' } 11 | maven { url "https://clojars.org/repo/" } 12 | maven { url "https://jitpack.io" } 13 | } 14 | } 15 | dependencyResolutionManagement { 16 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 17 | repositories { 18 | google() 19 | mavenCentral() 20 | mavenLocal() 21 | maven { url 'https://maven.aliyun.com/repository/public/' } 22 | maven { url 'https://maven.aliyun.com/repository/google/' } 23 | maven { url 'https://maven.aliyun.com/repository/gradle-plugin/' } 24 | maven { url 'https://dl.bintray.com/ppartisan/maven/' } 25 | maven { url "https://clojars.org/repo/" } 26 | maven { url "https://jitpack.io" } 27 | maven { url("https://api.xposed.info/") } 28 | } 29 | } 30 | 31 | rootProject.name = "QStory-Cloud" 32 | include ':app' 33 | --------------------------------------------------------------------------------