diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..c2820b2 --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,92 @@ +name: Android CI + +on: + push: + branches: [ "main", "v*/**"] + pull_request: + branches: [ "main" , "v*/**"] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + + - name: Install python pip dependencies + run: | + python -m pip install --upgrade pip + pip install requests + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + + - name: Prepare Env + id: Prepare + run: | + GIT_COMMIT=$(git rev-parse HEAD) + github_run_url=$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID + echo "GIT_COMMIT=$GIT_COMMIT$">> $GITHUB_ENV + echo "GITHUB_RUN_URL=$github_run_url">> $GITHUB_ENV + echo "GIT_BRANCH=`git branch --show-current`" >> $GITHUB_ENV + chmod +x robot.py + + # debug + - name: Dump Env + run: env | sort + + - name: Dump GitHub context + env: + GITHUB_CONTEXT: ${{ toJson(github) }} + run: | + echo "$GITHUB_CONTEXT" + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Grant execute permission for gradlew + run: chmod +x gradlew +# - name: Build with Gradle +# run: ./gradlew build + - name: Build with Gradle + uses: gradle/gradle-build-action@v2 + with: + gradle-version: wrapper + arguments: build + + - name: Prepare Artifact + if: success() + id: prepareArtifact + run: | + releaseArtifactName=`ls app/build/outputs/apk/release/*.apk | grep -Po "(?<=release/).*(?=\.apk)"` && echo "releaseArtifactName=$releaseArtifactName" >> $GITHUB_OUTPUT + debugArtifactName=`ls app/build/outputs/apk/debug/*.apk | grep -Po "(?<=debug/).*(?=\.apk)"` && echo "debugArtifactName=$debugArtifactName" >> $GITHUB_OUTPUT + + - name: Upload Archive Release-Apk + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.prepareArtifact.outputs.releaseArtifactName }} + path: app/build/outputs/apk/release/*.apk + + - name: Upload Archive Debug-Apk + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.prepareArtifact.outputs.debugArtifactName }} + path: ./app/build/outputs/apk/debug/*.apk + + - name: Post Message + env: + CHAT_ID: ${{ secrets.TG_CI_CHAT_ID }} + BOT_TOKEN: ${{ secrets.TG_CI_BOT_TOKEN }} + run: | + echo "github_run_url=$GITHUB_RUN_URL" + echo '${{ toJson(github) }}'> github_context.txt + if [ -f "robot.py" ]; then + python robot.py -e ci + fi +# curl -X POST "https://api.telegram.org/bot$BOT_TOKEN/sendMessage" -d "chat_id=$CHAT_ID&text=CI编译完成${github_run_url}" + diff --git a/.github/workflows/release-post.yml b/.github/workflows/release-post.yml new file mode 100644 index 0000000..92dfc8b --- /dev/null +++ b/.github/workflows/release-post.yml @@ -0,0 +1,61 @@ +name: Release-Robot + +on: + release: + types: [ published, edited ] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [ "3.10" ] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + # debug + - name: Dump Env + run: env | sort + + - name: Dump GitHub context + env: + GITHUB_CONTEXT: ${{ toJson(github) }} + run: | + echo "$GITHUB_CONTEXT" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install requests + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + + # GIT_TAG=$(git describe --exact-match --abbrev=0 --tags "${COMMIT}" 2> /dev/null || true) + # https://github.com/actions/actions-runner-controller/blob/acbce4b70ad1cf719f3bab94e127ca38a1576228/hack/make-env.sh + # GIT_TAG=${GITHUB_REF/refs\/tags\//} + # 这个方式在commit没有tag时,结果类似于: refs/heads/main + - name: Prepare Env + id: Prepare + run: | + GIT_TAG=${GITHUB_REF/refs\/tags\//} + GIT_COMMIT=$(git rev-parse HEAD) + echo "GIT_COMMIT=$GIT_COMMIT$">> $GITHUB_ENV + echo "GIT_TAG=$GIT_TAG">> $GITHUB_ENV + echo "GITHUB_RUN_URL=$github_run_url">> $GITHUB_ENV + echo "GIT_BRANCH=`git branch --show-current`" >> $GITHUB_ENV + + - name: Robot Post Message + env: + tag_name: ${{ github.event.release.tag_name }} + chat_id: ${{ secrets.TG_CI_CHAT_ID }} + bot_token: ${{ secrets.TG_CI_BOT_TOKEN }} + github_user: ${{ github.actor }} + github_token: ${{ secrets.GITHUB_TOKEN }} +# github_release: ${{ toJson(github.event.release) }} + run: | + echo "${{ toJson(github) }}"> github_context.txt + python robot.py -e release diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..26e1c03 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties +/.idea/** \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..275080b --- /dev/null +++ b/README.md @@ -0,0 +1,10 @@ +# MaskWechat + +[Source link / 项目地址](https://github.com/Mingyueyixi/PicCatcher) + +反馈问题可点击以上地址,发起issues + + +## 介绍 + +Catch app's picture \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..bead70b --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,200 @@ +import org.json.JSONObject + +import java.text.SimpleDateFormat + +plugins { + id 'com.android.application' + id 'org.jetbrains.kotlin.android' +} +apply { + from rootProject.file('props.gradle') +} +def buildInfoJson = { + def p = "git branch --show-current".execute() + //当前分支 + def currentBranch = p.text.trim() + p.destroy() + //最后1个提交ID + p = "git rev-parse HEAD".execute() + def lastedCommit = p.text.trim() + p.destroy() + + //github等服务器打包时,因时区不同,格式化的时间与本地时间对不上 + //故直接保存毫秒 + def buildTime = System.currentTimeMillis() + + def gitBranch = currentBranch + def gitCommit = lastedCommit; + + def jsonObj = new JSONObject() + jsonObj.put("time", buildTime) + jsonObj.put("branch", gitBranch) + jsonObj.put("commit", gitCommit) + return jsonObj.toString() +}() + +def writeBuildInfoJson(dirPath, jsonText) { + println "----------Commit Info------------" + + def buildInfoPath = "$dirPath/build_info.json".replace("\\", "/") + def jsonFile = file(buildInfoPath) + jsonFile.parentFile.mkdirs() + jsonFile.withWriter('utf-8') { + it.write(jsonText) + } + println "commit info file: $buildInfoPath" + +} + +this.afterEvaluate { + project.tasks.forEach { + if (it.name.startsWith("process") && it.name.endsWith("JavaRes")) { + //将构建信息打包到apk中,这个方法似乎不太靠谱,改为写入buildConfig + it.doLast { + println(">>> ${it.name} attach build json") + writeBuildInfoJson(it.destinationDir.path, buildInfoJson) + } + } + } + +} + +android { + namespace 'com.pic.catcher' + compileSdk 35 + + defaultConfig { + applicationId "com.pic.catcher" + minSdk 24 + targetSdk 35 + versionCode 1001 + versionName "1.0-dev" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + + buildConfigField "int", "VERSION_CODE", "${versionCode}" + buildConfigField "String", "VERSION_NAME", "\"${versionName}\"" + buildConfigField "String", "buildInfoJson64", "\"${buildInfoJson.bytes.encodeBase64()}\"" + } +// sourceSets { +// main{ +// resources{ +// srcDir("src/main/reouserces") +// } +// } +// } + signingConfigs { + alpha { + storeFile file("${rootDir}/app/key-store-test.jks") + storePassword "123456" + keyAlias "piccatch" + keyPassword "123456" + } + } + buildTypes { + debug { + debuggable true + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + signingConfig signingConfigs.alpha + } + release { + debuggable false + minifyEnabled true + shrinkResources true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + signingConfig signingConfigs.alpha + } + + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = '1.8' + } + + viewBinding { + enabled = true + } + + applicationVariants.all { variant -> + def outputs = variant.outputs + def size = outputs.size() + def commit = new JSONObject(buildInfoJson).optString("commit").substring(0, 6) + outputs.eachWithIndex { Object output, int index -> + def fontName = "maskwechat-v${variant.versionName}-${commit}-${variant.name}" + if (size == 1) { + output.outputFileName = "${fontName}.apk" + } else { + output.outputFileName = "${fontName}-${index}.apk" + } + } +// variant.properties.each { +// println("${it.key} --》 ${it.value}") +// } +// println("------------"+ variant.outputs) + +// testOptions { +// //使javaTest可以使用外部库的代码,例如android.jar +// //但是,api实际没有结果。 +// unitTests.returnDefaultValues = true +// } + } + buildFeatures { + buildConfig = true + } +} + +configurations.all { + resolutionStrategy { + force 'androidx.core:core:1.13.1' + force 'androidx.arch.core:core-runtime:2.2.0' + force 'androidx.lifecycle:lifecycle-runtime:2.6.2' + force 'androidx.lifecycle:lifecycle-livedata-core:2.6.2' + force 'androidx.lifecycle:lifecycle-livedata:2.6.2' + force 'androidx.lifecycle:lifecycle-process:2.6.2' + force 'androidx.activity:activity:1.5.1' + force 'androidx.annotation:annotation-experimental:1.4.0' + force 'org.jetbrains:annotations:23.0.0' + force 'androidx.collection:collection:1.1.0' + force 'androidx.concurrent:concurrent-futures:1.1.0' + force 'androidx.arch.core:core-common:2.2.0' + force 'androidx.test:core:1.5.0' + force 'com.google.code.gson:gson:2.10.1' + force 'com.google.guava:guava:32.0.1-jre' + force 'junit:junit:4.13.2' + force 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.20' + force 'org.jetbrains.kotlin:kotlin-stdlib-common:2.0.20' + force 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.0' + force 'org.jetbrains.kotlin:kotlin-stdlib:1.9.20' + force 'androidx.test:monitor:1.6.0' + force 'com.google.protobuf:protobuf-java:3.22.3' + force 'androidx.startup:startup-runtime:1.1.1' + } +} + + + +dependencies { + implementation(fileTree(dir: "libs", include: ["*.jar", "*.aar"])) + implementation(deps['com.github.Mingyueyixi.frame-base-utils:core']) + implementation(deps['com.github.Mingyueyixi.frame-ui:ui-appcompat']){ +// exclude(group:'androidx.core', module:'core') + } + implementation(deps['com.github.Mingyueyixi.lposed:xposed-api2']) + implementation(deps['com.github.Mingyueyixi.lposed:plugin']) + implementation(deps['androidx.appcompat:appcompat']) + compileOnly(deps['de.robv.android.xposed:api']) +// compileOnly('com.tencent.wcdb:wcdb-android:1.1-19') + // https://mvnrepository.com/artifact/com.tencent.wcdb/wcdb-android + + // https://mvnrepository.com/artifact/org.json/json + // testImplementation 'org.json:json:20220924' + + testImplementation(deps['junit:junit']) + androidTestImplementation(deps['androidx.test.ext:junit']) + androidTestImplementation(deps['androidx.test.espresso:espresso-core']) +} \ No newline at end of file diff --git a/app/key-store-test.jks b/app/key-store-test.jks new file mode 100644 index 0000000..8a06ed9 Binary files /dev/null and b/app/key-store-test.jks differ diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/com/lu/mask/wx/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/lu/mask/wx/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..29805fa --- /dev/null +++ b/app/src/androidTest/java/com/lu/mask/wx/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.lu.mask.wx + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.lu.mask.wx", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..cc3c77f --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/assets/xposed_init b/app/src/main/assets/xposed_init new file mode 100644 index 0000000..9e3831d --- /dev/null +++ b/app/src/main/assets/xposed_init @@ -0,0 +1 @@ +com.lu.wxmask.MainHook diff --git a/app/src/main/java/com/pic/catcher/App.java b/app/src/main/java/com/pic/catcher/App.java new file mode 100644 index 0000000..39eb835 --- /dev/null +++ b/app/src/main/java/com/pic/catcher/App.java @@ -0,0 +1,45 @@ +package com.pic.catcher; + +import android.app.Application; + +import androidx.lifecycle.ViewModelStore; +import androidx.lifecycle.ViewModelStoreOwner; + +import com.pic.catcher.ui.JsonMenuManager; +import com.pic.catcher.ui.JsonMenuManager; + +import org.jetbrains.annotations.NotNull; + +public final class App extends Application implements ViewModelStoreOwner { + public static final Companion Companion = new Companion(); + public static App instance; + + public void onCreate() { + super.onCreate(); + Companion.setInstance(this); + JsonMenuManager.Companion.updateMenuListFromRemote(this); + } + + @Override + public ViewModelStore getViewModelStore() { + return new ViewModelStore(); + } + + public static final class Companion { + private Companion() { + } + + public final App getInstance() { + App app = instance; + if (app != null) { + return app; + } + return null; + } + + public final void setInstance(@NotNull App app) { + instance = app; + } + + } +} diff --git a/app/src/main/java/com/pic/catcher/ClazzN.kt b/app/src/main/java/com/pic/catcher/ClazzN.kt new file mode 100644 index 0000000..45107cb --- /dev/null +++ b/app/src/main/java/com/pic/catcher/ClazzN.kt @@ -0,0 +1,14 @@ +package com.pic.catcher + +import com.lu.lposed.api2.XposedHelpers2 +import com.lu.magic.util.AppUtil + +interface ClazzN { + companion object { + @JvmStatic + @JvmOverloads + fun from(clazz: String, classLoader: ClassLoader = AppUtil.getContext().classLoader): Class<*>? { + return XposedHelpers2.findClassIfExists(clazz, classLoader) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pic/catcher/MainHook.java b/app/src/main/java/com/pic/catcher/MainHook.java new file mode 100644 index 0000000..9a6a1f7 --- /dev/null +++ b/app/src/main/java/com/pic/catcher/MainHook.java @@ -0,0 +1,239 @@ +package com.pic.catcher; + +import android.app.Application; +import android.app.Instrumentation; +import android.content.Context; +import android.os.Process; + +import androidx.annotation.Keep; +import androidx.annotation.NonNull; + +import com.lu.lposed.api2.XC_MethodHook2; +import com.lu.lposed.api2.XposedHelpers2; +import com.lu.lposed.plugin.PluginRegistry; +import com.lu.magic.util.AppUtil; +import com.lu.magic.util.log.LogUtil; +import com.lu.magic.util.log.SimpleLogger; +import com.pic.catcher.plugin.GlideCatcherPlugin; +import com.pic.catcher.BuildConfig; +import com.pic.catcher.plugin.GlideCatcherPlugin; + + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.concurrent.CopyOnWriteArraySet; + +import de.robv.android.xposed.IXposedHookInitPackageResources; +import de.robv.android.xposed.IXposedHookLoadPackage; +import de.robv.android.xposed.IXposedHookZygoteInit; +import de.robv.android.xposed.XC_MethodHook; +import de.robv.android.xposed.callbacks.XC_InitPackageResources; +import de.robv.android.xposed.callbacks.XC_LoadPackage; + +@Keep +public class MainHook implements IXposedHookLoadPackage, IXposedHookZygoteInit, IXposedHookInitPackageResources { + private static final String TARGET_PACKAGE = "com.tencent.mm"; + public static CopyOnWriteArraySet uniqueMetaStore = new CopyOnWriteArraySet<>(); + private boolean hasInit = false; + private List initUnHookList = new ArrayList<>(); + private static String MODULE_PATH = null; + private boolean isHookEntryHandle = false; + + @Override + public void handleLoadPackage(XC_LoadPackage.LoadPackageParam lpparam) throws Throwable { +// if (BuildConfig.APPLICATION_ID.equals(lpparam.packageName)) { +// SelfHook.getInstance().handleLoadPackage(lpparam); +// return; +// } + + if (isHookEntryHandle) { + return; + } + isHookEntryHandle = true; + + HashSet allowList = new HashSet<>(); + allowList.add(BuildConfig.APPLICATION_ID); + allowList.add(TARGET_PACKAGE); + + if (!allowList.contains(lpparam.processName)) { + return; + } + + LogUtil.setLogger(new SimpleLogger() { + @Override + public void onLog(int level, @NonNull Object[] objects) { + if (BuildConfig.DEBUG) { + super.onLog(level, objects); + } else { + //release 打印i以上级别的log,其他的忽略 + if (level > 1) { + String msgText = buildLogText(objects); + XposedHelpers2.log(TAG + " " + msgText); + } + } + } + }); + LogUtil.i("start main plugin for wechat", lpparam.processName, Process.myPid()); + XposedHelpers2.Config + .setCallMethodWithProxy(true) + .setThrowableCallBack(throwable -> LogUtil.w("MaskPlugin error", throwable)) + .setOnErrorReturnFallback((method, throwable) -> { + Class returnType = method.getReturnType(); + // 函数执行错误时,给定一个默认的返回值值。 + // 没什么鸟用。xposed api就没有byte/short/int/long/这些基本类型的返回值函数 + if (String.class.equals(returnType) || CharSequence.class.isAssignableFrom(returnType)) { + return ""; + } + if (Integer.TYPE.equals(returnType) || Integer.class.equals(returnType)) { + return 0; + } + if (Long.TYPE.equals(returnType) || Long.class.equals(returnType)) { + return 0L; + } + if (Double.TYPE.equals(returnType) || Double.class.equals(returnType)) { + return 0d; + } + if (Float.TYPE.equals(returnType) || Float.class.equals(returnType)) { + return 0f; + } + if (Byte.TYPE.equals(returnType) || Byte.class.equals(returnType)) { + return new byte[]{}; + } + if (Short.TYPE.equals(returnType) || Short.class.equals(returnType)) { + return (short) 0; + } + if (BuildConfig.DEBUG) { + LogUtil.w("setOnErrorReturnFallback", throwable); + } + return null; + }); + + XC_MethodHook.Unhook unhook = XposedHelpers2.findAndHookMethod( + Application.class.getName(), + lpparam.classLoader, + "onCreate", + new XC_MethodHook() { + @Override + protected void afterHookedMethod(MethodHookParam param) throws Throwable { + initPlugin((Context) param.thisObject, lpparam); + } + } + ); + initUnHookList.add(unhook); + +// initHookCallBack = XposedHelpers2.findAndHookMethod( +// Activity.class.getName(), +// lpparam.classLoader, +// "onCreate", +// Bundle.class, +// new XC_MethodHook() { +// @Override +// protected void beforeHookedMethod(MethodHookParam param) throws Throwable { +// initPlugin(((Activity) param.thisObject).getApplicationContext(), lpparam); +// } +// } +// ); +// + //"com.tencent.mm.app.com.Application"的父类 + //"tencent.tinker.loader.app.TinkerApplication" + +// XposedHelpers2.findAndHookMethod( +// Application.class.getName(), +// lpparam.classLoader, +// "attach", +// Context.class.getName(), +// new XC_MethodHook() { +// @Override +// protected void afterHookedMethod(MethodHookParam param) throws Throwable { +// initPlugin((Context) param.args[0], lpparam); +// } +// } +// ); +// + unhook = XposedHelpers2.findAndHookMethod( + Instrumentation.class.getName(), + lpparam.classLoader, + "callApplicationOnCreate", + Application.class.getName(), + new XC_MethodHook2() { + @Override + protected void afterHookedMethod(MethodHookParam param) throws Throwable { + initPlugin((Context) param.args[0], lpparam); + } + } + ); + initUnHookList.add(unhook); +// +// XposedHelpers2.findAndHookMethod( +// Activity.class.getName(), +// lpparam.classLoader, +// "onCreate", +// Bundle.class.getName(), +// new XC_MethodHook2() { +// @Override +// protected void beforeHookedMethod(MethodHookParam param) throws Throwable { +// initPlugin((Context) param.thisObject, lpparam); +// } +// } +// ); +// + + } + + private void initPlugin(Context context, XC_LoadPackage.LoadPackageParam lpparam) { + if (context == null) { + LogUtil.w("context is null"); + return; + } + if (hasInit) { + return; + } + LogUtil.i("start init Plugin"); + hasInit = true; + AppUtil.attachContext(context); + + if (BuildConfig.APPLICATION_ID.equals(lpparam.packageName)) { + initSelfPlugins(context, lpparam); + } else { + initTargetPlugins(context, lpparam); + } + + for (XC_MethodHook.Unhook unhook : initUnHookList) { + if (unhook != null) { + unhook.unhook(); + } + } + LogUtil.i("init plugin finish"); + } + + private void initSelfPlugins(Context context, XC_LoadPackage.LoadPackageParam lpparam) { + SelfHook.getInstance().handleHook(context, lpparam); + } + + private void initTargetPlugins(Context context, XC_LoadPackage.LoadPackageParam lpparam) { + //目前生成的plugin都是单例的 + PluginRegistry.register( + GlideCatcherPlugin.class + ).handleHooks(context, lpparam); + + + } + + @Override + public void initZygote(StartupParam startupParam) throws Throwable { + MODULE_PATH = startupParam.modulePath; + } + + @Override + public void handleInitPackageResources(XC_InitPackageResources.InitPackageResourcesParam resparam) throws Throwable { + if (BuildConfig.APPLICATION_ID.equals(resparam.packageName)) { + return; + } + if (TARGET_PACKAGE.equals(resparam.packageName)) { +// XModuleResources xRes = XModuleResources.createInstance(MODULE_PATH, resparam.res); +// Rm.mask_layout_plugin_manager = resparam.res.addResource(xRes, R.layout.mask_layout_plugin_manager); + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/pic/catcher/MaskAppBuildInfo.kt b/app/src/main/java/com/pic/catcher/MaskAppBuildInfo.kt new file mode 100644 index 0000000..56cba9b --- /dev/null +++ b/app/src/main/java/com/pic/catcher/MaskAppBuildInfo.kt @@ -0,0 +1,25 @@ +package com.pic.catcher + +import android.util.Base64 +import com.pic.catcher.BuildConfig +import org.json.JSONObject +import java.text.SimpleDateFormat +import java.util.Locale + +class MaskAppBuildInfo( + val buildTime: String, + val branch: String, + val commit: String, +) { + companion object { + fun of(): com.pic.catcher.MaskAppBuildInfo { + val json = JSONObject(Base64.decode(BuildConfig.buildInfoJson64, Base64.DEFAULT).toString(Charsets.UTF_8)) + return com.pic.catcher.MaskAppBuildInfo( + SimpleDateFormat("yyyy/MM/dd HH:mm:ss", Locale.getDefault()).format(json.optLong("time")), + json.optString("branch"), + json.optString("commit").substring(0, 11) + ) + } + } +} + diff --git a/app/src/main/java/com/pic/catcher/SelfHook.java b/app/src/main/java/com/pic/catcher/SelfHook.java new file mode 100644 index 0000000..20d90d0 --- /dev/null +++ b/app/src/main/java/com/pic/catcher/SelfHook.java @@ -0,0 +1,46 @@ +package com.pic.catcher; + +import android.content.Context; + +import androidx.annotation.Keep; + +import com.lu.lposed.plugin.IPlugin; + +import de.robv.android.xposed.XC_MethodHook; +import de.robv.android.xposed.XposedHelpers; +import de.robv.android.xposed.callbacks.XC_LoadPackage; + +@Keep +public class SelfHook implements IPlugin { + + + private final static class Holder { + private static final SelfHook INSTANCE = new SelfHook(); + } + + public static SelfHook getInstance() { + return Holder.INSTANCE; + } + + //自己hook自己,改变其值,说明模块有效 + public boolean isModuleEnable() { + return false; + } + + @Override + public void handleHook(Context context, XC_LoadPackage.LoadPackageParam lpparam) { + XposedHelpers.findAndHookMethod( + SelfHook.class.getName(), + lpparam.classLoader, + "isModuleEnable", + new XC_MethodHook() { + @Override + protected void beforeHookedMethod(MethodHookParam param) throws Throwable { + param.setResult(true); + } + } + ); + + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/pic/catcher/adapter/AbsListAdapter.kt b/app/src/main/java/com/pic/catcher/adapter/AbsListAdapter.kt new file mode 100644 index 0000000..3db2e2b --- /dev/null +++ b/app/src/main/java/com/pic/catcher/adapter/AbsListAdapter.kt @@ -0,0 +1,31 @@ +package com.pic.catcher.adapter + +import android.view.View +import android.view.ViewGroup +import android.widget.BaseAdapter + + +abstract class AbsListAdapter : BaseAdapter() { + + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val vh = if (convertView == null) { + onCreateViewHolder(parent, getItemViewType(position)).also { + it.itemView.tag = it + } + } else { + @Suppress("UNCHECKED_CAST") + convertView.tag as VH + } + vh.layoutPosition = position + onBindViewHolder(vh, position, parent) + return vh.itemView + } + + abstract fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH + abstract fun onBindViewHolder(vh: VH, position: Int, parent: ViewGroup) + + open class ViewHolder(var itemView: View){ + var layoutPosition = 0 + } + +} diff --git a/app/src/main/java/com/pic/catcher/adapter/BindingListAdapter.kt b/app/src/main/java/com/pic/catcher/adapter/BindingListAdapter.kt new file mode 100644 index 0000000..090b18f --- /dev/null +++ b/app/src/main/java/com/pic/catcher/adapter/BindingListAdapter.kt @@ -0,0 +1,2 @@ +package com.pic.catcher.adapter + diff --git a/app/src/main/java/com/pic/catcher/adapter/CommonListAdapter.kt b/app/src/main/java/com/pic/catcher/adapter/CommonListAdapter.kt new file mode 100644 index 0000000..cb56b2b --- /dev/null +++ b/app/src/main/java/com/pic/catcher/adapter/CommonListAdapter.kt @@ -0,0 +1,76 @@ +package com.pic.catcher.adapter + +import java.util.concurrent.CopyOnWriteArrayList + + +abstract class CommonListAdapter : AbsListAdapter() { + + open val dataList: MutableList = CopyOnWriteArrayList() + + open fun setData(data: List): CommonListAdapter { + this.dataList.clear() + this.dataList.addAll(data) + return this + } + + open fun updateDataAt(data: List) { + this.dataList.clear() + this.dataList.addAll(data) + notifyDataSetChanged() + } + + open fun updateDataAt(ele: E) { + val index = dataList.indexOf(ele) + updateDataAt(index) + } + + open fun updateDataAt(position: Int) { + if (position < 0 || position >= dataList.size) { + return + } + notifyDataSetChanged() + } + + open fun addData(data: List): CommonListAdapter { + this.dataList.addAll(data) + return this + } + + open fun addData(vararg ele: E): CommonListAdapter { + this.dataList.addAll(ele) + return this + } + + open fun remove(data: List): CommonListAdapter { + this.dataList.removeAll(data) + return this + } + + open fun remove(vararg elements: E): CommonListAdapter { + this.dataList.removeAll(elements.toSet()) + return this + } + + open fun removeAt(index: Int): CommonListAdapter { + this.dataList.removeAt(index) + return this + } + + open fun getData(): MutableList { + return dataList + } + + override fun getItem(position: Int): E? { + return dataList[position] + } + + + override fun getCount(): Int { + return dataList.size + } + + override fun getItemId(position: Int): Long { + return position.toLong() + } + +} diff --git a/app/src/main/java/com/pic/catcher/config/AppConfig.kt b/app/src/main/java/com/pic/catcher/config/AppConfig.kt new file mode 100644 index 0000000..251474e --- /dev/null +++ b/app/src/main/java/com/pic/catcher/config/AppConfig.kt @@ -0,0 +1,61 @@ +package com.pic.catcher.config + +import androidx.annotation.Keep +import org.json.JSONObject + + +@Keep +class AppConfig( + var mainUi: MainUi? +) { + constructor() : this(null) + + companion object { + fun fromJson(json: JSONObject?): AppConfig? { + return if (json == null) null else AppConfig( + MainUi.fromJson(json.optJSONObject("mainUi")) + ) + } + } +} + +@Keep +class MainUi( + var donateCard: DonateCard?, + var moduleCard: ModuleCard? +) { + companion object { + fun fromJson(json: JSONObject?): MainUi? { + return if (json == null) null else MainUi( + DonateCard.fromJson(json.optJSONObject("donateCard")), + ModuleCard.fromJson(json.optJSONObject("moduleCard")) + ) + } + } +} + +@Keep +class DonateCard( + var des: String?, + var show: Boolean = false, + var title: String? +) { + companion object { + fun fromJson(json: JSONObject?): DonateCard? { + return if (json == null) null else DonateCard( + json.optString("des"), + json.optBoolean("show", false), + json.optString("title") + ) + } + } +} + +@Keep +class ModuleCard(var link: String?) { + companion object { + fun fromJson(json: JSONObject?): ModuleCard? { + return if (json == null) null else ModuleCard(json.optString("link")) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pic/catcher/config/AppConfigUtil.kt b/app/src/main/java/com/pic/catcher/config/AppConfigUtil.kt new file mode 100644 index 0000000..95dfecf --- /dev/null +++ b/app/src/main/java/com/pic/catcher/config/AppConfigUtil.kt @@ -0,0 +1,124 @@ +package com.pic.catcher.config + +import android.net.Uri +import com.lu.magic.util.AppUtil + +import com.lu.magic.util.log.LogUtil +import com.pic.catcher.util.TimeExpiredCalculator +import com.pic.catcher.util.http.HttpConnectUtil +import com.pic.catcher.R +import org.json.JSONObject +import java.io.File + +class AppConfigUtil { + companion object { + private val configFilePath = "res/raw/app_config.json" + val githubMainUrl = "https://raw.githubusercontent.com/Mingyueyixi/MaskWechat/main" + + //@main分支 或者@v1.6, commit id之类的,直接在写/main有时候不行 + //不指定版本,则取最后一个https://www.jsdelivr.com/?docs=gh + val cdnMainUrl = "https://cdn.jsdelivr.net/gh/Mingyueyixi/MaskWechat@main" + var config: AppConfig = AppConfig() + + //5分钟过期时间 + val releaseNoteExpiredSetting by lazy { TimeExpiredCalculator(5 * 60 * 1000L) } + + // 不通过构造函数创建对象 + // UnsafeAllocator.INSTANCE.newInstance(AppConfig::class.java) + fun load(callBack: ((config: AppConfig, fromRemote: Boolean) -> Unit)? = null) { + val rawUrl = "$githubMainUrl/$configFilePath" + //例如分支v1.12, 写法url编码,且前缀加@v:@vv1.12%2Fdev + val cdnUrl = "$cdnMainUrl/$configFilePath" + + val file = File(AppUtil.getContext().filesDir, configFilePath) + if (!file.exists()) { + try { + file.parentFile.mkdirs() + } catch (e: Exception) { + } + } + HttpConnectUtil.get(rawUrl, HttpConnectUtil.noCacheHttpHeader) { raw -> + if (raw.error != null || raw.code != 200) { + LogUtil.d("request raw fail, $rawUrl", raw) + HttpConnectUtil.get(cdnUrl, HttpConnectUtil.noCacheHttpHeader) { cdn -> + if (cdn.error == null && cdn.code == 200) { + parseConfig(cdn.body) + saveLocalFile(file, cdn.body) + callBack?.invoke(config, true) + } else { + LogUtil.d("request cdn fail, $cdnUrl", cdn) + //本地读取 + parseLocalConfig(file) + callBack?.invoke(config, false) + } + } + } else { + parseConfig(raw.body) + saveLocalFile(file, raw.body) + callBack?.invoke(config, true) + } + } + } + + private fun parseLocalConfig(file: File) { + file.readBytes().let { + parseConfig(it) + } + } + + private fun parseConfig(data: ByteArray) { + val jsonText = data.toString(Charsets.UTF_8) + AppConfig.fromJson(JSONObject(jsonText))?.let { + config = it + } + } + + private fun saveLocalFile(file: File, data: ByteArray) { + file.outputStream().use { + it.write(data) + } + } + + + fun getReleaseNoteWebUrl(): String { + val filePath = "res/html/releases_note.html" + val cdnUrl = "$cdnMainUrl/$filePath" + val githubUrl = "$githubMainUrl/$filePath" + + val file = getLocalFile(filePath) + if (!file.exists()) { + try { + file.parentFile.mkdirs() + AppUtil.getContext().resources.openRawResource(R.raw.releases_note).use { inStream -> + saveLocalFile(file, inStream.readBytes()) + } + } catch (e: Exception) { + } + releaseNoteExpiredSetting.updateLastTime(0) + } + if (releaseNoteExpiredSetting.isExpired()) { + HttpConnectUtil.get(githubUrl) { github -> + if (github.error == null && github.body.isNotEmpty()) { + saveLocalFile(file, github.body) + releaseNoteExpiredSetting.updateLastTime() + } else { + LogUtil.i("get fail: ", githubUrl, github) + HttpConnectUtil.get(cdnUrl) { cdn -> + if (cdn.error == null && cdn.body.isNotEmpty()) { + saveLocalFile(file, cdn.body) + releaseNoteExpiredSetting.updateLastTime() + } else { + LogUtil.i("get fail: ", cdnUrl, cdn) + } + } + } + } + } + return Uri.fromFile(file).toString() + } + + private fun getLocalFile(relativePath: String): File { + return File(AppUtil.getContext().filesDir, relativePath) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pic/catcher/plugin/GlideCatcherPlugin.java b/app/src/main/java/com/pic/catcher/plugin/GlideCatcherPlugin.java new file mode 100644 index 0000000..66e926d --- /dev/null +++ b/app/src/main/java/com/pic/catcher/plugin/GlideCatcherPlugin.java @@ -0,0 +1,19 @@ +package com.pic.catcher.plugin; + +import android.content.Context; + +import com.lu.lposed.plugin.IPlugin; + +import de.robv.android.xposed.callbacks.XC_LoadPackage; + +/** + * @author Lu + * @date 2024/9/22 0:22 + * @description + */ +public class GlideCatcherPlugin implements IPlugin { + @Override + public void handleHook(Context context, XC_LoadPackage.LoadPackageParam loadPackageParam) { + + } +} diff --git a/app/src/main/java/com/pic/catcher/route/MaskAppRouter.kt b/app/src/main/java/com/pic/catcher/route/MaskAppRouter.kt new file mode 100644 index 0000000..45c115f --- /dev/null +++ b/app/src/main/java/com/pic/catcher/route/MaskAppRouter.kt @@ -0,0 +1,198 @@ +package com.pic.catcher.route + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.lifecycle.ViewModelProvider +import com.lu.magic.util.AppUtil +import com.lu.magic.util.ToastUtil +import com.lu.magic.util.log.LogUtil +import com.pic.catcher.App +import com.pic.catcher.config.AppConfigUtil +import com.pic.catcher.ui.MainActivity +import com.pic.catcher.ui.WebViewActivity +import com.pic.catcher.ui.WebViewDialog +import com.pic.catcher.ui.vm.AppUpdateViewModel + +/** + * app内部跳转协议实现,如: + * maskwechat://com.lu.wxmask/feat/checkAppUpdate + * maskwechat://com.lu.wxmask/page/main + * maskwechat://com.lu.wxmask/page/webView?forceHtml=false&isDialog=true&url=https://www.baidu.com + * maskwechat://com.lu.wxmask/page/releasesNote?isDialog=true&title=更新日记 + */ +class MaskAppRouter { + companion object { + val vailScheme = "maskwechat" + val vailHost = "com.lu.wxmask" + private val appUpdateViewModel = ViewModelProvider(com.pic.catcher.App.instance)[AppUpdateViewModel::class.java] + fun routeCheckAppUpdateFeat(activity: Activity) { + route(activity, "maskwechat://com.lu.wxmask/feat/checkAppUpdate") + } + + fun routeDonateFeat(activity: Activity) { + route(activity, "maskwechat://com.lu.wxmask/feat/donate") + } + + fun routeWebViewPage( + activity: Context, + webUrl: String, + title: String, + isDialogUI: Boolean = false, + forceHtml: Boolean = false + ) { + val uri = Uri.parse("maskwechat://com.lu.wxmask/page/webView") + .buildUpon() + .appendQueryParameter("forceHtml", forceHtml.toString()) + .appendQueryParameter("isDialog", isDialogUI.toString()) + .appendQueryParameter("url", webUrl) + .appendQueryParameter("title", title) + .build() + route(activity, uri.toString()) + } + + fun routeReleasesNotePage(activity: Activity, title: String, isDialogWebUI: Boolean = false) { + val uri = Uri.parse("maskwechat://com.lu.wxmask/page/releasesNote") + .buildUpon() + .appendQueryParameter("isDialog", isDialogWebUI.toString()) + .appendQueryParameter("title", title) + .build() + route(activity, uri.toString()) + } + + fun isMaskAppLink(uri: Uri?): Boolean { + if (uri == null) { + return false + } + return vailScheme == uri.scheme && vailHost == uri.host + } + fun isPageGroup(uri: Uri): Boolean { + val segments = uri.pathSegments + if (segments.size >= 1) { + return segments[0] == "page" + } + return false + } + + @JvmOverloads + fun route(context: Context = AppUtil.getContext(), url: String?, onFail: ((e: Throwable) -> Unit)? = null) { + try { + LogUtil.i("App Route", url) + val uri = Uri.parse(url) + if (isMaskAppLink(uri)) { + val pathSegments = uri.pathSegments + if (pathSegments.size == 2) { + val group = pathSegments[0] ?: "" + val name = pathSegments[1] ?: "" + routeMaskAppLink(context, uri, group, name) + } else { + LogUtil.w("is Mask App link ,but pathSegments‘s size is not match. Jump to main Page") + jumpMainPage(context, uri) + } + } else { + val intent = Intent.parseUri(url, Intent.URI_ALLOW_UNSAFE) + // intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + } + } catch (e: Throwable) { + LogUtil.w(e) + onFail?.invoke(e) + } + } + +// private fun routeOtherAppLink(context: Context, uri: Uri) { +// val intent = Intent() +// intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) +// intent.action = Intent.ACTION_VIEW +// intent.data = uri +// context.startActivity(intent) +// } + + private fun routeMaskAppLink(context: Context, uri: Uri, group: String, name: String) { + when (group) { + "feat" -> routeFeatGroup(context, uri, name) + "page" -> routePageGroup(context, uri, name) + else -> { + LogUtil.w(group, "for mask link 's group not impl") + } + } + } + + private fun routeFeatGroup(context: Context, uri: Uri, name: String) { + when (name) { + "checkAppUpdate" -> appUpdateViewModel.checkOnce(context) + "donate" -> { + ToastUtil.show("Good good study, day day up.") + } + else -> LogUtil.w(name, "for mask link featGroup not impl") + } + + } + + private fun routePageGroup(context: Context, uri: Uri, name: String) { + when (name) { + "webView" -> { + val isDialog = uri.getQueryParameter("isDialog").toBoolean() + if (isDialog) { + com.pic.catcher.ui.WebViewDialog( + context, + uri.getQueryParameter("url") ?: "about:blank", + uri.getQueryParameter("title"), + uri.getQueryParameter("forceHtml").toBoolean() + ).show() + } else { + val intent = Intent(context, WebViewActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + intent.putExtra("url", uri.getQueryParameter("url")) + intent.putExtra("title", uri.getQueryParameter("title")) + intent.putExtra("forceHtml", uri.getQueryParameter("forceHtml").toBoolean()) + context.startActivity(intent) + } + } + + "releasesNote" -> { + val isDialog = uri.getQueryParameter("isDialog").toBoolean() + val webUrl = AppConfigUtil.getReleaseNoteWebUrl() + val forceHtml = Regex("https?://.+\\.html", RegexOption.IGNORE_CASE).matches(webUrl) + if (isDialog) { + com.pic.catcher.ui.WebViewDialog( + context, + webUrl, + uri.getQueryParameter("title") ?: context.applicationInfo.name, + forceHtml + ).show() + } else { + val intent = Intent(context, WebViewActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + intent.putExtra("url", webUrl) + intent.putExtra("title", uri.getQueryParameter("title")) + intent.putExtra("forceHtml", forceHtml) + context.startActivity(intent) + } + } + + "main" -> jumpMainPage(context, uri) + + else -> LogUtil.w(name, "for mask link pageGroup not impl") + } + } + + fun routeMainPage(context: Context, intentData: Uri? = null) { + val routeUri = Uri.parse("maskwechat://com.lu.wxmask/page/main").buildUpon() + .appendQueryParameter("data", intentData.toString()) + .build() + .toString() + route(context, routeUri) + } + + fun jumpMainPage(context: Context, uri: Uri) { + val intent = Intent(context, MainActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + uri.getQueryParameter("data")?.let { + intent.data = Uri.parse(it) + } + context.startActivity(intent) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pic/catcher/ui/DeepLinkActivity.kt b/app/src/main/java/com/pic/catcher/ui/DeepLinkActivity.kt new file mode 100644 index 0000000..e257f08 --- /dev/null +++ b/app/src/main/java/com/pic/catcher/ui/DeepLinkActivity.kt @@ -0,0 +1,24 @@ +package com.pic.catcher.ui + +import android.content.Intent +import android.os.Bundle +import com.lu.magic.ui.BaseActivity + +class DeepLinkActivity : BaseActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + intent.putExtra("from", DeepLinkActivity::class.java.name) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + +// intent.data?.let { +// if (MaskAppRouter.isPageGroup(it)) { +// MaskAppRouter.route(this, it.toString()) +// finish() +// return +// } +// } + intent.setClass(this, MainActivity::class.java) + startActivity(intent) + finish() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pic/catcher/ui/JsonMenuManager.kt b/app/src/main/java/com/pic/catcher/ui/JsonMenuManager.kt new file mode 100644 index 0000000..3c3e50d --- /dev/null +++ b/app/src/main/java/com/pic/catcher/ui/JsonMenuManager.kt @@ -0,0 +1,210 @@ +package com.pic.catcher.ui + +import android.content.Context +import android.view.Menu +import android.view.MenuItem +import androidx.annotation.Keep +import com.lu.magic.util.AppUtil +import com.lu.magic.util.log.LogUtil +import com.pic.catcher.R +import com.pic.catcher.config.AppConfigUtil +import com.pic.catcher.route.MaskAppRouter +import com.pic.catcher.util.ext.takeNotNull +import com.pic.catcher.util.http.HttpConnectUtil +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.nio.charset.Charset + +class JsonMenuManager { + @Keep + class MenuBean( + var groupId: Int = 0, + var itemId: Int = 0, + var order: Int = 0, + var title: String? = "", + var link: String? = "", + var appLink: AppLink? = null, + /**最低支持app版本,小于则不显示*/ + var since: Int = 0 + ) { + companion object { + fun fromJson(json: JSONObject?): MenuBean { + return if (json == null) MenuBean() else MenuBean( + json.optInt("groupId"), + json.optInt("itemId"), + json.optInt("order"), + json.optString("title"), + json.optString("link"), + json.optJSONObject("appLink")?.let { AppLink.fromJson(it) }, + json.optInt("since") + ) + } + + fun fromJsonArray(jsonArray: JSONArray?): ArrayList? { + if (jsonArray == null) { + return null + } + val result = ArrayList() + for (i in 0 until jsonArray.length()) { + result.add(MenuBean.fromJson(jsonArray.optJSONObject(i))) + } + return result + } + } + } + + @Keep + class AppLink(var links: Array? = null, var priority: Int = 0) { + companion object { + fun fromJson(json: JSONObject?): AppLink? { + return if (json == null) null else AppLink( + json.optJSONArray("links")?.let { + val result = ArrayList() + for (i in 0 until it.length()) { + result.add(it.optString(i)) + } + result.toTypedArray() + }, + json.optInt("priority") + ) + } + } + } + + companion object { + private var isRemoteUpdating: Boolean = false + private var lastUpdateSuccessMills = 0L + private val menuFilePath = "res/raw/menu_ui.json" + + fun inflate(context: Context, menu: Menu) { + for (menuBean in readMenuList(context)) { + if (AppUtil.getVersionCode() < menuBean.since) { + //不支持的版本,忽略 + continue + } + val menuItem = menu.add(menuBean.groupId, menuBean.itemId, menuBean.order, menuBean.title) + menuItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_COLLAPSE_ACTION_VIEW) + menuItem.setOnMenuItemClickListener { + val appLink = menuBean.appLink + val clickLinkPriority = 0 + val appLinks = appLink?.links + if (appLink == null || clickLinkPriority > appLink.priority || appLinks.isNullOrEmpty()) { + try { + openLinkWith(context, menuBean.link) + } catch (e: Throwable) { + LogUtil.w("open link error", e) + } + } else { + var failCount = 0 + for (link in appLinks) { + try { + openLinkWith(context, link) + } catch (e: Throwable) { + failCount++; + LogUtil.w("open link faild", e) + } + if (failCount == 0) { + //成功,跳出循环 + break + } + } + if (failCount == appLinks.size) { + LogUtil.w("open appLink with all error", it) + try { + openLinkWith(context, menuBean.link) + } catch (e: Throwable) { + LogUtil.w("try open link also error", e) + } + } + + + } + return@setOnMenuItemClickListener true + } + } + + } + + private fun openLinkWith(context: Context, link: String?) { + if (link == null) { + throw IllegalArgumentException("link is null") + } + MaskAppRouter.route(context, link) { + throw it + } + } + + private fun readMenuList(context: Context): ArrayList { + val file = File(context.filesDir, menuFilePath) + val ret: ArrayList = try { + val result = ArrayList() + if (file.exists()) { + MenuBean.fromJsonArray(JSONArray(file.readText())) + } else { + file.parentFile.mkdirs() + val text = readMenuJsonTextFromRaw(context) + file.writeText(text) + MenuBean.fromJsonArray(JSONArray(text)) + } + result + } catch (e: Exception){ + val text = readMenuJsonTextFromRaw(context) + MenuBean.fromJsonArray(JSONArray(text)).takeNotNull(ArrayList()) + } + return ret + } + + private fun readMenuJsonTextFromRaw(context: Context): String { + return context.resources.openRawResource(R.raw.menu_ui).use { + it.readBytes().toString(Charset.forName("UTF-8")) + } + } + + fun updateMenuListFromRemoteIfNeed(ctx: Context) { + // 2 hour + if (!isRemoteUpdating && System.currentTimeMillis() - lastUpdateSuccessMills > 1000 * 60 * 60 * 2) { + updateMenuListFromRemote(ctx) + } + } + + fun updateMenuListFromRemote(ctx: Context) { + val context = ctx.applicationContext + + val rawJsonMenuUrl = "${AppConfigUtil.githubMainUrl}/$menuFilePath" + isRemoteUpdating = true + HttpConnectUtil.getWithRetry(rawJsonMenuUrl, HttpConnectUtil.noCacheHttpHeader, 1, { retryCount, res -> + LogUtil.i("onFetch retry:$retryCount", rawJsonMenuUrl) + }, { + if (it.error == null && it.code == 200 && it.body.isNotEmpty()) { + writeRemoteToLocal(context, it.body) + isRemoteUpdating = false + } else { + LogUtil.w("request raw remote menu fail", it) + + val cdnRawPath = "${AppConfigUtil.cdnMainUrl}/$menuFilePath" + LogUtil.i("request $cdnRawPath") + HttpConnectUtil.get(cdnRawPath, HttpConnectUtil.noCacheHttpHeader) { cdnRes -> + if (cdnRes.error == null && cdnRes.code == 200 && cdnRes.body.isNotEmpty()) { + writeRemoteToLocal(context, cdnRes.body) + } else { + LogUtil.i("request jscdn remote menu fail", cdnRes) + } + isRemoteUpdating = false + } + + } + + }) + } + + private fun writeRemoteToLocal(context: Context, body: ByteArray) { + File(context.filesDir, menuFilePath).outputStream().use { out -> + out.write(body) + } + lastUpdateSuccessMills = System.currentTimeMillis() + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/pic/catcher/ui/MainActivity.kt b/app/src/main/java/com/pic/catcher/ui/MainActivity.kt new file mode 100644 index 0000000..f966dd4 --- /dev/null +++ b/app/src/main/java/com/pic/catcher/ui/MainActivity.kt @@ -0,0 +1,68 @@ +package com.pic.catcher.ui + +import android.content.Intent +import android.os.Bundle +import android.view.Menu +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.ViewModelProvider +import com.lu.magic.ui.FragmentNavigation +import com.pic.catcher.databinding.LayoutMainBinding +import com.pic.catcher.route.MaskAppRouter +import com.pic.catcher.ui.vm.AppUpdateViewModel + + +class MainActivity : AppCompatActivity() { + private lateinit var fragmentNavigation: FragmentNavigation + private lateinit var binding: LayoutMainBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = LayoutMainBinding.inflate(layoutInflater) + setContentView(binding.root) + fragmentNavigation = FragmentNavigation(this, binding.mainContainer) + fragmentNavigation.navigate(MainFragment::class.java) + ViewModelProvider(this)[AppUpdateViewModel::class.java].checkOnEnter(this) + + handleDeeplinkRoute(intent) + } + + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + handleDeeplinkRoute(intent) + } + + private fun handleDeeplinkRoute(intent: Intent?) { + if (intent == null) return + val from = intent.getStringExtra("from") + if (DeepLinkActivity::class.java.name != from) { + return + } + intent.data?.let { + binding.root.post { + MaskAppRouter.route(this, it.toString()) + } + } + } + + + @Suppress("DEPRECATION") + override fun onBackPressed() { + if (!fragmentNavigation.navigateBack()) { + super.onBackPressed() + } + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + + // Inflate the menu; this adds items to the action bar if it is present. + super.onCreateOptionsMenu(menu) + JsonMenuManager.inflate(this, menu) + return true + } + + override fun onResume() { + super.onResume() + JsonMenuManager.updateMenuListFromRemoteIfNeed(this) + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/pic/catcher/ui/MainFragment.kt b/app/src/main/java/com/pic/catcher/ui/MainFragment.kt new file mode 100644 index 0000000..ec17ceb --- /dev/null +++ b/app/src/main/java/com/pic/catcher/ui/MainFragment.kt @@ -0,0 +1,226 @@ +package com.pic.catcher.ui + +import android.content.ComponentName +import android.content.Intent +import android.graphics.Color +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.lu.magic.ui.BaseFragment +import com.lu.magic.ui.LifecycleAutoViewBinding +import com.lu.magic.util.SizeUtil +import com.lu.magic.util.ToastUtil +import com.lu.magic.util.kxt.toElseString +import com.lu.magic.util.log.LogUtil +import com.lu.magic.util.ripple.RectangleRippleBuilder +import com.lu.magic.util.ripple.RippleApplyUtil +import com.lu.magic.util.thread.AppExecutor +import com.pic.catcher.ClazzN +import com.pic.catcher.MaskAppBuildInfo +import com.pic.catcher.SelfHook +import com.pic.catcher.adapter.AbsListAdapter +import com.pic.catcher.adapter.CommonListAdapter +import com.pic.catcher.config.AppConfigUtil +import com.pic.catcher.route.MaskAppRouter +import com.pic.catcher.BuildConfig +import com.pic.catcher.R +import com.pic.catcher.databinding.FragmentMainBinding +import com.pic.catcher.databinding.ItemIconTextBinding + + +class MainFragment : BaseFragment() { + private var itemBinding: ItemIconTextBinding by LifecycleAutoViewBinding() + private var mainBinding: FragmentMainBinding by LifecycleAutoViewBinding() + private val buildInfo = com.pic.catcher.MaskAppBuildInfo.of() + + private val donateCardId = 10086 + + private var mListAdapter: CommonListAdapter? = null + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return FragmentMainBinding.inflate(inflater, container, false).let { + mainBinding = it + it.root + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val rippleRadius = SizeUtil.dp2px(resources, 8f).toInt() + + mListAdapter = object : CommonListAdapter() { + init { + setData(arrayListOf(1, 2, 3)) + } + + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemBindingViewHolder { + itemBinding = ItemIconTextBinding.inflate(layoutInflater, parent, false) + + return object : ItemBindingViewHolder(itemBinding) { + init { + itemBinding.layoutItem.setOnClickListener { + val itemValue = getItem(layoutPosition) + + when (itemValue) { +// 1 -> clickModuleCard() +// 2 -> jumpWxManagerConfigUI(Constrant.VALUE_INTENT_PLUGIN_MODE_ADD) +// 3 -> jumpWxManagerConfigUI(Constrant.VALUE_INTENT_PLUGIN_MODE_MANAGER) +// donateCardId -> MaskAppRouter.routeDonateFeat(requireActivity()) + } + } + + } + } + } + + override fun onBindViewHolder(vh: ItemBindingViewHolder, position: Int, parent: ViewGroup) { + if (position != 0) { + applyCommonItemRipple(vh.binding.layoutItem) + } + val position = getItem(position) + if (position == 1) { + vh.binding.tvItemTitleSub2.text = "代码分支:" + buildInfo.branch + vh.binding.tvItemTitleSub3.text = "提交哈希:" + buildInfo.commit + vh.binding.tvItemTitleSub4.text = "构建时间:" + buildInfo.buildTime + vh.binding.tvItemTitleSub2.visibility = View.VISIBLE + vh.binding.tvItemTitleSub3.visibility = View.VISIBLE + vh.binding.tvItemTitleSub4.visibility = View.VISIBLE + } else { + vh.binding.tvItemTitleSub2.visibility = View.GONE + vh.binding.tvItemTitleSub3.visibility = View.GONE + vh.binding.tvItemTitleSub4.visibility = View.GONE + } + + when (position) { + 1 -> { + if (com.pic.catcher.SelfHook.getInstance().isModuleEnable) { + vh.binding.ivItemIcon.setImageResource(R.drawable.ic_icon_check) + vh.binding.tvItemTitle.setText(R.string.module_have_active) + applyModuleStateRipple(vh.binding.layoutItem, true) + } else { + vh.binding.ivItemIcon.setImageResource(R.drawable.ic_icon_warning) + vh.binding.tvItemTitle.setText(R.string.module_not_active) + applyModuleStateRipple(vh.binding.layoutItem, false) + } + vh.binding.tvItemTitleSub.text = (getString(R.string.module_version) + ":" + getVersionText()) + } + + 2 -> { + vh.binding.ivItemIcon.setImageResource(R.drawable.ic_icon_add) + vh.binding.tvItemTitle.setText(R.string.config_add) + vh.binding.tvItemTitleSub.setText(R.string.click_here_to_add) + } + + 3 -> { + vh.binding.ivItemIcon.setImageResource(R.drawable.ic_icon_manager) + vh.binding.tvItemTitle.setText(R.string.config_manager) + vh.binding.tvItemTitleSub.setText(R.string.click_here_to_manager) + } + + donateCardId -> { + val donateCard = AppConfigUtil.config.mainUi?.donateCard + vh.binding.tvItemTitle.text = donateCard?.title.toElseString( + getString(R.string.donate) + ) + vh.binding.tvItemTitleSub.text = donateCard?.des.toElseString( + getString(R.string.donate_description) + ) + vh.binding.ivItemIcon.setImageResource(R.drawable.ic_icon_dollar) + } + + } + + } + + private fun applyCommonItemRipple(v: View) { + RectangleRippleBuilder(Color.TRANSPARENT, Color.GRAY, rippleRadius).let { + RippleApplyUtil.apply(v, it) + } + } + + private fun applyModuleStateRipple(v: View, enable: Boolean) { + val contentColor = if (enable) { + view.context.getColor(R.color.app_primary) + } else { + 0xFFFF6027.toInt() + } + RectangleRippleBuilder(contentColor, Color.GRAY, rippleRadius).let { + RippleApplyUtil.apply(v, it) + } + } + + } + + +// 设置了ripple, 子view拿走了事件,此处不响应 +// mainBinding.listView.setOnItemClickListener { _, view, position, _ -> +// +// } + mainBinding.listView.adapter = mListAdapter + AppConfigUtil.load { config, isRemote -> + if (isDetached || isRemoving) { + return@load + } + val donateCard = config.mainUi?.donateCard ?: return@load + if (donateCard.show) { + showDonateCard() + } + } + } + + private fun clickModuleCard() { + val moduleCard = AppConfigUtil.config.mainUi?.moduleCard + if (moduleCard == null || moduleCard.link.isNullOrBlank()) { + MaskAppRouter.routeReleasesNotePage(requireActivity(), "更新日记") +// MaskAppRouter.routeCheckAppUpdateFeat(requireActivity()) + } else { + MaskAppRouter.route(requireActivity(), moduleCard.link) +// MaskAppRouter.routeReleasesNotePage(requireActivity(), "更新日记") + } + } + + private fun showDonateCard() { + AppExecutor.executeMain { + mListAdapter?.let { adapter -> + if (adapter.dataList.contains(donateCardId)) { + return@executeMain + } + adapter.addData(donateCardId) + adapter.notifyDataSetChanged() + } + } + } + + private fun getVersionText(): String { + return if (BuildConfig.DEBUG) { + "v${BuildConfig.VERSION_NAME}-debug" + } else { + "v${BuildConfig.VERSION_NAME}-release" + } + } + + open class ItemBindingViewHolder(@JvmField var binding: ItemIconTextBinding) : + AbsListAdapter.ViewHolder(binding.root) +// +// private fun jumpWxManagerConfigUI(mode: Int = Constrant.VALUE_INTENT_PLUGIN_MODE_MANAGER) { +// try { +// val intent = Intent() +// intent.action = "com.tencent.mm.action.BIZSHORTCUT" +//// intent.action = Intent.ACTION_MAIN +// intent.addCategory(Intent.CATEGORY_LAUNCHER) +// intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) +// intent.putExtra(Constrant.KEY_INTENT_FROM_MASK, true) +// intent.putExtra(Constrant.KEY_INTENT_PLUGIN_MODE, mode) +// +// intent.component = ComponentName("com.tencent.mm", com.pic.catcher.ClazzN.LauncherUI) +// startActivity(intent) +// } catch (e: Exception) { +// ToastUtil.show("跳转WeChat配置页面失败") +// LogUtil.e(e) +// } +// } + +} \ No newline at end of file diff --git a/app/src/main/java/com/pic/catcher/ui/WebViewActivity.kt b/app/src/main/java/com/pic/catcher/ui/WebViewActivity.kt new file mode 100644 index 0000000..3cabb5e --- /dev/null +++ b/app/src/main/java/com/pic/catcher/ui/WebViewActivity.kt @@ -0,0 +1,49 @@ +package com.pic.catcher.ui + +import android.os.Bundle +import android.webkit.WebView +import android.widget.FrameLayout +import com.lu.magic.ui.BaseActivity +import com.lu.magic.util.log.LogUtil +import com.pic.catcher.ui.wrapper.WebViewComponentCallBack +import com.pic.catcher.ui.wrapper.WebViewComponent + +class WebViewActivity : BaseActivity() { + val webViewComponent by lazy { WebViewComponent(this) } + var hasLoadUrl = false + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val contentLayout = FrameLayout(this) + setContentView(contentLayout) + + val webUrl = intent.getStringExtra("url") + val forceHtml = intent.getBooleanExtra("forceHtml", false) + val preTitleText = intent.getStringExtra("title") + if (!preTitleText.isNullOrBlank()) { + title = preTitleText + } + + if (webUrl.isNullOrBlank()) { + finish() + return + } + LogUtil.i("onCreate") + webViewComponent.forceHtml = forceHtml + webViewComponent.attachView(contentLayout) + webViewComponent.loadUrl(webUrl, object : com.pic.catcher.ui.wrapper.WebViewComponentCallBack { + override fun onReceivedTitle(view: WebView?, title: String?) { + if (preTitleText.isNullOrBlank() && !title.isNullOrBlank()) { + setTitle(title) + } + } + }) + hasLoadUrl = true + } + + override fun onDestroy() { + super.onDestroy() + if (hasLoadUrl) { + webViewComponent.destroy() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pic/catcher/ui/WebViewDialog.java b/app/src/main/java/com/pic/catcher/ui/WebViewDialog.java new file mode 100644 index 0000000..0c8dbed --- /dev/null +++ b/app/src/main/java/com/pic/catcher/ui/WebViewDialog.java @@ -0,0 +1,103 @@ +package com.pic.catcher.ui; + +import android.content.Context; +import android.os.Bundle; +import android.view.ViewGroup; +import android.view.Window; +import android.webkit.WebView; +import android.widget.FrameLayout; + +import androidx.appcompat.app.AppCompatDialog; + +import com.lu.magic.util.AppUtil; +import com.pic.catcher.ui.wrapper.WebViewComponent; +import com.pic.catcher.ui.wrapper.WebViewComponentCallBack; +import com.pic.catcher.ui.wrapper.WebViewComponent; +import com.pic.catcher.ui.wrapper.WebViewComponentCallBack; + +import kotlin.text.StringsKt; + +/** + * @author Lu + * @date 2024/9/8 15:54 + * @description + */ +public final class WebViewDialog extends AppCompatDialog { + private String dialogTitle; + private boolean forceHtml; + private String webUrl; + private final WebViewComponent webViewComponent; + + public WebViewDialog(Context context, String webUrl, String dialogTitle, boolean forceHtml) { + super(context); + this.webUrl = webUrl; + this.dialogTitle = dialogTitle; + this.forceHtml = forceHtml; + WebViewComponent it = new WebViewComponent(context); + it.setForceHtml(this.forceHtml); + this.webViewComponent = it; + } + + public final String getWebUrl() { + return this.webUrl; + } + + public final void setWebUrl(String str) { + this.webUrl = str; + } + + public final String getDialogTitle() { + return this.dialogTitle; + } + + public final void setDialogTitle(String str) { + this.dialogTitle = str; + } + + public final boolean getForceHtml() { + return this.forceHtml; + } + + public final void setForceHtml(boolean z) { + this.forceHtml = z; + } + + public final WebViewComponent getWebViewComponent() { + return this.webViewComponent; + } + + /* JADX INFO: Access modifiers changed from: protected */ + @Override // androidx.appcompat.app.AppCompatDialog, androidx.activity.ComponentDialog, android.app.Dialog + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + String str = this.dialogTitle; + if (!(str == null || StringsKt.isBlank(str))) { + setTitle(this.dialogTitle); + } + this.webViewComponent.loadUrl(this.webUrl, new WebViewComponentCallBack() { + @Override // com.p004lu.wxmask.p009ui.wrapper.WebViewComponentCallBack + public void onReceivedTitle(WebView view, String title) { + String dialogTitle = WebViewDialog.this.getDialogTitle(); + boolean z = false; + if (dialogTitle == null || StringsKt.isBlank(dialogTitle)) { + String str2 = title; + if (str2 == null || StringsKt.isBlank(str2)) { + z = true; + } + if (!z) { + WebViewDialog.this.setTitle(title); + } + } + } + }); + FrameLayout it = new FrameLayout(getContext()); + it.setLayoutParams(new ViewGroup.MarginLayoutParams(ViewGroup.MarginLayoutParams.MATCH_PARENT, ViewGroup.MarginLayoutParams.MATCH_PARENT)); + this.webViewComponent.attachView(it); + setContentView(it); + float height = AppUtil.getContext().getResources().getDisplayMetrics().heightPixels * 0.6f; + Window window = getWindow(); + if (window != null) { + window.setLayout(ViewGroup.MarginLayoutParams.MATCH_PARENT, (int) height); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pic/catcher/ui/adapter/SpinnerListAdapter.kt b/app/src/main/java/com/pic/catcher/ui/adapter/SpinnerListAdapter.kt new file mode 100644 index 0000000..2b8cede --- /dev/null +++ b/app/src/main/java/com/pic/catcher/ui/adapter/SpinnerListAdapter.kt @@ -0,0 +1,26 @@ +package com.pic.catcher.ui.adapter + +import android.view.ViewGroup +import android.widget.TextView +import androidx.core.view.setPadding +import com.pic.catcher.adapter.AbsListAdapter +import com.pic.catcher.adapter.CommonListAdapter +import com.pic.catcher.util.ext.dp + +class SpinnerListAdapter(spinnerDataList: ArrayList>) : + CommonListAdapter, AbsListAdapter.ViewHolder>() { + init { + setData(spinnerDataList) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + return ViewHolder(TextView(parent.context).also { it.setPadding(4.dp) }) + } + + override fun onBindViewHolder(vh: ViewHolder, position: Int, parent: ViewGroup) { + val itemView = vh.itemView + if (itemView is TextView) { + itemView.text = getItem(position)?.second + } + } +} diff --git a/app/src/main/java/com/pic/catcher/ui/vm/AppUpdateViewModel.kt b/app/src/main/java/com/pic/catcher/ui/vm/AppUpdateViewModel.kt new file mode 100644 index 0000000..e867650 --- /dev/null +++ b/app/src/main/java/com/pic/catcher/ui/vm/AppUpdateViewModel.kt @@ -0,0 +1,78 @@ +package com.pic.catcher.ui.vm + +import android.app.AlertDialog +import android.content.Context +import androidx.lifecycle.ViewModel +import com.lu.magic.util.ToastUtil +import com.lu.magic.util.log.LogUtil +import com.pic.catcher.route.MaskAppRouter +import com.pic.catcher.util.AppUpdateCheckUtil + +class AppUpdateViewModel : ViewModel() { + private var hasOnCheckAction = false + fun checkOnEnter(context: Context) { + if (hasOnCheckAction) { + return + } + if (!AppUpdateCheckUtil.hasCheckFlagOnEnter()) { + return + } + hasOnCheckAction = true + + AppUpdateCheckUtil.checkUpdate { url, name, err -> + if (url.isBlank() || name.isBlank() || err != null) { + hasOnCheckAction = false + return@checkUpdate + } + AlertDialog.Builder(context) + .setTitle("更新提示") + .setMessage("检查到新版本:$name,是否更新?") + .setNegativeButton("取消", null) + .setNeutralButton("确定") { _, _ -> + openBrowserDownloadUrl(context, url) + } + .setPositiveButton("不再提示") { _, _ -> + AppUpdateCheckUtil.setCheckFlagOnEnter(false) + } + .setOnDismissListener { + hasOnCheckAction = false + } + .show() + } + } + + fun checkOnce(context: Context, fallBackText: String = "未检查到新版本") { + if (hasOnCheckAction) { + return + } + hasOnCheckAction = true + AppUpdateCheckUtil.checkUpdate { url, name, err -> + if (url.isBlank() || name.isBlank()) { + hasOnCheckAction = false + ToastUtil.show(fallBackText) + return@checkUpdate + } + AlertDialog.Builder(context) + .setTitle("更新提示") + .setMessage("检查到新版本$name,是否更新?") + .setNegativeButton("取消", null) + .setNeutralButton("确定") { _, _ -> + openBrowserDownloadUrl(context, url) + } + .setOnDismissListener { + hasOnCheckAction = false + } + .show() + } + } + + private fun openBrowserDownloadUrl(context: Context, url: String) { + try { + MaskAppRouter.route(context, url) + } catch (e: Exception) { + ToastUtil.show("下载链接打开失败") + LogUtil.w(e) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/pic/catcher/ui/wrapper/WebViewComponent.kt b/app/src/main/java/com/pic/catcher/ui/wrapper/WebViewComponent.kt new file mode 100644 index 0000000..ef84193 --- /dev/null +++ b/app/src/main/java/com/pic/catcher/ui/wrapper/WebViewComponent.kt @@ -0,0 +1,196 @@ +package com.pic.catcher.ui.wrapper + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import android.net.http.SslError +import android.os.Build +import android.util.Log +import android.view.ViewGroup +import android.view.ViewGroup.MarginLayoutParams +import android.webkit.ConsoleMessage +import android.webkit.SslErrorHandler +import android.webkit.WebChromeClient +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.core.view.contains +import com.lu.magic.util.kxt.toElseEmptyString +import com.lu.magic.util.log.LogUtil +import com.pic.catcher.route.MaskAppRouter +import com.pic.catcher.BuildConfig +import java.net.URL + +@Suppress("DEPRECATION") +class WebViewComponent(context: Context) { + var forceHtml = false + + companion object { + private val TAG: String = WebViewComponent::class.java.simpleName + } + + private var webviewComponentCallBack: com.pic.catcher.ui.wrapper.WebViewComponentCallBack? = null + val webView = WebView(context) + + init { + if (BuildConfig.DEBUG) { + WebView.setWebContentsDebuggingEnabled(true) + } + webView.settings.let { + it.domStorageEnabled = true + it.javaScriptEnabled = true + it.databaseEnabled = true + it.javaScriptCanOpenWindowsAutomatically = true + it.allowFileAccess = true + it.allowContentAccess = true + it.allowFileAccessFromFileURLs = true + it.allowUniversalAccessFromFileURLs = true + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + it.isAlgorithmicDarkeningAllowed = true + } + } + webView.webViewClient = object : WebViewClient() { + val webUrlInterceptor = WebUrlInterceptor() + override fun onLoadResource(view: WebView?, url: String?) { + super.onLoadResource(view, url) + Log.i(TAG, "webViewLinker onLoadResource $url") + } + + override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean { + LogUtil.i(TAG, "shouldOverrideUrlLoading", url) + if (webUrlInterceptor.shouldOverrideUrlLoading(view, Uri.parse(url))) { + return true + } + return super.shouldOverrideUrlLoading(view, url) + } + + override fun shouldInterceptRequest(view: WebView?, url: String): WebResourceResponse? { + LogUtil.i(TAG, "shouldInterceptRequest", url) + if (forceHtml && url.endsWith(".html")) { + val iStream = try { + URL(url).openStream() + } catch (e: Throwable) { + null + } + if (iStream != null) { + return WebResourceResponse("text/html", "utf-8", iStream) + } + } + return super.shouldInterceptRequest(view, url) + } + + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + LogUtil.i(TAG, "onPageStarted", url) + super.onPageStarted(view, url, favicon) + } + + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url) + LogUtil.i(TAG, "onPageFinished", url) + webviewComponentCallBack?.onPageFinish(view, url) + } + + override fun onReceivedError(view: WebView?, errorCode: Int, description: String?, failingUrl: String?) { + super.onReceivedError(view, errorCode, description, failingUrl) + LogUtil.w(TAG, "onReceivedError", errorCode) + } + + override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) { + super.onReceivedError(view, request, error) + LogUtil.w(TAG, "onReceivedError", request?.url) + } + + override fun onReceivedHttpError( + view: WebView?, + request: WebResourceRequest?, + errorResponse: WebResourceResponse? + ) { + super.onReceivedHttpError(view, request, errorResponse) + LogUtil.w(TAG, "onReceivedHttpError", request?.url, errorResponse) + } + + @SuppressLint("WebViewClientOnReceivedSslError") + override fun onReceivedSslError(view: WebView?, handler: SslErrorHandler?, error: SslError?) { + handler?.proceed() + LogUtil.w(TAG, "onReceivedSslError", error) + } + + } + webView.webChromeClient = object : WebChromeClient() { + override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean { + val level = consoleMessage.messageLevel() + val msgArr = arrayOf( + TAG, + "console: ", + level, + consoleMessage.lineNumber(), + consoleMessage.sourceId(), + consoleMessage.message() + ) + + when (level) { + ConsoleMessage.MessageLevel.DEBUG -> LogUtil.d(*msgArr) + ConsoleMessage.MessageLevel.WARNING -> LogUtil.w(*msgArr) + ConsoleMessage.MessageLevel.ERROR -> LogUtil.e(*msgArr) + else -> LogUtil.i(*msgArr) + } + return super.onConsoleMessage(consoleMessage) + } + + override fun onReceivedTitle(view: WebView?, title: String?) { + super.onReceivedTitle(view, title) + webviewComponentCallBack?.onReceivedTitle(view, title) + } + + } + + } + + fun loadUrl(url: String, callBack: com.pic.catcher.ui.wrapper.WebViewComponentCallBack? = null) { + this.webviewComponentCallBack = callBack + webView.loadUrl(url) + LogUtil.i(TAG, "webview load url:", url) + } + + fun attachView(root: ViewGroup): WebViewComponent { + if (root.contains(webView)) { + return this + } + root.addView(webView, MarginLayoutParams.MATCH_PARENT, MarginLayoutParams.MATCH_PARENT) + return this + } + + fun destroy() { + webView.loadUrl("about:blank") + webView.clearMatches() + webView.clearHistory() + webView.destroy() + } + + class WebUrlInterceptor() { + fun shouldOverrideUrlLoading(view: WebView?, uri: Uri?): Boolean { + if (uri == null || view == null) return false + + val scheme = uri.scheme.toElseEmptyString().lowercase() + if (uri.toString().matches(Regex("https?://.+\\.(?:apk|zip|rar|gzip)", RegexOption.IGNORE_CASE))) { + MaskAppRouter.route(view.context, uri.toString()) + return true + } + if (!"http".equals(scheme, true) + && !"https".equals(scheme, true) + && !"file".equals(scheme, true) + && !"about".equals(scheme, true) + ) { +// webViewLinker.callBack?.invoke(view, uri) + LogUtil.i(TAG, "webView appLink:", uri) + MaskAppRouter.route(view.context, uri.toString()) + return true + } + return false + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/pic/catcher/ui/wrapper/WebViewComponentCallBack.java b/app/src/main/java/com/pic/catcher/ui/wrapper/WebViewComponentCallBack.java new file mode 100644 index 0000000..25c95b4 --- /dev/null +++ b/app/src/main/java/com/pic/catcher/ui/wrapper/WebViewComponentCallBack.java @@ -0,0 +1,14 @@ +package com.pic.catcher.ui.wrapper; + +import android.webkit.WebView; + +import org.jetbrains.annotations.Nullable; + +public interface WebViewComponentCallBack { + default void onPageFinish(WebView view, String url) { + + } + + default void onReceivedTitle(@Nullable WebView view, @Nullable String title) { + } +} diff --git a/app/src/main/java/com/pic/catcher/util/ActivityUtils.java b/app/src/main/java/com/pic/catcher/util/ActivityUtils.java new file mode 100644 index 0000000..d834f59 --- /dev/null +++ b/app/src/main/java/com/pic/catcher/util/ActivityUtils.java @@ -0,0 +1,84 @@ +package com.pic.catcher.util; + +import android.app.Activity; +import android.content.Context; +import android.content.ContextWrapper; +import android.os.Build; + +import java.lang.ref.WeakReference; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; + +public class ActivityUtils { + + /** + * Return the activity by context. + * + * @param context The context. + * @return the activity by context. + */ + public static Activity getActivityByContext(Context context) { + Activity activity = getActivityByContextInner(context); + if (!isActivityAlive(activity)) return null; + return activity; + } + + private static Activity getActivityByContextInner(Context context) { + if (context == null) return null; + List list = new ArrayList<>(); + while (context instanceof ContextWrapper) { + if (context instanceof Activity) { + return (Activity) context; + } + Activity activity = getActivityFromDecorContext(context); + if (activity != null) return activity; + list.add(context); + context = ((ContextWrapper) context).getBaseContext(); + if (context == null) { + return null; + } + if (list.contains(context)) { + // loop context + return null; + } + } + return null; + } + + private static Activity getActivityFromDecorContext(Context context) { + if (context == null) return null; + if (context.getClass().getName().equals("com.android.internal.policy.DecorContext")) { + try { + Field mActivityContextField = context.getClass().getDeclaredField("mActivityContext"); + mActivityContextField.setAccessible(true); + //noinspection ConstantConditions,unchecked + return ((WeakReference) mActivityContextField.get(context)).get(); + } catch (Exception ignore) { + } + } + return null; + } + + /** + * Return whether the activity is alive. + * + * @param context The context. + * @return {@code true}: yes
{@code false}: no + */ + public static boolean isActivityAlive(final Context context) { + return isActivityAlive(getActivityByContext(context)); + } + + /** + * Return whether the activity is alive. + * + * @param activity The activity. + * @return {@code true}: yes
{@code false}: no + */ + public static boolean isActivityAlive(final Activity activity) { + return activity != null && !activity.isFinishing() + && (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1 || !activity.isDestroyed()); + } + +} diff --git a/app/src/main/java/com/pic/catcher/util/AppUpdateCheckUtil.kt b/app/src/main/java/com/pic/catcher/util/AppUpdateCheckUtil.kt new file mode 100644 index 0000000..6e54e64 --- /dev/null +++ b/app/src/main/java/com/pic/catcher/util/AppUpdateCheckUtil.kt @@ -0,0 +1,97 @@ +package com.pic.catcher.util + +import com.lu.magic.util.thread.AppExecutor +import com.pic.catcher.util.http.HttpConnectUtil +import org.json.JSONArray +import org.json.JSONObject +import java.lang.Exception + +class AppUpdateCheckUtil { + companion object { + val repoLastReleaseUrl = "https://api.github.com/repos/Mingyueyixi/PicCatcher/releases/latest" + private val key_check_app_update_on_enter = "check_app_update_on_enter" + + fun setCheckFlagOnEnter(check: Boolean) { + LocalKVUtil.getDefaultTable().edit().putBoolean(key_check_app_update_on_enter, check).apply() + } + + fun hasCheckFlagOnEnter(): Boolean { + return LocalKVUtil.getDefaultTable().getBoolean(key_check_app_update_on_enter, true) + } + + fun checkUpdate(callBack: (downloadUrl: String, name: String, err: Throwable?) -> Unit) { + val currVersionCode = AppVersionUtil.getVersionCode() + if (currVersionCode == -1) { + AppExecutor.executeMain { + callBack.invoke("", "", IllegalStateException("无法获取当前版本号")) + } + return + } + checkLastReleaseByRepo { url, code, name, err -> + if (url != null && code > currVersionCode) { + AppExecutor.executeMain { + callBack.invoke(url, name, null) + } + } else { + AppExecutor.executeMain { + callBack.invoke("", "", err) + } + } + } + } + + private fun checkLastReleaseByRepo(callBack: (url: String?, versionCode: Int, versionName: String, error: Throwable?) -> Unit) { + HttpConnectUtil.get(repoLastReleaseUrl) { + var apkDownloadUrl: String? = null + var error: Throwable? = null + var versionCode = -1 + var versionName = "" + + try { + if (it.error != null) { + throw Exception(error) + } + if (it.code != 200) { + throw Exception("response code is not 200") + } + val json = JSONObject(it.body.toString(Charsets.UTF_8)) + val tagName = json.optString("tag_name") + val assets = json.optJSONArray("assets") + + if (tagName.isBlank() || assets == null || assets.length() == 0) { + throw Exception("tagName is Blank or assets is empty") + } else { + apkDownloadUrl = findDownloadUrl(assets) + if (apkDownloadUrl == null) { + throw Exception("assets node can't find downloadUrl") + } + + val index = tagName.indexOf("-") + if (index > -1) { + versionCode = tagName.substring(0, index).toInt() + versionName = tagName.substring(index + 1) + } else { + throw Exception("tag_name format error: $tagName") + } + } + + } catch (e: Exception) { + error = e + } + callBack.invoke(apkDownloadUrl, versionCode, versionName, error) + } + } + + private fun findDownloadUrl(assetsJsonArr: JSONArray): String? { + for (i in 0 until assetsJsonArr.length()) { + val ele = assetsJsonArr.getJSONObject(i) + val url = ele.optString("browser_download_url") + if (url.endsWith(".apk")) { + return url + } + } + return null + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pic/catcher/util/AppVersionUtil.kt b/app/src/main/java/com/pic/catcher/util/AppVersionUtil.kt new file mode 100644 index 0000000..7b4a8d6 --- /dev/null +++ b/app/src/main/java/com/pic/catcher/util/AppVersionUtil.kt @@ -0,0 +1,46 @@ +package com.pic.catcher.util + +import com.lu.magic.util.AppUtil +import com.lu.magic.util.log.LogUtil + +class AppVersionUtil { + companion object { + private var versionCode = -1 + private var versionName = "" + + @JvmStatic + fun getVersionName(): String? { + if (versionName.isBlank()) { + versionName = try { + val manager = AppUtil.getContext().packageManager + val info = manager.getPackageInfo(AppUtil.getContext().packageName, 0) + return info.versionName + } catch (e: Exception) { + LogUtil.e(e) + "" + } + } + return versionName + } + + @JvmStatic + fun getVersionCode(): Int { + if (versionCode == -1) { + versionCode = try { + val manager = AppUtil.getContext().packageManager + val info = manager.getPackageInfo(AppUtil.getContext().packageName, 0) + info.versionCode + } catch (e: Exception) { + LogUtil.e(e) + -1 + } + } + return versionCode + } + + @JvmStatic + fun getSmartVersionName(): String { + return "${getVersionName()}(${getVersionCode()})" + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pic/catcher/util/BarUtils.java b/app/src/main/java/com/pic/catcher/util/BarUtils.java new file mode 100644 index 0000000..424f4e6 --- /dev/null +++ b/app/src/main/java/com/pic/catcher/util/BarUtils.java @@ -0,0 +1,707 @@ +package com.pic.catcher.util; + + +import static android.Manifest.permission.EXPAND_STATUS_BAR; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Color; +import android.graphics.Point; +import android.os.Build; +import android.util.TypedValue; +import android.view.Display; +import android.view.KeyCharacterMap; +import android.view.KeyEvent; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.ViewGroup; +import android.view.ViewGroup.MarginLayoutParams; +import android.view.Window; +import android.view.WindowManager; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; +import androidx.annotation.RequiresPermission; +import androidx.drawerlayout.widget.DrawerLayout; + +import com.lu.magic.util.AppUtil; + +import java.lang.reflect.Method; + +/** + *
+ *     author: Blankj
+ *     blog  : http://blankj.com
+ *     time  : 2016/09/23
+ *     desc  : utils about bar
+ * 
+ */ +public final class BarUtils { + + /////////////////////////////////////////////////////////////////////////// + // status bar + /////////////////////////////////////////////////////////////////////////// + + private static final String TAG_STATUS_BAR = "TAG_STATUS_BAR"; + private static final String TAG_OFFSET = "TAG_OFFSET"; + private static final int KEY_OFFSET = -123; + + private BarUtils() { + throw new UnsupportedOperationException("u can't instantiate me..."); + } + + /** + * Return the status bar's height. + * + * @return the status bar's height + */ + public static int getStatusBarHeight() { + Resources resources = Resources.getSystem(); + int resourceId = resources.getIdentifier("status_bar_height", "dimen", "android"); + return resources.getDimensionPixelSize(resourceId); + } + + /** + * Set the status bar's visibility. + * + * @param activity The activity. + * @param isVisible True to set status bar visible, false otherwise. + */ + public static void setStatusBarVisibility(@NonNull final Activity activity, + final boolean isVisible) { + setStatusBarVisibility(activity.getWindow(), isVisible); + } + + /** + * Set the status bar's visibility. + * + * @param window The window. + * @param isVisible True to set status bar visible, false otherwise. + */ + public static void setStatusBarVisibility(@NonNull final Window window, + final boolean isVisible) { + if (isVisible) { + window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + showStatusBarView(window); + addMarginTopEqualStatusBarHeight(window); + } else { + window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + hideStatusBarView(window); + subtractMarginTopEqualStatusBarHeight(window); + } + } + + /** + * Return whether the status bar is visible. + * + * @param activity The activity. + * @return {@code true}: yes
{@code false}: no + */ + public static boolean isStatusBarVisible(@NonNull final Activity activity) { + int flags = activity.getWindow().getAttributes().flags; + return (flags & WindowManager.LayoutParams.FLAG_FULLSCREEN) == 0; + } + + /** + * Set the status bar's light mode. + * + * @param activity The activity. + * @param isLightMode True to set status bar light mode, false otherwise. + */ + public static void setStatusBarLightMode(@NonNull final Activity activity, + final boolean isLightMode) { + setStatusBarLightMode(activity.getWindow(), isLightMode); + } + + /** + * Set the status bar's light mode. + * + * @param window The window. + * @param isLightMode True to set status bar light mode, false otherwise. + */ + public static void setStatusBarLightMode(@NonNull final Window window, + final boolean isLightMode) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + View decorView = window.getDecorView(); + int vis = decorView.getSystemUiVisibility(); + if (isLightMode) { + vis |= View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR; + } else { + vis &= ~View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR; + } + decorView.setSystemUiVisibility(vis); + } + } + + /** + * Is the status bar light mode. + * + * @param activity The activity. + * @return {@code true}: yes
{@code false}: no + */ + public static boolean isStatusBarLightMode(@NonNull final Activity activity) { + return isStatusBarLightMode(activity.getWindow()); + } + + /** + * Is the status bar light mode. + * + * @param window The window. + * @return {@code true}: yes
{@code false}: no + */ + public static boolean isStatusBarLightMode(@NonNull final Window window) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + View decorView = window.getDecorView(); + int vis = decorView.getSystemUiVisibility(); + return (vis & View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR) != 0; + } + return false; + } + + /** + * Add the top margin size equals status bar's height for view. + * + * @param view The view. + */ + public static void addMarginTopEqualStatusBarHeight(@NonNull View view) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) return; + view.setTag(TAG_OFFSET); + Object haveSetOffset = view.getTag(KEY_OFFSET); + if (haveSetOffset != null && (Boolean) haveSetOffset) return; + MarginLayoutParams layoutParams = (MarginLayoutParams) view.getLayoutParams(); + layoutParams.setMargins(layoutParams.leftMargin, + layoutParams.topMargin + getStatusBarHeight(), + layoutParams.rightMargin, + layoutParams.bottomMargin); + view.setTag(KEY_OFFSET, true); + } + + /** + * Subtract the top margin size equals status bar's height for view. + * + * @param view The view. + */ + public static void subtractMarginTopEqualStatusBarHeight(@NonNull View view) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) return; + Object haveSetOffset = view.getTag(KEY_OFFSET); + if (haveSetOffset == null || !(Boolean) haveSetOffset) return; + MarginLayoutParams layoutParams = (MarginLayoutParams) view.getLayoutParams(); + layoutParams.setMargins(layoutParams.leftMargin, + layoutParams.topMargin - getStatusBarHeight(), + layoutParams.rightMargin, + layoutParams.bottomMargin); + view.setTag(KEY_OFFSET, false); + } + + private static void addMarginTopEqualStatusBarHeight(final Window window) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) return; + View withTag = window.getDecorView().findViewWithTag(TAG_OFFSET); + if (withTag == null) return; + addMarginTopEqualStatusBarHeight(withTag); + } + + private static void subtractMarginTopEqualStatusBarHeight(final Window window) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) return; + View withTag = window.getDecorView().findViewWithTag(TAG_OFFSET); + if (withTag == null) return; + subtractMarginTopEqualStatusBarHeight(withTag); + } + + /** + * Set the status bar's color. + * + * @param activity The activity. + * @param color The status bar's color. + */ + public static View setStatusBarColor(@NonNull final Activity activity, + @ColorInt final int color) { + return setStatusBarColor(activity, color, false); + } + + /** + * Set the status bar's color. + * + * @param activity The activity. + * @param color The status bar's color. + * @param isDecor True to add fake status bar in DecorView, + * false to add fake status bar in ContentView. + */ + public static View setStatusBarColor(@NonNull final Activity activity, + @ColorInt final int color, + final boolean isDecor) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) return null; + transparentStatusBar(activity); + return applyStatusBarColor(activity, color, isDecor); + } + + + /** + * Set the status bar's color. + * + * @param window The window. + * @param color The status bar's color. + */ + public static View setStatusBarColor(@NonNull final Window window, + @ColorInt final int color) { + return setStatusBarColor(window, color, false); + } + + /** + * Set the status bar's color. + * + * @param window The window. + * @param color The status bar's color. + * @param isDecor True to add fake status bar in DecorView, + * false to add fake status bar in ContentView. + */ + public static View setStatusBarColor(@NonNull final Window window, + @ColorInt final int color, + final boolean isDecor) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) return null; + transparentStatusBar(window); + return applyStatusBarColor(window, color, isDecor); + } + + /** + * Set the status bar's color. + * + * @param fakeStatusBar The fake status bar view. + * @param color The status bar's color. + */ + public static void setStatusBarColor(@NonNull final View fakeStatusBar, + @ColorInt final int color) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) return; + Activity activity = ActivityUtils.getActivityByContext(fakeStatusBar.getContext()); + if (activity == null) return; + transparentStatusBar(activity); + fakeStatusBar.setVisibility(View.VISIBLE); + ViewGroup.LayoutParams layoutParams = fakeStatusBar.getLayoutParams(); + layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT; + layoutParams.height = getStatusBarHeight(); + fakeStatusBar.setBackgroundColor(color); + } + + /** + * Set the custom status bar. + * + * @param fakeStatusBar The fake status bar view. + */ + public static void setStatusBarCustom(@NonNull final View fakeStatusBar) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) return; + Activity activity = ActivityUtils.getActivityByContext(fakeStatusBar.getContext()); + if (activity == null) return; + transparentStatusBar(activity); + fakeStatusBar.setVisibility(View.VISIBLE); + ViewGroup.LayoutParams layoutParams = fakeStatusBar.getLayoutParams(); + if (layoutParams == null) { + layoutParams = new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + getStatusBarHeight() + ); + fakeStatusBar.setLayoutParams(layoutParams); + } else { + layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT; + layoutParams.height = getStatusBarHeight(); + } + } + + /** + * Set the status bar's color for DrawerLayout. + *

DrawLayout must add {@code android:fitsSystemWindows="true"}

+ * + * @param drawer The DrawLayout. + * @param fakeStatusBar The fake status bar view. + * @param color The status bar's color. + */ + public static void setStatusBarColor4Drawer(@NonNull final DrawerLayout drawer, + @NonNull final View fakeStatusBar, + @ColorInt final int color) { + setStatusBarColor4Drawer(drawer, fakeStatusBar, color, false); + } + + /** + * Set the status bar's color for DrawerLayout. + *

DrawLayout must add {@code android:fitsSystemWindows="true"}

+ * + * @param drawer The DrawLayout. + * @param fakeStatusBar The fake status bar view. + * @param color The status bar's color. + * @param isTop True to set DrawerLayout at the top layer, false otherwise. + */ + public static void setStatusBarColor4Drawer(@NonNull final DrawerLayout drawer, + @NonNull final View fakeStatusBar, + @ColorInt final int color, + final boolean isTop) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) return; + Activity activity = ActivityUtils.getActivityByContext(fakeStatusBar.getContext()); + if (activity == null) return; + transparentStatusBar(activity); + drawer.setFitsSystemWindows(false); + setStatusBarColor(fakeStatusBar, color); + for (int i = 0, count = drawer.getChildCount(); i < count; i++) { + drawer.getChildAt(i).setFitsSystemWindows(false); + } + if (isTop) { + hideStatusBarView(activity); + } else { + setStatusBarColor(activity, color, false); + } + } + + private static View applyStatusBarColor(final Activity activity, + final int color, + boolean isDecor) { + return applyStatusBarColor(activity.getWindow(), color, isDecor); + } + + private static View applyStatusBarColor(final Window window, + final int color, + boolean isDecor) { + ViewGroup parent = isDecor ? + (ViewGroup) window.getDecorView() : + (ViewGroup) window.findViewById(android.R.id.content); + View fakeStatusBarView = parent.findViewWithTag(TAG_STATUS_BAR); + if (fakeStatusBarView != null) { + if (fakeStatusBarView.getVisibility() == View.GONE) { + fakeStatusBarView.setVisibility(View.VISIBLE); + } + fakeStatusBarView.setBackgroundColor(color); + } else { + fakeStatusBarView = createStatusBarView(window.getContext(), color); + parent.addView(fakeStatusBarView); + } + return fakeStatusBarView; + } + + private static void hideStatusBarView(final Activity activity) { + hideStatusBarView(activity.getWindow()); + } + + private static void hideStatusBarView(final Window window) { + ViewGroup decorView = (ViewGroup) window.getDecorView(); + View fakeStatusBarView = decorView.findViewWithTag(TAG_STATUS_BAR); + if (fakeStatusBarView == null) return; + fakeStatusBarView.setVisibility(View.GONE); + } + + private static void showStatusBarView(final Window window) { + ViewGroup decorView = (ViewGroup) window.getDecorView(); + View fakeStatusBarView = decorView.findViewWithTag(TAG_STATUS_BAR); + if (fakeStatusBarView == null) return; + fakeStatusBarView.setVisibility(View.VISIBLE); + } + + private static View createStatusBarView(final Context context, + final int color) { + View statusBarView = new View(context); + statusBarView.setLayoutParams(new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, getStatusBarHeight())); + statusBarView.setBackgroundColor(color); + statusBarView.setTag(TAG_STATUS_BAR); + return statusBarView; + } + + public static void transparentStatusBar(final Activity activity) { + transparentStatusBar(activity.getWindow()); + } + + public static void transparentStatusBar(final Window window) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) return; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS); + window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS); + int option = View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN; + int vis = window.getDecorView().getSystemUiVisibility(); + window.getDecorView().setSystemUiVisibility(option | vis); + window.setStatusBarColor(Color.TRANSPARENT); + } else { + window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS); + } + } + + /////////////////////////////////////////////////////////////////////////// + // action bar + /////////////////////////////////////////////////////////////////////////// + + /** + * Return the action bar's height. + * + * @return the action bar's height + */ + public static int getActionBarHeight() { + TypedValue tv = new TypedValue(); + if (AppUtil.getContext().getTheme().resolveAttribute(android.R.attr.actionBarSize, tv, true)) { + return TypedValue.complexToDimensionPixelSize( + tv.data, Resources.getSystem().getDisplayMetrics() + ); + } + return 0; + } + + /////////////////////////////////////////////////////////////////////////// + // notification bar + /////////////////////////////////////////////////////////////////////////// + + /** + * Set the notification bar's visibility. + *

Must hold {@code }

+ * + * @param isVisible True to set notification bar visible, false otherwise. + */ + @RequiresPermission(EXPAND_STATUS_BAR) + public static void setNotificationBarVisibility(final boolean isVisible) { + String methodName; + if (isVisible) { + methodName = (Build.VERSION.SDK_INT <= 16) ? "expand" : "expandNotificationsPanel"; + } else { + methodName = (Build.VERSION.SDK_INT <= 16) ? "collapse" : "collapsePanels"; + } + invokePanels(methodName); + } + + private static void invokePanels(final String methodName) { + try { + @SuppressLint("WrongConstant") + Object service = AppUtil.getContext().getSystemService("statusbar"); + @SuppressLint("PrivateApi") + Class statusBarManager = Class.forName("android.app.StatusBarManager"); + Method expand = statusBarManager.getMethod(methodName); + expand.invoke(service); + } catch (Exception e) { + e.printStackTrace(); + } + } + + /////////////////////////////////////////////////////////////////////////// + // navigation bar + /////////////////////////////////////////////////////////////////////////// + + /** + * Return the navigation bar's height. + * + * @return the navigation bar's height + */ + public static int getNavBarHeight() { + Resources res = Resources.getSystem(); + int resourceId = res.getIdentifier("navigation_bar_height", "dimen", "android"); + if (resourceId != 0) { + return res.getDimensionPixelSize(resourceId); + } else { + return 0; + } + } + + /** + * Set the navigation bar's visibility. + * + * @param activity The activity. + * @param isVisible True to set navigation bar visible, false otherwise. + */ + public static void setNavBarVisibility(@NonNull final Activity activity, boolean isVisible) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) return; + setNavBarVisibility(activity.getWindow(), isVisible); + + } + + /** + * Set the navigation bar's visibility. + * + * @param window The window. + * @param isVisible True to set navigation bar visible, false otherwise. + */ + public static void setNavBarVisibility(@NonNull final Window window, boolean isVisible) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) return; + final ViewGroup decorView = (ViewGroup) window.getDecorView(); + for (int i = 0, count = decorView.getChildCount(); i < count; i++) { + final View child = decorView.getChildAt(i); + final int id = child.getId(); + if (id != View.NO_ID) { + String resourceEntryName = getResNameById(id); + if ("navigationBarBackground".equals(resourceEntryName)) { + child.setVisibility(isVisible ? View.VISIBLE : View.INVISIBLE); + } + } + } + final int uiOptions = View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY; + if (isVisible) { + decorView.setSystemUiVisibility(decorView.getSystemUiVisibility() & ~uiOptions); + } else { + decorView.setSystemUiVisibility(decorView.getSystemUiVisibility() | uiOptions); + } + } + + /** + * Return whether the navigation bar visible. + *

Call it in onWindowFocusChanged will get right result.

+ * + * @param activity The activity. + * @return {@code true}: yes
{@code false}: no + */ + public static boolean isNavBarVisible(@NonNull final Activity activity) { + return isNavBarVisible(activity.getWindow()); + } + + /** + * Return whether the navigation bar visible. + *

Call it in onWindowFocusChanged will get right result.

+ * + * @param window The window. + * @return {@code true}: yes
{@code false}: no + */ + public static boolean isNavBarVisible(@NonNull final Window window) { + boolean isVisible = false; + ViewGroup decorView = (ViewGroup) window.getDecorView(); + for (int i = 0, count = decorView.getChildCount(); i < count; i++) { + final View child = decorView.getChildAt(i); + final int id = child.getId(); + if (id != View.NO_ID) { + String resourceEntryName = getResNameById(id); + if ("navigationBarBackground".equals(resourceEntryName) + && child.getVisibility() == View.VISIBLE) { + isVisible = true; + break; + } + } + } + if (isVisible) { + int visibility = decorView.getSystemUiVisibility(); + isVisible = (visibility & View.SYSTEM_UI_FLAG_HIDE_NAVIGATION) == 0; + } + return isVisible; + } + + private static String getResNameById(int id) { + try { + return AppUtil.getContext().getResources().getResourceEntryName(id); + } catch (Exception ignore) { + return ""; + } + } + + /** + * Set the navigation bar's color. + * + * @param activity The activity. + * @param color The navigation bar's color. + */ + @RequiresApi(Build.VERSION_CODES.LOLLIPOP) + public static void setNavBarColor(@NonNull final Activity activity, @ColorInt final int color) { + setNavBarColor(activity.getWindow(), color); + } + + /** + * Set the navigation bar's color. + * + * @param window The window. + * @param color The navigation bar's color. + */ + @RequiresApi(Build.VERSION_CODES.LOLLIPOP) + public static void setNavBarColor(@NonNull final Window window, @ColorInt final int color) { + window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS); + window.setNavigationBarColor(color); + } + + /** + * Return the color of navigation bar. + * + * @param activity The activity. + * @return the color of navigation bar + */ + @RequiresApi(Build.VERSION_CODES.LOLLIPOP) + public static int getNavBarColor(@NonNull final Activity activity) { + return getNavBarColor(activity.getWindow()); + } + + /** + * Return the color of navigation bar. + * + * @param window The window. + * @return the color of navigation bar + */ + @RequiresApi(Build.VERSION_CODES.LOLLIPOP) + public static int getNavBarColor(@NonNull final Window window) { + return window.getNavigationBarColor(); + } + + /** + * Return whether the navigation bar visible. + * + * @return {@code true}: yes
{@code false}: no + */ + public static boolean isSupportNavBar() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { + WindowManager wm = (WindowManager) AppUtil.getContext().getSystemService(Context.WINDOW_SERVICE); + if (wm == null) return false; + Display display = wm.getDefaultDisplay(); + Point size = new Point(); + Point realSize = new Point(); + display.getSize(size); + display.getRealSize(realSize); + return realSize.y != size.y || realSize.x != size.x; + } + boolean menu = ViewConfiguration.get(AppUtil.getContext()).hasPermanentMenuKey(); + boolean back = KeyCharacterMap.deviceHasKey(KeyEvent.KEYCODE_BACK); + return !menu && !back; + } + + /** + * Set the nav bar's light mode. + * + * @param activity The activity. + * @param isLightMode True to set nav bar light mode, false otherwise. + */ + public static void setNavBarLightMode(@NonNull final Activity activity, + final boolean isLightMode) { + setNavBarLightMode(activity.getWindow(), isLightMode); + } + + /** + * Set the nav bar's light mode. + * + * @param window The window. + * @param isLightMode True to set nav bar light mode, false otherwise. + */ + public static void setNavBarLightMode(@NonNull final Window window, + final boolean isLightMode) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + View decorView = window.getDecorView(); + int vis = decorView.getSystemUiVisibility(); + if (isLightMode) { + vis |= View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR; + } else { + vis &= ~View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR; + } + decorView.setSystemUiVisibility(vis); + } + } + + /** + * Is the nav bar light mode. + * + * @param activity The activity. + * @return {@code true}: yes
{@code false}: no + */ + public static boolean isNavBarLightMode(@NonNull final Activity activity) { + return isNavBarLightMode(activity.getWindow()); + } + + /** + * Is the nav bar light mode. + * + * @param window The window. + * @return {@code true}: yes
{@code false}: no + */ + public static boolean isNavBarLightMode(@NonNull final Window window) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + View decorView = window.getDecorView(); + int vis = decorView.getSystemUiVisibility(); + return (vis & View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR) != 0; + } + return false; + } +} diff --git a/app/src/main/java/com/pic/catcher/util/ClipboardUtil.java b/app/src/main/java/com/pic/catcher/util/ClipboardUtil.java new file mode 100644 index 0000000..5578a2f --- /dev/null +++ b/app/src/main/java/com/pic/catcher/util/ClipboardUtil.java @@ -0,0 +1,30 @@ +package com.pic.catcher.util; + +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; + +import com.lu.magic.util.AppUtil; + +public class ClipboardUtil { + /** + * 复制内容到剪切板 + * + * @param copyStr + * @return + */ + public static boolean copy(CharSequence copyStr) { + try { + //获取剪贴板管理器 + ClipboardManager cm = (ClipboardManager) AppUtil.getContext().getSystemService(Context.CLIPBOARD_SERVICE); + // 创建普通字符型ClipData + ClipData mClipData = ClipData.newPlainText("Label", copyStr); + // 将ClipData内容放到系统剪贴板里。 + cm.setPrimaryClip(mClipData); + return true; + } catch (Exception e) { + return false; + } + } + +} diff --git a/app/src/main/java/com/pic/catcher/util/CodeUtil.java b/app/src/main/java/com/pic/catcher/util/CodeUtil.java new file mode 100644 index 0000000..a2c5710 --- /dev/null +++ b/app/src/main/java/com/pic/catcher/util/CodeUtil.java @@ -0,0 +1,162 @@ +package com.pic.catcher.util; + +import android.content.Context; + +import com.lu.magic.util.ReflectUtil; +import com.lu.magic.util.function.Consumer; +import com.lu.magic.util.function.Predicate; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; + +import dalvik.system.DexFile; + +public class CodeUtil { + /** + * 获取应用程序下的所有Dex文件 + * + * @param packageCodePath 包路径 + * @return Set + */ + private static List getDexFileList(String packageCodePath) { + List dexFiles = new ArrayList<>(); + File dir = new File(packageCodePath).getParentFile(); + File[] files = dir.listFiles(); + for (File file : files) { + try { + String absolutePath = file.getAbsolutePath(); + if (!absolutePath.contains(".")) continue; + String suffix = absolutePath.substring(absolutePath.lastIndexOf(".")); + if (!suffix.equals(".apk")) continue; + DexFile dexFile = getDexFile(file.getAbsolutePath()); + if (dexFile == null) continue; + dexFiles.add(dexFile); + } catch (Exception e) { + e.printStackTrace(); + } + } + return dexFiles; + } + + /** + * 获取DexFile文件 + * + * @param path 路径 + * @return DexFile + */ + public static DexFile getDexFile(String path) { + try { + return new DexFile(path); + } catch (IOException e) { + return null; + } + } + + + public static List filterClass(Context context, Predicate func) { + List dexFiles = getDexFileListNewApi(context.getClassLoader()); + if (dexFiles.isEmpty()) { + //老方法需要重新读取,耗时大 + dexFiles = getDexFileList(context.getApplicationContext().getPackageCodePath()); + } + return filterClassInternal(dexFiles.iterator(), func); + } + + public static void eachClass(Context context, Consumer consumer) { + List dexFiles = getDexFileListNewApi(context.getClassLoader()); + if (dexFiles.isEmpty()) { + //老方法需要重新读取,耗时大 + dexFiles = getDexFileList(context.getApplicationContext().getPackageCodePath()); + } + eachClassInternal(dexFiles.iterator(), consumer); + } + + /** + * 读取类路径下的所有类 + * + * @param context 上下文 + * @param pkgName 包名 + * @return List + */ + public static List filterClass(Context context, String pkgName, boolean includeChildDir) { + List dexFiles = getDexFileListNewApi(context.getClassLoader()); + if (dexFiles.isEmpty()) { + //老方法需要重新读取,耗时大 + dexFiles = getDexFileList(context.getApplicationContext().getPackageCodePath()); + } + + return filterClassInternal(dexFiles.iterator(), currentClassPath -> { + if (!currentClassPath.startsWith(pkgName)) { + return false; + } + if (includeChildDir) { + return true; + } else { + return '.' == currentClassPath.charAt(pkgName.length()); + } + }); + } + + + public static List getDexFileListNewApi(ClassLoader classLoader) { + List result = new ArrayList<>(); + try { + Object pathList = ReflectUtil.getFieldValue(classLoader, classLoader.getClass().getSuperclass(), "pathList"); + Object[] dexElements = (Object[]) ReflectUtil.getFieldValue(pathList, "dexElements"); + for (Object dexElement : dexElements) { + DexFile dexFile = (DexFile) ReflectUtil.getFieldValue(dexElement, "dexFile"); + result.add(dexFile); + } + } catch (Exception e) { + e.printStackTrace(); + } + return result; + } + + + private static void eachClassInternal(Iterator dexFiles, Consumer func) { + + while (dexFiles.hasNext()) { + DexFile dexFile = dexFiles.next(); + if (dexFile == null) continue; + + Enumeration entries = dexFile.entries(); + while (entries.hasMoreElements()) { + try { + String currentClassPath = entries.nextElement(); + if (currentClassPath == null || currentClassPath.isEmpty()) { + continue; + } + func.accept(currentClassPath); + } catch (Exception e) { + e.printStackTrace(); + } + } + } + + } + + private static List filterClassInternal(Iterator dexFiles, Predicate predicateFunc) { + HashSet result = new HashSet<>(); + eachClassInternal(dexFiles, s -> { + if (predicateFunc.test(s)) { + result.add(s); + } + }); + return new ArrayList<>(result); + } + + + public static String getPackageName(String typeName) { + int index = typeName.lastIndexOf("."); + if (index == -1) { + return null; + } + return typeName.substring(0, index); + } +} diff --git a/app/src/main/java/com/pic/catcher/util/ColorUtilX.kt b/app/src/main/java/com/pic/catcher/util/ColorUtilX.kt new file mode 100644 index 0000000..d2cfae2 --- /dev/null +++ b/app/src/main/java/com/pic/catcher/util/ColorUtilX.kt @@ -0,0 +1,32 @@ +package com.pic.catcher.util + +import android.graphics.Color +import com.lu.magic.util.ColorUtil + +class ColorUtilX { + companion object { + /** + * 颜色取反 + */ + @JvmStatic + fun invertColor(color: Int): Int { + // 将颜色值转换为ARGB格式 + val a = Color.alpha(color) + val r = Color.red(color) + val g = Color.green(color) + val b = Color.blue(color) + + // 对RGB分量进行取反 + val invR = 255 - r + val invG = 255 - g + val invB = 255 - b + + // 构建新的颜色值 + return Color.argb(a, invR, invG, invB) + } + + } + +} + +fun ColorUtil.invertColor(color: Int) = ColorUtilX.invertColor(color) \ No newline at end of file diff --git a/app/src/main/java/com/pic/catcher/util/EachUtil.java b/app/src/main/java/com/pic/catcher/util/EachUtil.java new file mode 100644 index 0000000..8151237 --- /dev/null +++ b/app/src/main/java/com/pic/catcher/util/EachUtil.java @@ -0,0 +1,30 @@ +package com.pic.catcher.util; + +import java.lang.reflect.Field; +import java.util.function.Predicate; + +public class EachUtil { + + public static void eachFieldValue(Object obj, Predicate consumer) { + Class clazz = obj.getClass(); + while (clazz != null) { + for (Field field : clazz.getDeclaredFields()) { + field.setAccessible(true); + try { + Object v = field.get(obj); + boolean isBreak = consumer.test(v); + if (isBreak) { + break; + } + } catch (IllegalAccessException e) { + e.printStackTrace(); + } + } + try { + clazz = clazz.getSuperclass(); + } catch (Exception e) { + break; + } + } + } +} diff --git a/app/src/main/java/com/pic/catcher/util/KeyBoxUtil.java b/app/src/main/java/com/pic/catcher/util/KeyBoxUtil.java new file mode 100644 index 0000000..f1a6f5d --- /dev/null +++ b/app/src/main/java/com/pic/catcher/util/KeyBoxUtil.java @@ -0,0 +1,264 @@ +package com.pic.catcher.util; + +import android.app.Activity; +import android.content.Context; +import android.graphics.Rect; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.ResultReceiver; +import android.util.Log; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewTreeObserver.OnGlobalLayoutListener; +import android.view.Window; +import android.view.WindowManager; +import android.view.inputmethod.InputMethodManager; +import android.widget.EditText; +import android.widget.FrameLayout; + +import androidx.annotation.NonNull; + +import com.lu.magic.util.AppUtil; + +import java.lang.reflect.Field; + +public final class KeyBoxUtil { + + private static final int TAG_ON_GLOBAL_LAYOUT_LISTENER = -8; + + private KeyBoxUtil() { + throw new UnsupportedOperationException("u can't instantiate me..."); + } + + + public static void showSoftInput() { + InputMethodManager imm = (InputMethodManager) AppUtil.getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + if (imm == null) { + return; + } + imm.toggleSoftInput(InputMethodManager.SHOW_FORCED, InputMethodManager.HIDE_IMPLICIT_ONLY); + } + + + public static void showSoftInput(@NonNull Activity activity) { + if (!isSoftInputVisible(activity)) { + toggleSoftInput(); + } + } + + + public static void showSoftInput(@NonNull final View view) { + showSoftInput(view, 0); + } + + + public static void showSoftInput(@NonNull final View view, final int flags) { + InputMethodManager imm = + (InputMethodManager) AppUtil.getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + if (imm == null) return; + view.setFocusable(true); + view.setFocusableInTouchMode(true); + view.requestFocus(); + imm.showSoftInput(view, flags, new ResultReceiver(new Handler()) { + @Override + protected void onReceiveResult(int resultCode, Bundle resultData) { + if (resultCode == InputMethodManager.RESULT_UNCHANGED_HIDDEN + || resultCode == InputMethodManager.RESULT_HIDDEN) { + toggleSoftInput(); + } + } + }); + imm.toggleSoftInput(InputMethodManager.SHOW_FORCED, InputMethodManager.HIDE_IMPLICIT_ONLY); + } + + + public static void hideSoftInput(@NonNull final Activity activity) { + hideSoftInput(activity.getWindow()); + } + + + public static void hideSoftInput(@NonNull final Window window) { + View view = window.getCurrentFocus(); + if (view == null) { + View decorView = window.getDecorView(); + View focusView = decorView.findViewWithTag("keyboardTagView"); + if (focusView == null) { + view = new EditText(window.getContext()); + view.setTag("keyboardTagView"); + ((ViewGroup) decorView).addView(view, 0, 0); + } else { + view = focusView; + } + view.requestFocus(); + } + hideSoftInput(view); + } + + + public static void hideSoftInput(@NonNull final View view) { + InputMethodManager imm = + (InputMethodManager) AppUtil.getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + if (imm == null) return; + imm.hideSoftInputFromWindow(view.getWindowToken(), 0); + } + + private static long millis; + + + public static void hideSoftInputByToggle(final Activity activity) { + long nowMillis = System.currentTimeMillis(); + long delta = nowMillis - millis; + if (Math.abs(delta) > 500 && KeyBoxUtil.isSoftInputVisible(activity)) { + KeyBoxUtil.toggleSoftInput(); + } + millis = nowMillis; + } + + + public static void toggleSoftInput() { + InputMethodManager imm = + (InputMethodManager) AppUtil.getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + if (imm == null) return; + imm.toggleSoftInput(0, 0); + } + + private static int sDecorViewDelta = 0; + + + public static boolean isSoftInputVisible(@NonNull final Activity activity) { + return getDecorViewInvisibleHeight(activity.getWindow()) > 0; + } + + private static int getDecorViewInvisibleHeight(@NonNull final Window window) { + final View decorView = window.getDecorView(); + final Rect outRect = new Rect(); + decorView.getWindowVisibleDisplayFrame(outRect); + Log.d("KeyboardUtils", "getDecorViewInvisibleHeight: " + + (decorView.getBottom() - outRect.bottom)); + int delta = Math.abs(decorView.getBottom() - outRect.bottom); + if (delta <= BarUtils.getNavBarHeight() + BarUtils.getStatusBarHeight()) { + sDecorViewDelta = delta; + return 0; + } + return delta - sDecorViewDelta; + } + + + public static void registerSoftInputChangedListener(@NonNull final Activity activity, + @NonNull final OnSoftInputChangedListener listener) { + registerSoftInputChangedListener(activity.getWindow(), listener); + } + + + public static void registerSoftInputChangedListener(@NonNull final Window window, + @NonNull final OnSoftInputChangedListener listener) { + final int flags = window.getAttributes().flags; + if ((flags & WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS) != 0) { + window.clearFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS); + } + final FrameLayout contentView = window.findViewById(android.R.id.content); + final int[] decorViewInvisibleHeightPre = {getDecorViewInvisibleHeight(window)}; + OnGlobalLayoutListener onGlobalLayoutListener = new OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + int height = getDecorViewInvisibleHeight(window); + if (decorViewInvisibleHeightPre[0] != height) { + listener.onSoftInputChanged(height); + decorViewInvisibleHeightPre[0] = height; + } + } + }; + contentView.getViewTreeObserver().addOnGlobalLayoutListener(onGlobalLayoutListener); + contentView.setTag(TAG_ON_GLOBAL_LAYOUT_LISTENER, onGlobalLayoutListener); + } + + + public static void unregisterSoftInputChangedListener(@NonNull final Window window) { + final FrameLayout contentView = window.findViewById(android.R.id.content); + Object tag = contentView.getTag(TAG_ON_GLOBAL_LAYOUT_LISTENER); + if (tag instanceof OnGlobalLayoutListener) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + contentView.getViewTreeObserver().removeOnGlobalLayoutListener((OnGlobalLayoutListener) tag); + } + } + } + + + public static void fixAndroidBug5497(@NonNull final Activity activity) { + fixAndroidBug5497(activity.getWindow()); + } + + + public static void fixAndroidBug5497(@NonNull final Window window) { + int softInputMode = window.getAttributes().softInputMode; + window.setSoftInputMode(softInputMode & ~WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE); + final FrameLayout contentView = window.findViewById(android.R.id.content); + final View contentViewChild = contentView.getChildAt(0); + final int paddingBottom = contentViewChild.getPaddingBottom(); + final int[] contentViewInvisibleHeightPre5497 = {getContentViewInvisibleHeight(window)}; + contentView.getViewTreeObserver() + .addOnGlobalLayoutListener(new OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + int height = getContentViewInvisibleHeight(window); + if (contentViewInvisibleHeightPre5497[0] != height) { + contentViewChild.setPadding( + contentViewChild.getPaddingLeft(), + contentViewChild.getPaddingTop(), + contentViewChild.getPaddingRight(), + paddingBottom + getDecorViewInvisibleHeight(window) + ); + contentViewInvisibleHeightPre5497[0] = height; + } + } + }); + } + + private static int getContentViewInvisibleHeight(final Window window) { + final View contentView = window.findViewById(android.R.id.content); + if (contentView == null) return 0; + final Rect outRect = new Rect(); + contentView.getWindowVisibleDisplayFrame(outRect); + Log.d("KeyboardUtils", "getContentViewInvisibleHeight: " + + (contentView.getBottom() - outRect.bottom)); + int delta = Math.abs(contentView.getBottom() - outRect.bottom); + if (delta <= BarUtils.getStatusBarHeight() + BarUtils.getNavBarHeight()) { + return 0; + } + return delta; + } + + + public static void fixSoftInputLeaks(@NonNull final Activity activity) { + fixSoftInputLeaks(activity.getWindow()); + } + + + public static void fixSoftInputLeaks(@NonNull final Window window) { + InputMethodManager imm = + (InputMethodManager) AppUtil.getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + if (imm == null) return; + String[] leakViews = new String[]{"mLastSrvView", "mCurRootView", "mServedView", "mNextServedView"}; + for (String leakView : leakViews) { + try { + Field leakViewField = InputMethodManager.class.getDeclaredField(leakView); + if (!leakViewField.isAccessible()) { + leakViewField.setAccessible(true); + } + Object obj = leakViewField.get(imm); + if (!(obj instanceof View)) continue; + View view = (View) obj; + if (view.getRootView() == window.getDecorView().getRootView()) { + leakViewField.set(imm, null); + } + } catch (Throwable ignore) { + + } + } + } + + public interface OnSoftInputChangedListener { + void onSoftInputChanged(int height); + } +} diff --git a/app/src/main/java/com/pic/catcher/util/LocalKVUtil.kt b/app/src/main/java/com/pic/catcher/util/LocalKVUtil.kt new file mode 100644 index 0000000..61a0a47 --- /dev/null +++ b/app/src/main/java/com/pic/catcher/util/LocalKVUtil.kt @@ -0,0 +1,22 @@ +package com.pic.catcher.util + +import android.content.Context +import android.content.SharedPreferences +import com.lu.magic.util.AppUtil + +class LocalKVUtil { + companion object { + const val defaultTableName = "app" + + @JvmStatic + @JvmOverloads + fun getTable(name: String, mode: Int = Context.MODE_PRIVATE): SharedPreferences { + return AppUtil.getContext().getSharedPreferences(name, mode) + } + + @JvmStatic + fun getDefaultTable(): SharedPreferences { + return getTable(defaultTableName) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pic/catcher/util/QuickCountClickListenerUtil.kt b/app/src/main/java/com/pic/catcher/util/QuickCountClickListenerUtil.kt new file mode 100644 index 0000000..545d24b --- /dev/null +++ b/app/src/main/java/com/pic/catcher/util/QuickCountClickListenerUtil.kt @@ -0,0 +1,91 @@ +package com.pic.catcher.util + +import android.view.View +import com.lu.lposed.api2.XposedHelpers2 +import com.lu.magic.util.log.LogUtil + +class QuickCountClickListenerUtil { + companion object { + fun register(view: View?, fullCount: Int, maxDuration: Int, callBack: View.OnClickListener) { + if (view == null) { + return + } + val viewListener = try { + if (view.hasOnClickListeners()) { + val listenerInfo = XposedHelpers2.callMethod(view, "getListenerInfo") + XposedHelpers2.getObjectField(listenerInfo, "mOnClickListener") + } else { + null + } + } catch (e: Throwable) { + null + } + if (viewListener is QuickCountClickListener) { + viewListener.sourceListener + } else { + viewListener + }.let { + view.setOnClickListener(QuickCountClickListener(it, callBack, fullCount, maxDuration)) + } + } + + fun unRegister(view: View?) { + if (view == null) { + return + } + val viewListener = try { + if (view.hasOnClickListeners()) { + val listenerInfo = XposedHelpers2.callMethod(view, "getListenerInfo") + XposedHelpers2.getObjectField(listenerInfo, "mOnClickListener") + } else { + null + } + } catch (e: Throwable) { + null + } + if (viewListener is QuickCountClickListener) { + view.setOnClickListener(viewListener.sourceListener) + } + + } + + } + + class QuickCountClickListener( + //原始点击监听器 + @JvmField var sourceListener: View.OnClickListener?, + @JvmField var fullQuickCallBack: View.OnClickListener, + /**点击满多少次后, 触发fullQuickCallBack */ + @JvmField var fullCount: Int = 5, + /**最大间隔时间。当前默认150毫秒**/ + @JvmField var maxDuration: Int = 150 + ) : View.OnClickListener { + var quickClickCount = 0 + var lastMills = 0L + + override fun onClick(v: View) { + val currMills = System.currentTimeMillis() + LogUtil.d(quickClickCount, lastMills, currMills) + if (lastMills == 0L) { + lastMills = currMills + } + + if (currMills - lastMills < maxDuration) { + quickClickCount++ + } else { + quickClickCount = 0 + } + lastMills = currMills + + if (quickClickCount > fullCount) { + quickClickCount = 0 + lastMills = 0 + //快速点击超过5次,回调一下 + fullQuickCallBack.onClick(v) + } + sourceListener?.onClick(v) + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/pic/catcher/util/TextCheckUtil.java b/app/src/main/java/com/pic/catcher/util/TextCheckUtil.java new file mode 100644 index 0000000..e5eeb94 --- /dev/null +++ b/app/src/main/java/com/pic/catcher/util/TextCheckUtil.java @@ -0,0 +1,83 @@ +package com.pic.catcher.util; + +import android.view.View; +import android.widget.TextView; + +import com.lu.magic.util.ReflectUtil; +import com.lu.magic.util.TextUtil; +import com.lu.magic.util.log.LogUtil; +import com.lu.magic.util.view.SelfDeepCheck; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; + +public class TextCheckUtil { + + + public static int haveMatchText(View rootView, String... regexList) { + int[] retIndex = new int[]{-1}; + + new SelfDeepCheck().eachCheck(rootView, view -> { + if (view instanceof TextView) { + String input = ((TextView) view).getText() + ""; + int index = haveMatchText(input, regexList); + if (index > -1) { + retIndex[0] = index; + return true; + } + } else { + try { + Method m = ReflectUtil.getMatchingMethod(view.getClass(), "getText"); + if (m != null) { + m.setAccessible(true); + Object v = m.invoke(view); + if (v != null) { + int index = haveMatchText(v + "", regexList); + if (index > -1) { + retIndex[0] = index; + return true; + } + } + } + } catch (Exception e) { + } + } + return false; + }); + return retIndex[0]; + } + + private static int haveMatchText(CharSequence input, String... regexList) { + for (int i = 0; i < regexList.length; i++) { + String regex = regexList[i]; + if (TextUtil.matches(regex, input)) { + return i; + } + } + return -1; + } + + public static List getTextList(View rootView) { + List stringList = new ArrayList<>(); + new SelfDeepCheck().each(rootView, view -> { + if (view instanceof TextView) { + stringList.add(((TextView) view).getText() + ""); + } else { + try { + Method m = ReflectUtil.getMatchingMethod(view.getClass(), "getText"); + if (m != null) { + m.setAccessible(true); + Object v = m.invoke(view); + stringList.add(v + ""); + LogUtil.d("reflect ", view); + } + } catch (Exception e) { + + } + } + }); + return stringList; + } + +} diff --git a/app/src/main/java/com/pic/catcher/util/TextKit.java b/app/src/main/java/com/pic/catcher/util/TextKit.java new file mode 100644 index 0000000..d3157c3 --- /dev/null +++ b/app/src/main/java/com/pic/catcher/util/TextKit.java @@ -0,0 +1,23 @@ +package com.pic.catcher.util; + +import java.util.List; + +/** + * @author Lu + * @date 2024/6/10 23:39 + * @description + */ +public class TextKit { + public static boolean isContain(List texts, String kw) { + if (texts == null || texts.isEmpty()) { + return false; + } + + for (String text : texts) { + if (text.contains(kw)) { + return true; + } + } + return false; + } +} diff --git a/app/src/main/java/com/pic/catcher/util/TimeExpiredCalculator.kt b/app/src/main/java/com/pic/catcher/util/TimeExpiredCalculator.kt new file mode 100644 index 0000000..514be67 --- /dev/null +++ b/app/src/main/java/com/pic/catcher/util/TimeExpiredCalculator.kt @@ -0,0 +1,28 @@ +package com.pic.catcher.util + +//超期计算器 +class TimeExpiredCalculator(val expiredMills: Long) { + var lastTime = 0L + + @JvmOverloads + fun updateLastTime(newTIme: Long = System.currentTimeMillis()) { + lastTime = newTIme + } + + fun isExpiredAuto(): Boolean { + return isExpiredInternal(true) + } + + fun isExpired(): Boolean { + return isExpiredInternal(false) + } + + private fun isExpiredInternal(autoUpdate: Boolean): Boolean { + val curr = System.currentTimeMillis() + val result = curr - lastTime > expiredMills + if (result && autoUpdate) { + updateLastTime(curr) + } + return result + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pic/catcher/util/ext/AnyX.kt b/app/src/main/java/com/pic/catcher/util/ext/AnyX.kt new file mode 100644 index 0000000..5676ffc --- /dev/null +++ b/app/src/main/java/com/pic/catcher/util/ext/AnyX.kt @@ -0,0 +1,61 @@ +package com.pic.catcher.util.ext + +import android.widget.TextView +import com.lu.magic.util.AppUtil + +import com.lu.magic.util.ResUtil +import com.lu.magic.util.SizeUtil +import com.pic.catcher.util.ColorUtilX + + +val sizeIntCache = HashMap() +val sizeFloatCache = HashMap() + + +val Int.dp: Int + get() = sizeIntCache[this.toString()].let { + if (it == null) { + val v = SizeUtil.dp2px(AppUtil.getContext().resources, this.toFloat()).toInt() + sizeIntCache[this.toString()] = v + return@let v + } + return it + } + +val Float.dp: Float + get() = sizeFloatCache[this.toString()].let { + if (it == null) { + val v = SizeUtil.dp2px(AppUtil.getContext().resources, this.toFloat()) + sizeFloatCache[this.toString()] = v + return@let v + } + return it + } + +val Double.dp: Float + get() = this.toFloat().dp + +fun CharSequence?.toIntElse(fallback: Int): Int = try { + if (this == null) { + fallback + } + Integer.parseInt(this.toString()) +} catch (e: Exception) { + fallback +} + +fun TextView.setTextColorTheme(color: Int) { + if (ResUtil.isAppNightMode(this.context)) { + setTextColor(ColorUtilX.invertColor(color)) + } else { + setTextColor(color) + } +} + + +inline fun T?.takeNotNull(fallback: T): T { + if (this == null) { + return fallback + } + return this +} diff --git a/app/src/main/java/com/pic/catcher/util/ext/ResUtilX.kt b/app/src/main/java/com/pic/catcher/util/ext/ResUtilX.kt new file mode 100644 index 0000000..9ea8278 --- /dev/null +++ b/app/src/main/java/com/pic/catcher/util/ext/ResUtilX.kt @@ -0,0 +1,12 @@ +package com.pic.catcher.util.ext + +import com.lu.magic.util.AppUtil +import com.lu.magic.util.ResUtil + +@JvmOverloads +fun ResUtil.getViewId(idName: String, packageName: String = AppUtil.getPackageName()): Int { + val k = "@id/$idName" + return AppUtil.getContext().resources.getIdentifier(idName, "id", packageName) +} + + diff --git a/app/src/main/java/com/pic/catcher/util/http/HttpConnectUtil.kt b/app/src/main/java/com/pic/catcher/util/http/HttpConnectUtil.kt new file mode 100644 index 0000000..419f9a5 --- /dev/null +++ b/app/src/main/java/com/pic/catcher/util/http/HttpConnectUtil.kt @@ -0,0 +1,140 @@ +package com.pic.catcher.util.http + +import java.io.InputStream +import java.io.OutputStream +import java.net.HttpURLConnection +import java.net.URL +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.ThreadPoolExecutor +import java.util.concurrent.TimeUnit + +//懒得引入各种库,直接基于HttpConnection封装一个简易http工具 +class HttpConnectUtil { + class Response( + var code: Int, + var header: Map>, + var body: ByteArray, + var error: Throwable? = null + ) { + override fun toString(): String { + return "Response(code=$code, header=$header, body=${body.toString(Charsets.UTF_8)}, error=$error)" + } + } + + class Request( + var url: String, + var method: String, + //虽然http协议允许重复的 header,但实际没什么用,所以这里不许 + var header: Map = mutableMapOf(), + var body: ByteArray? = null, + var connectTimeOut: Int = 10000, + var readTimeOut: Int = 10000 + ) { + override fun toString(): String { + return "Request(url='$url', method='$method', header=$header, body=${body?.toString(Charsets.UTF_8)}, connectTimeOut=$connectTimeOut, readTimeOut=$readTimeOut)" + } + } + + class Fetcher(var request: Request) { + fun fetch(): Response { + var connection: HttpURLConnection? = null + var iStream: InputStream? = null + var outStream: OutputStream? = null + + val resp = Response(-1, mutableMapOf(), byteArrayOf()) + try { + connection = URL(request.url).openConnection() as HttpURLConnection + connection.requestMethod = request.method + connection.connectTimeout = request.connectTimeOut + connection.readTimeout = request.readTimeOut + request.header.forEach { + connection.setRequestProperty(it.key, it.value) + } + request.body?.let { + connection.doOutput = true + val out = connection.outputStream + outStream = out + out.write(it) + out.flush() + } + resp.code = connection.responseCode + resp.header = connection.headerFields + iStream = connection.getInputStream() + resp.body = iStream.readBytes() + } catch (e: Throwable) { + resp.error = e + } finally { + try { + connection?.disconnect() + outStream?.close() + iStream?.close() + } catch (e: Exception) { + resp.error = e + } + } + return resp + } + } + + companion object { + @JvmField + val httpExecutor = ThreadPoolExecutor(8, 16, 0L, TimeUnit.MILLISECONDS, LinkedBlockingQueue()); + + @JvmStatic + val noCacheHttpHeader = mapOf("Cache-Control" to "no-cache") + + @JvmStatic + fun get(url: String, callback: (resp: Response) -> Unit) { + httpExecutor.submit { + Fetcher(Request(url, "GET")).fetch().let(callback) + } + } + + + @JvmStatic + fun get(url: String, header: Map, callback: (resp: Response) -> Unit) { + httpExecutor.submit { + Fetcher(Request(url, "GET", header)).fetch().let(callback) + } + } + + @JvmStatic + fun getWithRetry( + url: String, + header: Map, + retryCount: Int, + onEachFetch: (retryCount: Int, res: Response) -> Unit, + onFinalCallback: (resp: Response) -> Unit + ) { + httpExecutor.submit { + val fetcher = Fetcher(Request(url, "GET", header)) + var res = fetcher.fetch() + onEachFetch(0, res) + if (res.error != null) { + for (i in 0 until retryCount) { + res = fetcher.fetch() + onEachFetch(i + 1, res) + if (res.error == null) { + break + } + } + } + onFinalCallback.invoke(res) + } + } + + @JvmStatic + fun postJson(url: String, json: String, callback: (resp: Response) -> Unit) { + httpExecutor.submit { + Fetcher( + Request( + url, + "POST", + mutableMapOf("Content-Type" to "application/json; charset=utf-8"), + json.toByteArray(Charsets.UTF_8) + ) + ).fetch().let(callback) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_icon_add.xml b/app/src/main/res/drawable/ic_icon_add.xml new file mode 100644 index 0000000..460bcd3 --- /dev/null +++ b/app/src/main/res/drawable/ic_icon_add.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_icon_check.xml b/app/src/main/res/drawable/ic_icon_check.xml new file mode 100644 index 0000000..d94226c --- /dev/null +++ b/app/src/main/res/drawable/ic_icon_check.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_icon_dollar.xml b/app/src/main/res/drawable/ic_icon_dollar.xml new file mode 100644 index 0000000..cfa2b42 --- /dev/null +++ b/app/src/main/res/drawable/ic_icon_dollar.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_icon_manager.xml b/app/src/main/res/drawable/ic_icon_manager.xml new file mode 100644 index 0000000..9d5bace --- /dev/null +++ b/app/src/main/res/drawable/ic_icon_manager.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_icon_warning.xml b/app/src/main/res/drawable/ic_icon_warning.xml new file mode 100644 index 0000000..9f8eed2 --- /dev/null +++ b/app/src/main/res/drawable/ic_icon_warning.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_main.xml b/app/src/main/res/layout/fragment_main.xml new file mode 100644 index 0000000..83b6d6f --- /dev/null +++ b/app/src/main/res/layout/fragment_main.xml @@ -0,0 +1,16 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_icon_text.xml b/app/src/main/res/layout/item_icon_text.xml new file mode 100644 index 0000000..bb5820e --- /dev/null +++ b/app/src/main/res/layout/item_icon_text.xml @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/layout_main.xml b/app/src/main/res/layout/layout_main.xml new file mode 100644 index 0000000..633a122 --- /dev/null +++ b/app/src/main/res/layout/layout_main.xml @@ -0,0 +1,13 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/raw/menu_ui.json b/app/src/main/res/raw/menu_ui.json new file mode 100644 index 0000000..95e3c26 --- /dev/null +++ b/app/src/main/res/raw/menu_ui.json @@ -0,0 +1,10 @@ +[ + { + "title": "反馈问题", + "link": "https://github.com/Mingyueyixi/PicCatcher/issues" + }, + { + "title": "关于", + "link": "https://github.com/Mingyueyixi/PicCatcher" + } +] \ No newline at end of file diff --git a/app/src/main/res/raw/releases_note.html b/app/src/main/res/raw/releases_note.html new file mode 100644 index 0000000..a89260b --- /dev/null +++ b/app/src/main/res/raw/releases_note.html @@ -0,0 +1,192 @@ + + + + + + + + 更新日记 + + + + + + + + + +
+
+
+
+ + + + \ No newline at end of file diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..509f1a1 --- /dev/null +++ b/app/src/main/res/values-night/themes.xml @@ -0,0 +1,17 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml new file mode 100644 index 0000000..1a5dac1 --- /dev/null +++ b/app/src/main/res/values/arrays.xml @@ -0,0 +1,7 @@ + + + + + com.tencent.mm + + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..db761c0 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,12 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + + #FFF8A1A4 + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..d81c6ab --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,13 @@ + + MaskWechat + 隐藏微信里特定用户的聊天记录 + 模块未激活 + 模块已激活 + 模块版本 + 捐赠 + 配置管理 + 添加配置 + 点击此处管理配置 + 点击添加配置 + 万水千山总是情,打赏一点也是情 + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..640ca3d --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,25 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..fa0f996 --- /dev/null +++ b/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,13 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..9ee9997 --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..3c81b25 --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/test/java/com/lu/catcher/clazz/ClazzTest.java b/app/src/test/java/com/lu/catcher/clazz/ClazzTest.java new file mode 100644 index 0000000..1d19134 --- /dev/null +++ b/app/src/test/java/com/lu/catcher/clazz/ClazzTest.java @@ -0,0 +1,22 @@ +package com.pic.catcher.clazz; + +import org.junit.Test; + +import java.lang.reflect.Array; + +public class ClazzTest { + + @Test + public void getStringArrayClazz() { + String[] strings = new String[]{"1", "2", "3"}; + Object hh = Array.newInstance(String.class, 8); + assert strings.getClass().equals(hh.getClass()); + assert "[Ljava.lang.String;".equals(hh.getClass().getName()); + } + + @Test + public void getObjectArrayClazz() { + Object[] objs = new Object[]{}; + System.out.println(objs.getClass().getName()); + } +} diff --git a/app/src/test/java/com/lu/catcher/util/http/HttpConnectUtilTest.kt b/app/src/test/java/com/lu/catcher/util/http/HttpConnectUtilTest.kt new file mode 100644 index 0000000..18d1632 --- /dev/null +++ b/app/src/test/java/com/lu/catcher/util/http/HttpConnectUtilTest.kt @@ -0,0 +1,40 @@ +package com.pic.catcher.util.http + +import com.pic.catcher.util.AppUpdateCheckUtil +import org.junit.Test +import java.util.concurrent.CountDownLatch + +class HttpConnectUtilTest { + + @Test + fun get() { + val lock = CountDownLatch(1) + HttpConnectUtil.get(AppUpdateCheckUtil.repoLastReleaseUrl) { + println(it.body.toString(Charsets.UTF_8)) + lock.countDown() + } + lock.await() + } + + @Test + fun postJson() { + val lock = CountDownLatch(1) + HttpConnectUtil.postJson("http://127.0.0.1:80/app/version", """{"尼玛":"123"}""") { + println(it.body.toString(Charsets.UTF_8)) + println(it.code) + println(it.error) + lock.countDown() + } + lock.await() + } + + @Test + fun getBaidu() { + val lock = CountDownLatch(1) + HttpConnectUtil.get("https://www.baidu.com") { + println(it.body.toString(Charsets.UTF_8)) + lock.countDown() + } + lock.await() + } +} \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..4b62788 --- /dev/null +++ b/build.gradle @@ -0,0 +1,14 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +buildscript { + dependencies { + // https://mvnrepository.com/artifact/org.json/json + classpath 'org.json:json:20240303' + + } +} + +plugins { + id 'com.android.application' version '8.5.2' apply false + id 'com.android.library' version '8.5.2' apply false + id 'org.jetbrains.kotlin.android' version '2.0.20' apply false +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..532582a --- /dev/null +++ b/gradle.properties @@ -0,0 +1,25 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true +#?debug apk??????? +android.injected.testOnly = false \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e708b1c Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..ff4317a --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +#Thu Dec 29 18:20:19 CST 2022 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +#distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip +distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.10-all.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..4f906e0 --- /dev/null +++ b/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/init.gradle b/init.gradle new file mode 100644 index 0000000..f4ff985 --- /dev/null +++ b/init.gradle @@ -0,0 +1,136 @@ +//gradle的可执行目录 +gradle.println "gradleHomeDir:${gradle.gradleHomeDir}" +//gradle的用户目录,用于缓存一些下载好的资源,编译好的构建脚本等 +gradle.println "gradleUserHomeDir:${gradle.gradleUserHomeDir}" +//gradle的版本号 +gradle.println "gradleVersion:${gradle.gradleVersion}" +//gralde当前构建的启动参数 +gradle.println "startParameter:${gradle.startParameter}" +gradle.println("gradle.startParameter.properties--------------->") +gradle.startParameter.properties.each { + gradle.println("${it.key}: ${it.value}") +} +gradle.println("<------------gradle.startParameter.properties") +gradle.println "startParameter.taskNames: ${gradle.startParameter.taskNames}" + + +// https://gist.github.com/mkckr0/97ec5b0d99feede4c19ee6f905d5e722 + +def repoMirrorMap = [ + "https://repo.maven.apache.org/maven2" : "https://maven.aliyun.com/repository/central", + "https://dl.google.com/dl/android/maven2": "https://maven.aliyun.com/repository/google", + "https://plugins.gradle.org/m2" : "https://maven.aliyun.com/repository/gradle-plugin", + "https://jcenter.bintray.com" : "https://maven.aliyun.com/repository/jcenter" +] +def repoReplaceMap = [ + "https://maven.google.com": "https://dl.google.com/dl/android/maven2" +] + +def printRepos = { RepositoryHandler repoHandler -> + repoHandler.all { repository -> + if (repository instanceof MavenArtifactRepository) { + println("Maven Repo: name=\"${repository.name}\", url=${repository.url}, artifacts=${repository.artifactUrls}") + } + } +} + +def setMirrors = { RepositoryHandler repoHandler -> +// maven { url "http://127.0.0.1:10072/repo/maven-local/" } + repoHandler.maven { url "https://maven.aliyun.com/repository/public" } + //central有的,public聚合仓不一定有,奇葩 + repoHandler.maven { url 'https://maven.aliyun.com/repository/central' } + repoHandler.maven { url "https://maven.aliyun.com/repository/google" } + repoHandler.maven { url "https://maven.aliyun.com/repository/gradle-plugin" } + repoHandler.maven { url 'https://jitpack.io/' } + repoHandler.maven { url 'https://repo.huaweicloud.com/repository/maven' } + repoHandler.maven { url 'https://mirrors.cloud.tencent.com/nexus/repository/maven-public' } + + repoHandler.maven { url 'https://maven.aliyun.com/repository/spring' } + repoHandler.maven { url 'https://maven.aliyun.com/repository/spring-plugin' } + repoHandler.maven { url 'https://maven.aliyun.com/repository/grails-core' } + repoHandler.maven { url 'https://maven.aliyun.com/repository/apache-snapshots' } + + repoHandler.all { repo -> + if (repo instanceof MavenArtifactRepository && !repo.name.endsWith("Origin")) { + def originName = repo.name + def originUrl = repo.url.toString().trim() + if (originUrl.startsWith('https://repo1.maven.org/maven2/')) { +// println("Repository ${repo.url} remove.") + remove repo + } + if (originUrl.startsWith('https://jcenter.bintray.com/')) { +// println("Repository ${repo.url} remove.") + remove repo + } + + if (originUrl.startsWith('https://dl.google.com/dl/android/maven2/')) { +// println("Repository ${repo.url} remove.") + + try { + remove repo + } catch (e) { + e.printStackTrace() + } + } + if (originUrl.startsWith('https://plugins.gradle.org/m2/')) { +// println("Repository ${repo.url} remove.") + remove repo + } + + // 替换 URL + def newUrl = repoReplaceMap[originUrl] + if (newUrl != null) { + repo.setUrl(newUrl) + originUrl = newUrl + } + + // 镜像设置 + newUrl = repoMirrorMap[originUrl] + if (newUrl != null) { + repo.setUrl(newUrl) + // 添加原始仓库以查找缺失的 JAR 文件 + repo.artifactUrls = [originUrl] + // 保留原始仓库以查找缺失的 POM + try { + repoHandler.maven(originUrl) { it.name = "${originName} Origin" } + } catch (e) { + e.printStackTrace() + } + } + } + } + printRepos(repoHandler) +} + +def useNewApi = true +try { + settingsEvaluated { settings -> + settings.pluginManagement { + repositories { + setMirrors(it) + } + } + settings.dependencyResolutionManagement { + repositories { + setMirrors(it) + } + } + } +} catch (Exception e) { + useNewApi = false +} + + +if (!useNewApi) { + allprojects { + buildscript { + repositories { + setMirrors(it) + } + } + repositories { + setMirrors(it) + } + } +} + diff --git a/init.gradle.kts b/init.gradle.kts new file mode 100644 index 0000000..4f8dfcd --- /dev/null +++ b/init.gradle.kts @@ -0,0 +1,70 @@ +// https://gist.github.com/mkckr0/97ec5b0d99feede4c19ee6f905d5e722 + +val repoMirrorMap = mapOf( + "https://repo.maven.apache.org/maven2" to "https://maven.aliyun.com/repository/central", + "https://dl.google.com/dl/android/maven2" to "https://maven.aliyun.com/repository/google", + "https://plugins.gradle.org/m2" to "https://maven.aliyun.com/repository/gradle-plugin", + "https://jcenter.bintray.com" to "https://maven.aliyun.com/repository/jcenter", +) +val repoReplaceMap = mapOf( + "https://maven.google.com" to "https://dl.google.com/dl/android/maven2" +) + +fun RepositoryHandler.setMirrors() { + all { + if (this is MavenArtifactRepository && !name.endsWith("Origin")) { + val originName = name + var originUrl = url.toString().trimEnd('/') + + // do replace + repoReplaceMap[originUrl]?.let { newUrl -> + originUrl = newUrl + setUrl(originUrl) + } + + // do mirror + repoMirrorMap[originUrl]?.let { newUrl -> + // replace into mirror repo + setUrl(newUrl) + // add origin repo to find missing jars + artifactUrls(originUrl) + // keep origin repo to find missing POM + maven(originUrl) { name = "$originName Origin" } + } + } + } + printRepos() +} + +fun RepositoryHandler.printRepos() { + all { + if (this is MavenArtifactRepository) { + println("Maven Repo: name=\"$name\", url=$url, artifacts=${artifactUrls}") + } + } +} + +settingsEvaluated { + pluginManagement { + repositories { + setMirrors() + } + } + dependencyResolutionManagement { + @Suppress("UnstableApiUsage") + repositories { + setMirrors() + } + } +} + +allprojects { + buildscript { + repositories { + setMirrors() + } + } + repositories { + setMirrors() + } +} \ No newline at end of file diff --git a/props.gradle b/props.gradle new file mode 100644 index 0000000..d13f9c7 --- /dev/null +++ b/props.gradle @@ -0,0 +1,43 @@ +ext { + deps = [ + 'androidx.appcompat:appcompat' : 'androidx.appcompat:appcompat:1.5.1', + 'androidx.core:core-ktx' : 'androidx.core:core-ktx:1.9.0', + 'androidx.constraintlayout:constraintlayout' : 'androidx.constraintlayout:constraintlayout:2.1.3', + "androidx.lifecycle:lifecycle-viewmodel-ktx" : "androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1", + 'com.google.android.material:material' : 'com.google.android.material:material:1.7.0', + 'com.google.code.gson:gson' : 'com.google.code.gson:gson:2.10', + 'de.robv.android.xposed:api' : 'de.robv.android.xposed:api:82', + 'com.kyleduo.switchbutton:library' : 'com.kyleduo.switchbutton:library:2.1.0', + // Flex布局依赖,可配合RecyclerView也可以单独使用 + // https://mvnrepository.com/artifact/com.google.android.flexbox/flexbox + 'com.google.android.flexbox:flexbox' : 'com.google.android.flexbox:flexbox:3.0.0', + 'com.tencent:mmkv-static' : 'com.tencent:mmkv-static:1.2.12', + 'junit:junit' : 'junit:junit:4.13.2', + 'androidx.test.ext:junit' : 'androidx.test.ext:junit:1.1.3', + 'androidx.test.espresso:espresso-core' : 'androidx.test.espresso:espresso-core:3.5.0', + "com.blankj:utilcodex" : "com.blankj:utilcodex:1.31.1", + 'org.jetbrains.kotlinx:kotlinx-coroutines-android': 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4', + + 'com.amap.api:map2d' : 'com.amap.api:map2d:6.0.0', + 'com.amap.api:location' : 'com.amap.api:location:6.2.0', + 'com.amap.api:search' : 'com.amap.api:search:9.5.0', + +// 'com.github.Mingyueyixi:frame-base-utils' : 'com.github.Mingyueyixi:frame-base-utils:v1.0.0-alpha.2', +// 'com.github.Mingyueyixi.frame-base-utils:all' : 'com.github.Mingyueyixi.frame-base-utils:all:269ae11d06', +// 'com.github.Mingyueyixi.frame-base-utils:base' : 'com.github.Mingyueyixi.frame-base-utils:base:269ae11d06', + 'com.github.Mingyueyixi.frame-base-utils:core' : 'com.github.Mingyueyixi.frame-baseutils:core:3811678a39', + + +// 'com.github.Mingyueyixi:frame-ui' : 'com.github.Mingyueyixi:frame-ui:main-SNAPSHOT', +// 'com.github.Mingyueyixi:frame-ui' : 'com.github.Mingyueyixi:frame-ui:v1.0.0-alpha.2', +// 'com.github.Mingyueyixi.frame-ui:ui-all' : 'com.github.Mingyueyixi.frame-ui:ui-all:8db89324ba', +// 'com.github.Mingyueyixi.frame-ui:ui-material' : 'com.github.Mingyueyixi.frame-ui:ui-material:8db89324ba', +// 'com.github.Mingyueyixi.frame-ui:ui-base' : 'com.github.Mingyueyixi.frame-ui:ui-base:8db89324ba', + 'com.github.Mingyueyixi.frame-ui:ui-appcompat' : 'com.github.Mingyueyixi.frame-ui:ui-appcompat:332536884f', + + 'com.github.Mingyueyixi:frame-multi-preference' : 'com.github.Mingyueyixi:frame-multi-preference:main-SNAPSHOT', + 'com.github.Mingyueyixi.lposed:xposed-api2' : 'com.github.Mingyueyixi.lposed:xposed-api2:43cc6b0858', + 'com.github.Mingyueyixi.lposed:plugin' : 'com.github.Mingyueyixi.lposed:plugin:43cc6b0858' + + ] +} \ No newline at end of file diff --git a/robot.py b/robot.py new file mode 100644 index 0000000..80d24e7 --- /dev/null +++ b/robot.py @@ -0,0 +1,380 @@ +#!/usr/bin/python + +# -*- coding: utf-8 -*- +# @Date:2023/02/13 0:06 +# @Author: Lu +# @Description +import argparse +import html +import json +import mimetypes +import os +import subprocess +from enum import Enum +from pathlib import Path + +import requests + +http = requests.Session() + + +def read_file(file_path, default=b''): + result = default + try: + with open(file_path, "rb") as f: + result = f.read() + except Exception as e: + print(e) + return result + + +def json_loads(text, default={}): + result = default + try: + result = json.loads(text) + except Exception as e: + print(e) + pass + return result + + +def run_cmd(cmd, prt=True): + if prt: + print(cmd) + p = subprocess.Popen(args="sh", + shell=True, + text=False, + start_new_session=True, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE) + p.stdin.write(cmd.encode('utf-8') + b"\n") + p.stdin.close() + bin_result = p.stdout.read() + p.stdout.close() + p.kill() + result = bin_result.decode('utf-8') + if prt: + print(result) + return result + + +def test_cmd(): + git.head_commit + print(git.head_commit_msg) + + +class GitMan(object): + @property + def head_commit(self): + return run_cmd("git rev-parse HEAD").strip() + + @property + def curr_branch(self): + return run_cmd("git branch --show-current").strip() + + @property + def head_commit_msg(self): + # 此命令没有问题,但目前在github action环境执行拿不到 + # return run_cmd("git log --pretty=format:"%s" HEAD~..HEAD").strip() + # 也没有问题,但报错,将head命令当做git参数解析了 + # return run_cmd('git log --pretty=format:"%s" head -n 1').strip() + try: + # 可能git版本问题,这个命令未裁剪返回也只有一条 + return run_cmd('git log --pretty=format:"%s"').strip().splitlines()[0] + except: + return '' + + +class GithubApi(object): + def __init__(self, token): + self.token = token + + def getReleases(self, tag): + result = None + try: + result = met.github_release + except Exception as e: + print(e) + print(met.github_release) + if not result: + url = f"https://api.github.com/repos/Mingyueyixi/MaskWechat/releases/tags/{tag}" + headers = { + "Accept": "application/vnd.github+json", + } + if self.token: + # github 对ip请求有限制,超出频次将得不到请求结果,需要设置auth + headers["Authorization"] = self.token + # api.github不需要走代理。代理服务器通常请求超量,需要auth + res = requests.get(url, headers=headers) + print(url, res.content.decode('utf-8')) + result = res.json() + if not result: + raise Exception(f"{url} response content is empty") + return result + + +class ParseMode(Enum): + HTML = "HTML" + Markdown = "Markdown" + MarkdownV2 = "MarkdownV2" + + +class TGBot(object): + def __init__(self, user, token): + self.bot_user = None + self.token = None + self.base_url = None + self.doInit(user, token) + + def doInit(self, user, token): + self.bot_user = user + self.token = token + self.base_url = f"https://api.telegram.org/bot{self.token}" + return self + + def getChannelChatId(self): + """ + 在群组中@username机器人并发消息后,通过getUpdates接口获取事件,其中有chatId + 频道和群组的响应体不一样,这是获取频道的ChatId + """ + res = http.get(f"{self.base_url}/getUpdates") + res_json = res.json() + print(json.dumps(res_json, ensure_ascii=False)) + if res_json['ok']: + for ele in res_json['result']: + try: + chatId = ele['channel_post']['chat']['id'] + if chatId: + return chatId + except Exception: + pass + return None + + def sendMessage(self, chatId, text: str, parse_mode: ParseMode = None, disable_preview=False): + data = { + "chat_id": chatId, + "text": text, + "disable_web_page_preview": disable_preview + } + if parse_mode: + data["parse_mode"] = parse_mode.value + + res = http.post(f"{self.base_url}/sendMessage", json=data) + print("sendMessage result:", res.text) + + def sendMediaGroup(self, chatId, media_paths: [], caption=None): + # 这是开源封装好的工具类 + # https://github.com/python-telegram-bot/python-telegram-bot + # 以下是自行实现的多媒体组消息发送方法,根据telegrame文档: + # https://core.telegram.org/bots/api#sendMediaGroup + # https://core.telegram.org/bots/api#inputmediadocument + # 根据文档,已经存在的文件则使用file_id即可 + media_arr = [] + files = {} + for i in range(len(media_paths)): + m = Path(media_paths[i]).resolve() + print(m, os.path.exists(m)) + name = os.path.basename(m) + # 名称过长无法发送 + attach_name = f"attach_{id(name)}" + content_type = mimetypes.guess_type(m)[0] + if not content_type: + if m.suffix == '.apk': + content_type = 'application/vnd.android.package-archive' + else: + content_type = 'application/octet-stream' + + files[attach_name] = (name, read_file(m), content_type) + media_item = { + "type": "document", + "media": f"attach://{attach_name}", + } + + media_arr.append(media_item) + + # 添加说明 + if caption: + # 说明文字超过长度将无法发送 + sub = len(caption) - 1024 + if sub > 0: + caption = caption[: -sub - 3] + "……" + if media_arr: + media_arr[-1]["caption"] = caption + + res = http.post( + f"{self.base_url}/sendMediaGroup", + data={ + "chat_id": chatId, + "media": json.dumps(media_arr), + }, + files=files + ) + print(media_arr) + print("sendMediaGroup result:", res.content.decode('utf-8')) + return res + + +def escapeMsgMarkdownV2(text: str): + result = text + for s in ['_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!']: + result = result.replace(s, "\\" + s) + return result + + +class TGNotification(object): + def __init__(self, tg_bot: TGBot): + self.tg_bot = tg_bot + + def postRelease(self): + release_data = GithubApi(met.github_token).getReleases(met.tag_name) + html_url = release_data["html_url"] + body = html.escape(release_data['body']).strip() + name = release_data["name"] + + mess = f"\n{name}发布:{html_url}\n{body}" + mess += "\n产物下载:\n" + for ass in release_data.get("assets"): + ass_name = ass["name"] + download_url = ass["browser_download_url"] + mess += f'{ass_name}\n' + print(mess) + self.tg_bot.sendMessage(met.chat_id, mess, ParseMode.HTML) + + def postCI(self): + github_commits = met.github_event_commits + github_run_url = met.github_run_url + + body = '' + print("github_commits", type(github_commits), github_commits) + + for g_commit in github_commits: + g_msg = g_commit.get('message') + if not g_msg: g_msg = "" + body += g_msg.strip() + "\n" + if not body: + body += met.git_commit_msg + mess = f"糊脸CI提示\n" + mess += f"编译地址:{github_run_url}\n" + mess += f"代码分支:{met.git_branch}\n" + mess += f"提交哈希:{met.git_commit[:6]}\n" + mess += f"更新信息:{body}" + res = self.tg_bot.sendMediaGroup(met.chat_id, caption=mess, media_paths=filter_apk_paths()) + if res.status_code == 200: + res_json = res.json() + if res_json['ok']: + return + self.tg_bot.sendMessage(met.chat_id, f"CI编译完成{github_run_url}") + + +def filter_apk_paths(verify_commit=False): + apk_dir = project_path / "app/build/outputs/apk/" + short = met.git_commit[:6] + if verify_commit: + result = [e for e in apk_dir.glob(f"*/*{short}*.apk")] + else: + result = [e for e in apk_dir.glob(f"*/*.apk")] + return result + + +def main(): + parser = argparse.ArgumentParser(description='Mask Robot') + parser.add_argument('-e', '--event', metavar='pattern', required=True, + dest='event', action='store', + help='the event for robot') + args = parser.parse_args() + print(f"python {__file__} by --event {args.event}") + bot = TGBot("@MaskCIBot", met.bot_token) + if args.event == "ci" or not args.event: + TGNotification(bot).postCI() + elif args.event == "release": + TGNotification(bot).postRelease() + elif args.event == "shabi": + met.bot_token = bytes( + [53, 57, 55, 50, 48, 54, 51, 52, 53, 49, 58, 65, 65, 70, 52, 98, 72, 98, 103, 72, 68, 88, 84, 112, 85, 103, + 120, + 110, 74, 55, 100, 69, 103, 86, 55, 57, 111, 73, 50, 80, 49, 113, 67, 89, 49, 119]).decode('utf-8') + # 和ShabiGo机器人的聊天id + met.chat_id = 5318101494 + bot.doInit("@ShabiGo", met.bot_token) + TGNotification(bot).postCI() + else: + print("not support robot") + + +class Meta(object): + def __init__(self): + self.github_context = json_loads(read_file('github_context.txt'), {}) + self.github_event = self.github_context.get('event', {}) + self.github_event_commits = self.github_event.get("commits", []) + + self.tag_name = os.environ.get("tag_name", "") + self.github_token = os.environ.get("github_token", "") + self.chat_id = os.environ.get("CHAT_ID", "") + self.bot_token = os.environ.get("BOT_TOKEN", "") + + self.github_run_url = os.environ.get("GITHUB_RUN_URL", "") + self.github_release = os.environ.get("event", {}).get("release", {}) + self.git_branch = os.environ.get("GIT_BRANCH", git.curr_branch) + self.git_commit = os.environ.get("GIT_COMMIT", git.head_commit) + self.git_commit_msg = self.github_event.get('head_commit', {}).get('message', git.head_commit_msg) + + def __str__(self): + return json.dumps(self.__dict__) + + +project_path = Path(__file__).parent +git = GitMan() +met = Meta() +print(met) + + +def setup_test(): + http.proxies = { + "http": "127.0.0.1:7890", + "https": "127.0.0.1:7890" + } + met.github_context = json_loads(read_file('test_data/git_context.json'), {}) + met.bot_token = bytes( + [53, 57, 55, 50, 48, 54, 51, 52, 53, 49, 58, 65, 65, 70, 52, 98, 72, 98, 103, 72, 68, 88, 84, 112, 85, 103, 120, + 110, 74, 55, 100, 69, 103, 86, 55, 57, 111, 73, 50, 80, 49, 113, 67, 89, 49, 119]).decode('utf-8') + # 和ShabiGo机器人的聊天id + met.chat_id = 5318101494 + # met.github_token = + tg = TGBot("@ShabiGo_bot", met.bot_token) + return tg + + +def test_postRelease(): + tg = setup_test() + met.tag_name = 'v1.15' + TGNotification(tg).postRelease() + + +def test_sendMediaGroup(): + # tg.getChannelChatId() + tg = setup_test() + met.tag_name = 'v1.15' + release_data = GithubApi(met.github_token).getReleases(met.tag_name) + # 测试发送apk、txt + tg.sendMediaGroup(met.chat_id, ["local.properties", "gradle.properties"], f"新版本发布\n{release_data['body']}") + + # bot = TGBot() + # await bot.sendMediaGroup( + # met.chat_id, + # media=[ + # InputMediaDocument(media=open(project_path / 'test1.txt'), filename="test1.txt"), + # InputMediaDocument(media=open(project_path / 'test2.txt'), filename="test2.txt") + # ], + # caption="更新橙瓜" + # ) + + +# @pytest.mark.asyncio +def test_postCI(): + tg = setup_test() + met.github_run_url = 'https://github.com/Mingyueyixi/MaskWechat/actions/runs/4709147817' + TGNotification(tg).postCI() + + +if __name__ == '__main__': + main() diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..8cf7a0f --- /dev/null +++ b/settings.gradle @@ -0,0 +1,43 @@ +pluginManagement { + repositories { +// maven { +// allowInsecureProtocol = true +// url "http://127.0.0.1:10072" +// } + maven { url 'https://jitpack.io/' } + maven { url 'https://mirrors.cloud.tencent.com/nexus/repository/maven-public' } + maven { url 'https://repo.huaweicloud.com/repository/maven' } + //central有的,public聚合仓不一定有,奇葩 + maven { url 'https://maven.aliyun.com/repository/central' } + maven { url "https://maven.aliyun.com/repository/public" } + maven { url "https://maven.aliyun.com/repository/google" } + maven { url "https://maven.aliyun.com/repository/gradle-plugin" } + + google() + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { +// maven { +// allowInsecureProtocol = true +// url "http://127.0.0.1:10072" +// } + + maven { url 'https://jitpack.io/' } + maven { url 'https://mirrors.cloud.tencent.com/nexus/repository/maven-public' } + maven { url 'https://repo.huaweicloud.com/repository/maven' } + //central有的,public聚合仓不一定有,奇葩 + maven { url 'https://maven.aliyun.com/repository/central' } + maven { url "https://maven.aliyun.com/repository/public" } + maven { url "https://maven.aliyun.com/repository/google" } + maven { url "https://maven.aliyun.com/repository/gradle-plugin" } + + google() + mavenCentral() + } +} +rootProject.name = "PicCatcher" +include ':app' \ No newline at end of file