diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 6d89cb3b..9b89b904 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -18,7 +18,11 @@ jobs: - name: Get version code run: echo APPVEYOR_BUILD_NUMBER=$(expr $GITHUB_RUN_NUMBER + 4999) >> $GITHUB_ENV - name: Build with Gradle - run: bash ./gradlew zipRelease zipDebug + env: + KEYSTORE_PASS: ${{ secrets.KEYSTORE_PASS }} + ALIAS_NAME: ${{ secrets.ALIAS_NAME }} + ALIAS_PASS: ${{ secrets.ALIAS_PASS }} + run: bash ./gradlew zipRelease zipDebug :app:assembleRelease - name: Prepare artifact if: success() run: unzip edxp-core/release/LSPosed-v*-release.zip -d LSPosed-release; diff --git a/.gitignore b/.gitignore index fc2fb6f5..0a9948db 100644 --- a/.gitignore +++ b/.gitignore @@ -19,5 +19,4 @@ elf-cleaner.sh .settings/ .vscode/ -dalvikdx/bin/ -dexmaker/bin/ \ No newline at end of file +.cxx diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 00000000..796b96d1 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 00000000..2b0bc869 --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,84 @@ +apply plugin: 'com.android.application' +apply plugin: 'com.google.android.gms.oss-licenses-plugin' + +android { + buildFeatures { + viewBinding = true + } + signingConfigs { + def keystorePwd = null + def alias = null + def pwd = null + if (project.rootProject.file('local.properties').exists()) { + Properties properties = new Properties() + properties.load(project.rootProject.file('local.properties').newDataInputStream()) + keystorePwd = properties.getProperty("RELEASE_STORE_PASSWORD") + alias = properties.getProperty("RELEASE_KEY_ALIAS") + pwd = properties.getProperty("RELEASE_KEY_PASSWORD") + } + release { + storeFile file("edxpmanager.jks") + storePassword keystorePwd != null ? keystorePwd : System.getenv("KEYSTORE_PASS") + keyAlias alias != null ? alias : System.getenv("ALIAS_NAME") + keyPassword pwd != null ? pwd : System.getenv("ALIAS_PASS") + } + } + lintOptions { + disable 'MissingTranslation' + disable 'ExtraTranslation' + } + compileSdkVersion 30 + buildToolsVersion "30.0.2" + defaultConfig { + applicationId "org.meowcat.edxposed.manager" + minSdkVersion 26 + targetSdkVersion 30 + versionCode 458010 + versionName "4.5.8.1" + signingConfig signingConfigs.release + } + buildTypes { + release { + minifyEnabled true + shrinkResources true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + debug { + minifyEnabled false + shrinkResources false + signingConfig signingConfigs.release + } + } + compileOptions { + sourceCompatibility = 1.8 + targetCompatibility = 1.8 + } + + applicationVariants.all { variant -> + variant.outputs.all { output -> + outputFileName = "EdXposedManagerR-${defaultConfig.versionName}-${defaultConfig.versionCode}-${buildType.name}.apk" + } + } +} + +dependencies { + annotationProcessor 'com.github.bumptech.glide:compiler:4.11.0' + implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation 'androidx.appcompat:appcompat:1.2.0' + implementation 'androidx.browser:browser:1.3.0' + implementation 'androidx.constraintlayout:constraintlayout:2.0.4' + implementation "androidx.recyclerview:recyclerview:1.1.0" + implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' + implementation 'com.github.bumptech.glide:glide:4.11.0' + implementation "com.github.topjohnwu.libsu:core:2.5.1" + implementation 'com.google.android.gms:play-services-oss-licenses:17.0.0' + implementation 'com.google.android.material:material:1.2.1' + implementation 'com.google.code.gson:gson:2.8.6' + implementation 'com.takisoft.preferencex:preferencex:1.1.0' + implementation 'com.takisoft.preferencex:preferencex-colorpicker:1.1.0' + implementation 'com.timehop.stickyheadersrecyclerview:library:0.4.3@aar' + implementation 'tech.rectifier.preferencex-android:preferencex-simplemenu:88f93154b2' + implementation 'me.zhanghai.android.appiconloader:appiconloader-glide:1.2.0' + implementation 'me.zhanghai.android.fastscroll:library:1.1.5' + compileOnly 'de.robv.android.xposed:api:82' +} diff --git a/app/edxpmanager.jks b/app/edxpmanager.jks new file mode 100644 index 00000000..f5028dca Binary files /dev/null and b/app/edxpmanager.jks differ diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 00000000..5f3c6e51 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,23 @@ +# 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 +-keep class org.meowcat.edxposed.manager.util.json.** {public *; } +-keep class org.meowcat.edxposed.manager.Constants { *; } \ 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 00000000..66b421a1 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/org/meowcat/edxposed/manager/App.java b/app/src/main/java/org/meowcat/edxposed/manager/App.java new file mode 100644 index 00000000..a28eeb7f --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/App.java @@ -0,0 +1,210 @@ +package org.meowcat.edxposed.manager; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.app.Application; +import android.app.PendingIntent; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.os.Bundle; +import android.os.Handler; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.preference.PreferenceManager; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; + +import org.meowcat.edxposed.manager.adapters.AppHelper; +import org.meowcat.edxposed.manager.receivers.PackageChangeReceiver; +import org.meowcat.edxposed.manager.ui.activity.CrashReportActivity; +import org.meowcat.edxposed.manager.util.ModuleUtil; +import org.meowcat.edxposed.manager.util.NotificationUtil; +import org.meowcat.edxposed.manager.util.RepoLoader; + +import java.io.File; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.util.Collection; +import java.util.Date; +import java.util.Objects; + +public class App extends Application implements Application.ActivityLifecycleCallbacks { + public static final String TAG = "EdXposedManager"; + @SuppressLint("StaticFieldLeak") + private static App instance = null; + private static Thread uiThread; + private static Handler mainHandler; + private SharedPreferences pref; + private AppCompatActivity currentActivity = null; + private boolean isUiLoaded = false; + + public static App getInstance() { + return instance; + } + + public static void runOnUiThread(Runnable action) { + if (Thread.currentThread() != uiThread) { + mainHandler.post(action); + } else { + action.run(); + } + } + + public static SharedPreferences getPreferences() { + return instance.pref; + } + + public static void mkdir(String dir) { + dir = Constants.getBaseDir() + dir; + //noinspection ResultOfMethodCallIgnored + new File(dir).mkdir(); + } + + public static boolean supportScope() { + return Constants.getXposedApiVersion() >= 92; + } + + public void onCreate() { + super.onCreate(); + if (!BuildConfig.DEBUG) { + try { + Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> { + + StringWriter sw = new StringWriter(); + PrintWriter pw = new PrintWriter(sw); + throwable.printStackTrace(pw); + String stackTraceString = sw.toString(); + + //Reduce data to 128KB so we don't get a TransactionTooLargeException when sending the intent. + //The limit is 1MB on Android but some devices seem to have it lower. + //See: http://developer.android.com/reference/android/os/TransactionTooLargeException.html + //And: http://stackoverflow.com/questions/11451393/what-to-do-on-transactiontoolargeexception#comment46697371_12809171 + if (stackTraceString.length() > 131071) { + String disclaimer = " [stack trace too large]"; + stackTraceString = stackTraceString.substring(0, 131071 - disclaimer.length()) + disclaimer; + } + Intent intent = new Intent(App.this, CrashReportActivity.class); + intent.putExtra(BuildConfig.APPLICATION_ID + ".EXTRA_STACK_TRACE", stackTraceString); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + App.this.startActivity(intent); + android.os.Process.killProcess(android.os.Process.myPid()); + System.exit(10); + }); + } catch (Throwable t) { + t.printStackTrace(); + } + } + + instance = this; + uiThread = Thread.currentThread(); + mainHandler = new Handler(); + + pref = PreferenceManager.getDefaultSharedPreferences(this); + + createDirectories(); + NotificationUtil.init(); + registerReceivers(); + + registerActivityLifecycleCallbacks(this); + + @SuppressLint("SimpleDateFormat") DateFormat dateFormat = new SimpleDateFormat("dd/MM/yyyy"); + Date date = new Date(); + + if (!Objects.requireNonNull(pref.getString("date", "")).equals(dateFormat.format(date))) { + pref.edit().putString("date", dateFormat.format(date)).apply(); + + try { + Log.i(TAG, String.format("EdXposedManager - %s - %s", BuildConfig.VERSION_CODE, getPackageManager().getPackageInfo(getPackageName(), 0).versionName)); + } catch (PackageManager.NameNotFoundException ignored) { + } + } + + RepoLoader.getInstance().triggerFirstLoadIfNecessary(); + } + + private void registerReceivers() { + IntentFilter filter = new IntentFilter(); + filter.addAction(Intent.ACTION_PACKAGE_ADDED); + filter.addAction(Intent.ACTION_PACKAGE_CHANGED); + filter.addAction(Intent.ACTION_PACKAGE_REMOVED); + filter.addDataScheme("package"); + registerReceiver(new PackageChangeReceiver(), filter); + + PendingIntent.getBroadcast(this, 0, + new Intent(this, PackageChangeReceiver.class), 0); + } + + @SuppressLint({"PrivateApi", "NewApi"}) + private void createDirectories() { + mkdir("conf"); + mkdir("log"); + } + + public void updateProgressIndicator(final SwipeRefreshLayout refreshLayout) { + final boolean isLoading = RepoLoader.getInstance().isLoading() || ModuleUtil.getInstance().isLoading(); + runOnUiThread(() -> { + synchronized (App.this) { + if (currentActivity != null) { + if (refreshLayout != null) + refreshLayout.setRefreshing(isLoading); + } + } + }); + } + + @Override + public synchronized void onActivityCreated(@NonNull Activity activity, Bundle savedInstanceState) { + if (isUiLoaded) { + return; + } + + RepoLoader.getInstance().triggerFirstLoadIfNecessary(); + isUiLoaded = true; + + if (pref.getBoolean("hook_modules", true)) { + Collection installedModules = ModuleUtil.getInstance().getModules().values(); + for (ModuleUtil.InstalledModule info : installedModules) { + if (!AppHelper.FORCE_WHITE_LIST_MODULE.contains(info.packageName)) { + AppHelper.FORCE_WHITE_LIST_MODULE.add(info.packageName); + } + } + Log.d(TAG, "ApplicationList: Force add modules to list"); + } + } + + @Override + public void onActivityStarted(@NonNull Activity activity) { + + } + + @Override + public synchronized void onActivityResumed(@NonNull Activity activity) { + currentActivity = (AppCompatActivity) activity; + updateProgressIndicator(null); + } + + @Override + public synchronized void onActivityPaused(@NonNull Activity activity) { + currentActivity = null; + } + + @Override + public void onActivityStopped(@NonNull Activity activity) { + + } + + @Override + public void onActivitySaveInstanceState(@NonNull Activity activity, @NonNull Bundle outState) { + + } + + @Override + public void onActivityDestroyed(@NonNull Activity activity) { + + } +} diff --git a/app/src/main/java/org/meowcat/edxposed/manager/Constants.java b/app/src/main/java/org/meowcat/edxposed/manager/Constants.java new file mode 100644 index 00000000..7cc13a8b --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/Constants.java @@ -0,0 +1,37 @@ +package org.meowcat.edxposed.manager; + +import android.util.Log; + +public class Constants { + public static int getXposedApiVersion() { + Log.e(App.TAG, "getXposedApiVersion: Xposed is not active"); + return -1; + } + + public static String getXposedVersion() { + Log.e(App.TAG, "getXposedVersion: Xposed is not active"); + return null; + } + + public static int getXposedVersionCode() { + Log.e(App.TAG, "getXposedVersionCode: Xposed is not active"); + return -1; + } + + public static String getXposedVariant() { + Log.e(App.TAG, "getXposedVariant: Xposed is not active"); + return null; + } + + public static String getEnabledModulesListFile() { + return getBaseDir() + "conf/enabled_modules.list"; + } + + public static String getModulesListFile() { + return getBaseDir() + "conf/modules.list"; + } + + public static String getBaseDir() { + return App.getInstance().getApplicationInfo().deviceProtectedDataDir + "/"; + } +} diff --git a/app/src/main/java/org/meowcat/edxposed/manager/adapters/AppAdapter.java b/app/src/main/java/org/meowcat/edxposed/manager/adapters/AppAdapter.java new file mode 100644 index 00000000..2e6b37e7 --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/adapters/AppAdapter.java @@ -0,0 +1,322 @@ +package org.meowcat.edxposed.manager.adapters; + +import android.content.Context; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.graphics.drawable.Drawable; +import android.os.AsyncTask; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.CompoundButton; +import android.widget.Filter; +import android.widget.Filterable; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.SwitchCompat; +import androidx.recyclerview.widget.RecyclerView; + +import com.bumptech.glide.request.target.CustomTarget; +import com.bumptech.glide.request.transition.Transition; + +import org.meowcat.edxposed.manager.App; +import org.meowcat.edxposed.manager.R; +import org.meowcat.edxposed.manager.util.GlideApp; +import org.meowcat.edxposed.manager.util.InstallApkUtil; + +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.Date; +import java.util.List; +import java.util.Locale; + +public class AppAdapter extends RecyclerView.Adapter implements Filterable { + + protected Context context; + private final ApplicationInfo.DisplayNameComparator displayNameComparator; + private Callback callback; + protected List fullList, showList; + private final DateFormat dateformat = new SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()); + private List checkedList; + private final PackageManager pm; + private final ApplicationFilter filter; + private Comparator cmp; + + AppAdapter(Context context) { + this.context = context; + fullList = showList = Collections.emptyList(); + checkedList = Collections.emptyList(); + filter = new ApplicationFilter(); + pm = context.getPackageManager(); + displayNameComparator = new ApplicationInfo.DisplayNameComparator(pm); + cmp = displayNameComparator; + refresh(); + } + + public void setCallback(Callback callback) { + this.callback = callback; + } + + @NonNull + @Override + public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View v = LayoutInflater.from(context).inflate(R.layout.item_module, parent, false); + return new ViewHolder(v); + } + + private void loadApps() { + fullList = pm.getInstalledApplications(PackageManager.GET_META_DATA); + List rmList = new ArrayList<>(); + for (ApplicationInfo info : fullList) { + if (this instanceof ScopeAdapter) { + if (AppHelper.isBlackListMode()) { + if (AppHelper.isWhiteListMode()) { + List whiteList = AppHelper.getWhiteList(); + if (!whiteList.contains(info.packageName)) { + rmList.add(info); + continue; + } + } else { + List blackList = AppHelper.getBlackList(); + if (blackList.contains(info.packageName)) { + rmList.add(info); + continue; + } + } + } + if (info.packageName.equals(((ScopeAdapter) this).modulePackageName)) { + rmList.add(info); + } + } else if (!App.getPreferences().getBoolean("show_modules", true)) { + if (info.metaData != null && info.metaData.containsKey("xposedmodule") || AppHelper.FORCE_WHITE_LIST_MODULE.contains(info.packageName)) { + rmList.add(info); + } + } + } + if (rmList.size() > 0) { + fullList.removeAll(rmList); + } + AppHelper.makeSurePath(); + checkedList = generateCheckedList(); + sortApps(); + showList = fullList; + if (callback != null) { + callback.onDataReady(); + } + } + + /** + * Called during {@link #loadApps()} in non-UI thread. + * + * @return list of package names which should be checked when shown + */ + protected List generateCheckedList() { + return Collections.emptyList(); + } + + private void sortApps() { + switch (App.getPreferences().getInt("list_sort", 0)) { + case 7: + cmp = Collections.reverseOrder((ApplicationInfo a, ApplicationInfo b) -> { + try { + return Long.compare(pm.getPackageInfo(a.packageName, 0).lastUpdateTime, pm.getPackageInfo(b.packageName, 0).lastUpdateTime); + } catch (PackageManager.NameNotFoundException e) { + e.printStackTrace(); + return displayNameComparator.compare(a, b); + } + }); + break; + case 6: + cmp = (ApplicationInfo a, ApplicationInfo b) -> { + try { + return Long.compare(pm.getPackageInfo(a.packageName, 0).lastUpdateTime, pm.getPackageInfo(b.packageName, 0).lastUpdateTime); + } catch (PackageManager.NameNotFoundException e) { + e.printStackTrace(); + return displayNameComparator.compare(a, b); + } + }; + break; + case 5: + cmp = Collections.reverseOrder((ApplicationInfo a, ApplicationInfo b) -> { + try { + return Long.compare(pm.getPackageInfo(a.packageName, 0).firstInstallTime, pm.getPackageInfo(b.packageName, 0).firstInstallTime); + } catch (PackageManager.NameNotFoundException e) { + e.printStackTrace(); + return displayNameComparator.compare(a, b); + } + }); + break; + case 4: + cmp = (ApplicationInfo a, ApplicationInfo b) -> { + try { + return Long.compare(pm.getPackageInfo(a.packageName, 0).firstInstallTime, pm.getPackageInfo(b.packageName, 0).firstInstallTime); + } catch (PackageManager.NameNotFoundException e) { + e.printStackTrace(); + return displayNameComparator.compare(a, b); + } + }; + break; + case 3: + cmp = Collections.reverseOrder((a, b) -> a.packageName.compareTo(b.packageName)); + break; + case 2: + cmp = (a, b) -> a.packageName.compareTo(b.packageName); + break; + case 1: + cmp = Collections.reverseOrder(displayNameComparator); + break; + case 0: + default: + cmp = displayNameComparator; + break; + } + fullList.sort((a, b) -> { + boolean aChecked = checkedList.contains(a.packageName); + boolean bChecked = checkedList.contains(b.packageName); + if (aChecked == bChecked) { + return cmp.compare(a, b); + } else if (aChecked) { + return -1; + } else { + return 1; + } + + }); + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, int position) { + ApplicationInfo info = showList.get(position); + holder.appName.setText(InstallApkUtil.getAppLabel(info, pm)); + try { + PackageInfo packageInfo = pm.getPackageInfo(info.packageName, 0); + GlideApp.with(holder.appIcon) + .load(packageInfo) + .into(new CustomTarget() { + @Override + public void onResourceReady(@NonNull Drawable resource, @Nullable Transition transition) { + holder.appIcon.setImageDrawable(resource); + } + + @Override + public void onLoadCleared(@Nullable Drawable placeholder) { + + } + }); + holder.appVersion.setText(packageInfo.versionName); + holder.appVersion.setSelected(true); + String creationDate = dateformat.format(new Date(packageInfo.firstInstallTime)); + String updateDate = dateformat.format(new Date(packageInfo.lastUpdateTime)); + holder.timestamps.setText(holder.itemView.getContext().getString(R.string.install_timestamps, creationDate, updateDate)); + } catch (PackageManager.NameNotFoundException e) { + e.printStackTrace(); + } + holder.appPackage.setText(info.packageName); + + holder.mSwitch.setOnCheckedChangeListener(null); + holder.mSwitch.setChecked(checkedList.contains(info.packageName)); + if (this instanceof ScopeAdapter) { + holder.mSwitch.setEnabled(((ScopeAdapter) this).enabled); + } else { + holder.mSwitch.setEnabled(true); + } + holder.mSwitch.setOnCheckedChangeListener((v, isChecked) -> + onCheckedChange(v, isChecked, info)); + holder.itemView.setOnClickListener(v -> { + if (callback != null) { + callback.onItemClick(v, info); + } + }); + } + + @Override + public long getItemId(int position) { + return showList.get(position).packageName.hashCode(); + } + + @Override + public Filter getFilter() { + return new ApplicationFilter(); + } + + @Override + public int getItemCount() { + return showList.size(); + } + + public void filter(String constraint) { + filter.filter(constraint); + } + + public void refresh() { + AsyncTask.THREAD_POOL_EXECUTOR.execute(this::loadApps); + } + + protected void onCheckedChange(CompoundButton buttonView, boolean isChecked, ApplicationInfo info) { + // override this to implements your functions + } + + public interface Callback { + void onDataReady(); + + void onItemClick(View v, ApplicationInfo info); + } + + static class ViewHolder extends RecyclerView.ViewHolder { + + ImageView appIcon; + TextView appName; + TextView appPackage; + TextView appVersion; + TextView timestamps; + SwitchCompat mSwitch; + + ViewHolder(View itemView) { + super(itemView); + appIcon = itemView.findViewById(R.id.app_icon); + appName = itemView.findViewById(R.id.app_name); + appPackage = itemView.findViewById(R.id.package_name); + appVersion = itemView.findViewById(R.id.version_name); + timestamps = itemView.findViewById(R.id.timestamps); + mSwitch = itemView.findViewById(R.id.checkbox); + } + } + + class ApplicationFilter extends Filter { + + private boolean lowercaseContains(String s, CharSequence filter) { + return !TextUtils.isEmpty(s) && s.toLowerCase().contains(filter); + } + + @Override + protected FilterResults performFiltering(CharSequence constraint) { + if (constraint.toString().isEmpty()) { + showList = fullList; + } else { + ArrayList filtered = new ArrayList<>(); + String filter = constraint.toString().toLowerCase(); + for (ApplicationInfo info : fullList) { + if (lowercaseContains(InstallApkUtil.getAppLabel(info, pm), filter) + || lowercaseContains(info.packageName, filter)) { + filtered.add(info); + } + } + showList = filtered; + } + return null; + } + + @Override + protected void publishResults(CharSequence constraint, FilterResults results) { + notifyDataSetChanged(); + } + } +} diff --git a/app/src/main/java/org/meowcat/edxposed/manager/adapters/AppHelper.java b/app/src/main/java/org/meowcat/edxposed/manager/adapters/AppHelper.java new file mode 100644 index 00000000..c475c5c3 --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/adapters/AppHelper.java @@ -0,0 +1,351 @@ +package org.meowcat.edxposed.manager.adapters; + +import android.annotation.SuppressLint; +import android.app.ActivityManager; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ApplicationInfo; +import android.net.Uri; +import android.view.View; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.appcompat.view.menu.MenuBuilder; +import androidx.appcompat.view.menu.MenuPopupHelper; +import androidx.appcompat.widget.PopupMenu; +import androidx.fragment.app.FragmentManager; + +import org.meowcat.edxposed.manager.App; +import org.meowcat.edxposed.manager.BuildConfig; +import org.meowcat.edxposed.manager.Constants; +import org.meowcat.edxposed.manager.R; +import org.meowcat.edxposed.manager.util.CompileUtil; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; +import java.io.PrintWriter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Objects; + +import static android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS; + +public class AppHelper { + + private static final String BASE_PATH = Constants.getBaseDir(); + private static final String WHITE_LIST_PATH = "conf/whitelist/"; + private static final String BLACK_LIST_PATH = "conf/blacklist/"; + private static final String COMPAT_LIST_PATH = "conf/compatlist/"; + private static final String SCOPE_LIST_PATH = "conf/%s.conf"; + private static final String WHITE_LIST_MODE = "conf/usewhitelist"; + private static final String BLACK_LIST_MODE = "conf/blackwhitelist"; + + private static final List FORCE_WHITE_LIST = new ArrayList<>(Collections.singletonList(BuildConfig.APPLICATION_ID)); + public static List FORCE_WHITE_LIST_MODULE = new ArrayList<>(FORCE_WHITE_LIST); + + private static final HashMap> scopeList = new HashMap<>(); + + static void makeSurePath() { + App.mkdir(WHITE_LIST_PATH); + App.mkdir(BLACK_LIST_PATH); + App.mkdir(COMPAT_LIST_PATH); + } + + public static boolean isWhiteListMode() { + return new File(BASE_PATH + WHITE_LIST_MODE).exists(); + } + + public static boolean isBlackListMode() { + return new File(BASE_PATH + BLACK_LIST_MODE).exists(); + } + + private static boolean addWhiteList(String packageName) { + return whiteListFileName(packageName, true); + } + + private static boolean addBlackList(String packageName) { + if (FORCE_WHITE_LIST_MODULE.contains(packageName)) { + removeBlackList(packageName); + return false; + } + return blackListFileName(packageName, true); + } + + private static boolean removeWhiteList(String packageName) { + if (FORCE_WHITE_LIST_MODULE.contains(packageName)) { + return false; + } + return whiteListFileName(packageName, false); + } + + private static boolean removeBlackList(String packageName) { + return blackListFileName(packageName, false); + } + + static List getBlackList() { + File file = new File(BASE_PATH + BLACK_LIST_PATH); + File[] files = file.listFiles(); + if (files == null) { + return new ArrayList<>(); + } + List s = new ArrayList<>(); + for (File file1 : files) { + if (!file1.isDirectory()) { + s.add(file1.getName()); + } + } + for (String pn : FORCE_WHITE_LIST_MODULE) { + if (s.contains(pn)) { + s.remove(pn); + removeBlackList(pn); + } + } + return s; + } + + static List getWhiteList() { + File file = new File(BASE_PATH + WHITE_LIST_PATH); + File[] files = file.listFiles(); + if (files == null) { + return FORCE_WHITE_LIST_MODULE; + } + List result = new ArrayList<>(); + for (File file1 : files) { + result.add(file1.getName()); + } + for (String pn : FORCE_WHITE_LIST_MODULE) { + if (!result.contains(pn)) { + result.add(pn); + addWhiteList(pn); + } + } + return result; + } + + @SuppressLint("WorldReadableFiles") + private static Boolean whiteListFileName(String packageName, boolean isAdd) { + boolean returns = true; + File file = new File(BASE_PATH + WHITE_LIST_PATH + packageName); + if (isAdd) { + if (!file.exists()) { + FileOutputStream fos = null; + try { + fos = new FileOutputStream(file.getPath()); + } catch (FileNotFoundException e) { + e.printStackTrace(); + } finally { + if (fos != null) { + try { + fos.close(); + } catch (IOException e) { + e.printStackTrace(); + try { + returns = file.createNewFile(); + } catch (IOException e1) { + e.printStackTrace(); + } + } + } + } + } + } else { + if (file.exists()) { + returns = file.delete(); + } + } + return returns; + } + + @SuppressLint("WorldReadableFiles") + private static Boolean blackListFileName(String packageName, boolean isAdd) { + boolean returns = true; + File file = new File(BASE_PATH + BLACK_LIST_PATH + packageName); + if (isAdd) { + if (!file.exists()) { + FileOutputStream fos = null; + try { + fos = new FileOutputStream(file.getPath()); + } catch (FileNotFoundException e) { + e.printStackTrace(); + } finally { + if (fos != null) { + try { + fos.close(); + } catch (IOException e) { + e.printStackTrace(); + try { + returns = file.createNewFile(); + } catch (IOException e1) { + e.printStackTrace(); + } + } + } + } + } + } else { + if (file.exists()) { + returns = file.delete(); + } + } + return returns; + } + + @SuppressLint("WorldReadableFiles") + private static Boolean compatListFileName(String packageName, boolean isAdd) { + boolean returns = true; + File file = new File(BASE_PATH + COMPAT_LIST_PATH + packageName); + if (isAdd) { + if (!file.exists()) { + FileOutputStream fos = null; + try { + fos = new FileOutputStream(file.getPath()); + } catch (FileNotFoundException e) { + e.printStackTrace(); + } finally { + if (fos != null) { + try { + fos.close(); + } catch (IOException e) { + e.printStackTrace(); + try { + returns = file.createNewFile(); + } catch (IOException e1) { + e.printStackTrace(); + } + } + } + } + } + } else { + if (file.exists()) { + returns = file.delete(); + } + } + return returns; + } + + static boolean addPackageName(boolean isWhiteListMode, String packageName) { + return isWhiteListMode ? addWhiteList(packageName) : addBlackList(packageName); + } + + static boolean removePackageName(boolean isWhiteListMode, String packageName) { + return isWhiteListMode ? removeWhiteList(packageName) : removeBlackList(packageName); + } + + @SuppressLint("RestrictedApi") + public static void showMenu(@NonNull Context context, + @NonNull FragmentManager fragmentManager, + @NonNull View anchor, + @NonNull ApplicationInfo info) { + PopupMenu appMenu = new PopupMenu(context, anchor); + appMenu.inflate(R.menu.menu_app_item); + appMenu.setOnMenuItemClickListener(menuItem -> { + int itemId = menuItem.getItemId(); + if (itemId == R.id.app_menu_launch) { + Intent launchIntent = context.getPackageManager().getLaunchIntentForPackage(info.packageName); + if (launchIntent != null) { + context.startActivity(launchIntent); + } else { + Toast.makeText(context, context.getString(R.string.module_no_ui), Toast.LENGTH_LONG).show(); + } + } else if (itemId == R.id.app_menu_stop) { + try { + ActivityManager manager = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + Objects.requireNonNull(manager).killBackgroundProcesses(info.packageName); + } catch (Exception ex) { + ex.printStackTrace(); + } + } else if (itemId == R.id.app_menu_compile_speed) { + CompileUtil.compileSpeed(context, fragmentManager, info); + } else if (itemId == R.id.app_menu_compile_dexopt) { + CompileUtil.compileDexopt(context, fragmentManager, info); + } else if (itemId == R.id.app_menu_compile_reset) { + CompileUtil.reset(context, fragmentManager, info); + } else if (itemId == R.id.app_menu_store) { + Uri uri = Uri.parse("market://details?id=" + info.packageName); + Intent intent = new Intent(Intent.ACTION_VIEW, uri); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + try { + context.startActivity(intent); + } catch (Exception ex) { + ex.printStackTrace(); + } + } else if (itemId == R.id.app_menu_info) { + context.startActivity(new Intent(ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", info.packageName, null))); + } else if (itemId == R.id.app_menu_uninstall) { + context.startActivity(new Intent(Intent.ACTION_UNINSTALL_PACKAGE, Uri.fromParts("package", info.packageName, null))); + } + return true; + }); + MenuPopupHelper menuHelper = new MenuPopupHelper(context, (MenuBuilder) appMenu.getMenu(), anchor); + menuHelper.setForceShowIcon(true); + menuHelper.show(); + } + + static List getCompatList() { + File file = new File(BASE_PATH + COMPAT_LIST_PATH); + File[] files = file.listFiles(); + if (files == null) { + return new ArrayList<>(); + } + List s = new ArrayList<>(); + for (File file1 : files) { + s.add(file1.getName()); + } + return s; + } + + static boolean addCompatList(String packageName) { + return compatListFileName(packageName, true); + } + + static boolean removeCompatList(String packageName) { + return compatListFileName(packageName, false); + } + + static List getScopeList(String modulePackageName) { + if (scopeList.containsKey(modulePackageName)) { + return scopeList.get(modulePackageName); + } + File file = new File(BASE_PATH + String.format(SCOPE_LIST_PATH, modulePackageName)); + List s = new ArrayList<>(); + try { + BufferedReader bufferedReader = new BufferedReader(new FileReader(file)); + for (String line; (line = bufferedReader.readLine()) != null; ) { + s.add(line); + } + scopeList.put(modulePackageName, s); + } catch (IOException e) { + e.printStackTrace(); + } + return s; + } + + @SuppressLint("WorldReadableFiles") + static boolean saveScopeList(String modulePackageName, List list) { + File file = new File(BASE_PATH + String.format(SCOPE_LIST_PATH, modulePackageName)); + if (list.size() == 0) { + scopeList.put(modulePackageName, list); + return file.delete(); + } + try { + PrintWriter pr = new PrintWriter(new FileWriter(file)); + for (String line : list) { + pr.println(line); + } + pr.close(); + } catch (IOException e) { + e.printStackTrace(); + return false; + } + scopeList.put(modulePackageName, list); + return true; + } +} diff --git a/app/src/main/java/org/meowcat/edxposed/manager/adapters/BlackListAdapter.java b/app/src/main/java/org/meowcat/edxposed/manager/adapters/BlackListAdapter.java new file mode 100644 index 00000000..dcea4048 --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/adapters/BlackListAdapter.java @@ -0,0 +1,59 @@ +package org.meowcat.edxposed.manager.adapters; + +import android.content.Context; +import android.content.pm.ApplicationInfo; +import android.widget.CompoundButton; + +import org.meowcat.edxposed.manager.App; +import org.meowcat.edxposed.manager.R; +import org.meowcat.edxposed.manager.util.ModuleUtil; +import org.meowcat.edxposed.manager.util.ToastUtil; + +import java.util.Collection; +import java.util.List; + + +public class BlackListAdapter extends AppAdapter { + + private final boolean isWhiteListMode; + private List checkedList; + + public BlackListAdapter(Context context, boolean isWhiteListMode) { + super(context); + this.isWhiteListMode = isWhiteListMode; + } + + @Override + public List generateCheckedList() { + if (App.getPreferences().getBoolean("hook_modules", true)) { + Collection installedModules = ModuleUtil.getInstance().getModules().values(); + for (ModuleUtil.InstalledModule info : installedModules) { + AppHelper.FORCE_WHITE_LIST_MODULE.add(info.packageName); + } + } + AppHelper.makeSurePath(); + if (isWhiteListMode) { + checkedList = AppHelper.getWhiteList(); + } else { + checkedList = AppHelper.getBlackList(); + } + return checkedList; + } + + @Override + protected void onCheckedChange(CompoundButton view, boolean isChecked, ApplicationInfo info) { + boolean success = isChecked ? + AppHelper.addPackageName(isWhiteListMode, info.packageName) : + AppHelper.removePackageName(isWhiteListMode, info.packageName); + if (success) { + if (isChecked) { + checkedList.add(info.packageName); + } else { + checkedList.remove(info.packageName); + } + } else { + ToastUtil.showShortToast(context, R.string.add_package_failed); + view.setChecked(!isChecked); + } + } +} diff --git a/app/src/main/java/org/meowcat/edxposed/manager/adapters/CompatListAdapter.java b/app/src/main/java/org/meowcat/edxposed/manager/adapters/CompatListAdapter.java new file mode 100644 index 00000000..20272096 --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/adapters/CompatListAdapter.java @@ -0,0 +1,41 @@ +package org.meowcat.edxposed.manager.adapters; + +import android.content.Context; +import android.content.pm.ApplicationInfo; +import android.widget.CompoundButton; + +import org.meowcat.edxposed.manager.R; +import org.meowcat.edxposed.manager.util.ToastUtil; + +import java.util.List; + +public class CompatListAdapter extends AppAdapter { + + private List checkedList; + + public CompatListAdapter(Context context) { + super(context); + } + + @Override + protected List generateCheckedList() { + AppHelper.makeSurePath(); + return checkedList = AppHelper.getCompatList(); + } + + @Override + protected void onCheckedChange(CompoundButton view, boolean isChecked, ApplicationInfo info) { + boolean success = isChecked ? + AppHelper.addCompatList(info.packageName) : AppHelper.removeCompatList(info.packageName); + if (success) { + if (isChecked) { + checkedList.add(info.packageName); + } else { + checkedList.remove(info.packageName); + } + } else { + ToastUtil.showShortToast(context, R.string.add_package_failed); + view.setChecked(!isChecked); + } + } +} diff --git a/app/src/main/java/org/meowcat/edxposed/manager/adapters/CursorRecyclerViewAdapter.java b/app/src/main/java/org/meowcat/edxposed/manager/adapters/CursorRecyclerViewAdapter.java new file mode 100644 index 00000000..7c6a336c --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/adapters/CursorRecyclerViewAdapter.java @@ -0,0 +1,123 @@ +package org.meowcat.edxposed.manager.adapters; + +import android.database.Cursor; +import android.database.DataSetObserver; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +public abstract class CursorRecyclerViewAdapter extends RecyclerView.Adapter { + + private Cursor cursor; + + private boolean dataValid; + + private int rowIdColumn; + + private final DataSetObserver dataSetObserver; + + public CursorRecyclerViewAdapter(Cursor cursor) { + this.cursor = cursor; + dataValid = cursor != null; + rowIdColumn = dataValid ? cursor.getColumnIndex("_id") : -1; + dataSetObserver = new NotifyingDataSetObserver(); + if (this.cursor != null) { + this.cursor.registerDataSetObserver(dataSetObserver); + } + } + + protected Cursor getCursor() { + return cursor; + } + + @Override + public int getItemCount() { + if (dataValid && cursor != null) { + return cursor.getCount(); + } + return 0; + } + + @Override + public long getItemId(int position) { + if (dataValid && cursor != null && cursor.moveToPosition(position)) { + return cursor.getLong(rowIdColumn); + } + return 0; + } + + @Override + public void setHasStableIds(boolean hasStableIds) { + super.setHasStableIds(true); + } + + public abstract void onBindViewHolder(VH viewHolder, Cursor cursor); + + @Override + public void onBindViewHolder(@NonNull VH viewHolder, int position) { + if (!dataValid) { + throw new IllegalStateException("this should only be called when the cursor is valid"); + } + if (!cursor.moveToPosition(position)) { + throw new IllegalStateException("couldn't move cursor to position " + position); + } + onBindViewHolder(viewHolder, cursor); + } + + /** + * Change the underlying cursor to a new cursor. If there is an existing cursor it will be + * closed. + */ + public void changeCursor(Cursor cursor) { + Cursor old = swapCursor(cursor); + if (old != null) { + old.close(); + } + } + + /** + * Swap in a new Cursor, returning the old Cursor. Unlike + * {@link #changeCursor(Cursor)}, the returned old Cursor is not + * closed. + */ + private Cursor swapCursor(Cursor newCursor) { + if (newCursor == cursor) { + return null; + } + final Cursor oldCursor = cursor; + if (oldCursor != null && dataSetObserver != null) { + oldCursor.unregisterDataSetObserver(dataSetObserver); + } + cursor = newCursor; + if (cursor != null) { + if (dataSetObserver != null) { + cursor.registerDataSetObserver(dataSetObserver); + } + rowIdColumn = newCursor.getColumnIndexOrThrow("_id"); + dataValid = true; + } else { + rowIdColumn = -1; + dataValid = false; + //There is no notifyDataSetInvalidated() method in RecyclerView.Adapter + } + notifyDataSetChanged(); + return oldCursor; + } + + private class NotifyingDataSetObserver extends DataSetObserver { + @Override + public void onChanged() { + super.onChanged(); + dataValid = true; + notifyDataSetChanged(); + } + + @Override + public void onInvalidated() { + super.onInvalidated(); + dataValid = false; + notifyDataSetChanged(); + //There is no notifyDataSetInvalidated() method in RecyclerView.Adapter + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/meowcat/edxposed/manager/adapters/ScopeAdapter.java b/app/src/main/java/org/meowcat/edxposed/manager/adapters/ScopeAdapter.java new file mode 100644 index 00000000..ab212dcf --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/adapters/ScopeAdapter.java @@ -0,0 +1,69 @@ +package org.meowcat.edxposed.manager.adapters; + +import android.app.Activity; +import android.content.Context; +import android.content.pm.ApplicationInfo; +import android.widget.CompoundButton; + +import org.meowcat.edxposed.manager.R; +import org.meowcat.edxposed.manager.ui.widget.MasterSwitch; +import org.meowcat.edxposed.manager.util.ToastUtil; + +import java.util.ArrayList; +import java.util.List; + +public class ScopeAdapter extends AppAdapter { + + protected final String modulePackageName; + protected boolean enabled = true; + private List checkedList; + private final MasterSwitch masterSwitch; + + public ScopeAdapter(Context context, String modulePackageName, MasterSwitch masterSwitch) { + super(context); + this.modulePackageName = modulePackageName; + this.masterSwitch = masterSwitch; + masterSwitch.setTitle(context.getString(R.string.enable_scope)); + masterSwitch.setOnCheckedChangedListener(new MasterSwitch.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(boolean checked) { + enabled = checked; + AppHelper.saveScopeList(modulePackageName, enabled ? checkedList : new ArrayList<>()); + notifyDataSetChanged(); + } + }); + } + + @Override + public List generateCheckedList() { + AppHelper.makeSurePath(); + List scopeList = AppHelper.getScopeList(modulePackageName); + List list = new ArrayList<>(); + for (ApplicationInfo info : fullList) { + list.add(info.packageName); + } + scopeList.retainAll(list); + checkedList = scopeList; + enabled = checkedList.size() != 0; + ((Activity) context).runOnUiThread(() -> masterSwitch.setChecked(enabled)); + return checkedList; + } + + @Override + protected void onCheckedChange(CompoundButton view, boolean isChecked, ApplicationInfo info) { + if (isChecked) { + checkedList.add(info.packageName); + } else { + checkedList.remove(info.packageName); + } + if (!AppHelper.saveScopeList(modulePackageName, checkedList)) { + ToastUtil.showShortToast(context, R.string.add_package_failed); + if (!isChecked) { + checkedList.add(info.packageName); + } else { + checkedList.remove(info.packageName); + } + view.setChecked(!isChecked); + } + } +} diff --git a/app/src/main/java/org/meowcat/edxposed/manager/receivers/BootReceiver.java b/app/src/main/java/org/meowcat/edxposed/manager/receivers/BootReceiver.java new file mode 100644 index 00000000..c6764728 --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/receivers/BootReceiver.java @@ -0,0 +1,44 @@ +package org.meowcat.edxposed.manager.receivers; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +import org.json.JSONObject; +import org.meowcat.edxposed.manager.BuildConfig; +import org.meowcat.edxposed.manager.util.NotificationUtil; +import org.meowcat.edxposed.manager.util.TaskRunner; +import org.meowcat.edxposed.manager.util.json.JSONUtils; + +import java.util.concurrent.Callable; + +public class BootReceiver extends BroadcastReceiver { + + @Override + public void onReceive(final Context context, Intent intent) { + new TaskRunner().executeAsync(new LongRunningTask()); + } + + private static class LongRunningTask implements Callable { + + @Override + public Void call() { + try { + Thread.sleep(60 * 60 * 1000); + String jsonString = JSONUtils.getFileContent(JSONUtils.JSON_LINK).replace("%XPOSED_ZIP%", ""); + + String newApkVersion = new JSONObject(jsonString).getJSONObject("apk").getString("version"); + + Integer a = BuildConfig.VERSION_CODE; + Integer b = Integer.valueOf(newApkVersion); + + if (a.compareTo(b) < 0) { + NotificationUtil.showInstallerUpdateNotification(); + } + } catch (Exception e) { + e.printStackTrace(); + } + return null; + } + } +} diff --git a/app/src/main/java/org/meowcat/edxposed/manager/receivers/PackageChangeReceiver.java b/app/src/main/java/org/meowcat/edxposed/manager/receivers/PackageChangeReceiver.java new file mode 100644 index 00000000..9c7a3623 --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/receivers/PackageChangeReceiver.java @@ -0,0 +1,79 @@ +package org.meowcat.edxposed.manager.receivers; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; + +import org.meowcat.edxposed.manager.util.ModuleUtil; +import org.meowcat.edxposed.manager.util.ModuleUtil.InstalledModule; +import org.meowcat.edxposed.manager.util.NotificationUtil; + +import java.util.Objects; + +public class PackageChangeReceiver extends BroadcastReceiver { + private static ModuleUtil moduleUtil = null; + + private static String getPackageName(Intent intent) { + Uri uri = intent.getData(); + return (uri != null) ? uri.getSchemeSpecificPart() : null; + } + + @Override + public void onReceive(final Context context, final Intent intent) { + if (Objects.requireNonNull(intent.getAction()).equals(Intent.ACTION_PACKAGE_REMOVED) && intent.getBooleanExtra(Intent.EXTRA_REPLACING, false)) + // Ignore existing packages being removed in order to be updated + return; + + String packageName = getPackageName(intent); + if (packageName == null) + return; + + if (intent.getAction().equals(Intent.ACTION_PACKAGE_CHANGED)) { + // make sure that the change is for the complete package, not only a + // component + String[] components = intent.getStringArrayExtra(Intent.EXTRA_CHANGED_COMPONENT_NAME_LIST); + if (components != null) { + boolean isForPackage = false; + for (String component : components) { + if (packageName.equals(component)) { + isForPackage = true; + break; + } + } + if (!isForPackage) + return; + } + } else if (intent.getAction().equals(Intent.ACTION_PACKAGE_REMOVED)) { + NotificationUtil.cancel(packageName, NotificationUtil.NOTIFICATION_MODULE_NOT_ACTIVATED_YET); + return; + } + + moduleUtil = getModuleUtilInstance(); + + moduleUtil.updateModulesList(false); + InstalledModule module = ModuleUtil.getInstance().reloadSingleModule(packageName); + if (module == null + || intent.getAction().equals(Intent.ACTION_PACKAGE_REMOVED)) { + // Package being removed, disable it if it was a previously active + // Xposed mod + if (moduleUtil.isModuleEnabled(packageName)) { + moduleUtil.setModuleEnabled(packageName, false); + } + return; + } + + if (moduleUtil.isModuleEnabled(packageName)) { + NotificationUtil.showModulesUpdatedNotification(); + } else { + NotificationUtil.showNotActivatedNotification(packageName, module.getAppName()); + } + } + + private ModuleUtil getModuleUtilInstance() { + if (moduleUtil == null) { + moduleUtil = ModuleUtil.getInstance(); + } + return moduleUtil; + } +} diff --git a/app/src/main/java/org/meowcat/edxposed/manager/repo/Module.java b/app/src/main/java/org/meowcat/edxposed/manager/repo/Module.java new file mode 100644 index 00000000..83e76989 --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/repo/Module.java @@ -0,0 +1,28 @@ +package org.meowcat.edxposed.manager.repo; + +import android.util.Pair; + +import java.util.ArrayList; +import java.util.LinkedList; +import java.util.List; + +public class Module { + @SuppressWarnings("WeakerAccess") + public final Repository repository; + public final List> moreInfo = new LinkedList<>(); + public final List versions = new ArrayList<>(); + final List screenshots = new ArrayList<>(); + public String packageName; + public String name; + public String summary; + public String description; + public boolean descriptionIsHtml = false; + public String author; + public String support; + long created = -1; + long updated = -1; + + Module(Repository repository) { + this.repository = repository; + } +} diff --git a/app/src/main/java/org/meowcat/edxposed/manager/repo/ModuleVersion.java b/app/src/main/java/org/meowcat/edxposed/manager/repo/ModuleVersion.java new file mode 100644 index 00000000..299a0da0 --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/repo/ModuleVersion.java @@ -0,0 +1,17 @@ +package org.meowcat.edxposed.manager.repo; + +public class ModuleVersion { + public final Module module; + public String name; + public int code; + public String downloadLink; + public String md5sum; + public String changelog; + public boolean changelogIsHtml = false; + public ReleaseType relType = ReleaseType.STABLE; + public long uploaded = -1; + + /* package */ ModuleVersion(Module module) { + this.module = module; + } +} diff --git a/app/src/main/java/org/meowcat/edxposed/manager/repo/ReleaseType.java b/app/src/main/java/org/meowcat/edxposed/manager/repo/ReleaseType.java new file mode 100644 index 00000000..2e549a9a --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/repo/ReleaseType.java @@ -0,0 +1,40 @@ +package org.meowcat.edxposed.manager.repo; + + +import org.meowcat.edxposed.manager.R; + +public enum ReleaseType { + STABLE(R.string.reltype_stable, R.string.reltype_stable_summary), BETA(R.string.reltype_beta, R.string.reltype_beta_summary), EXPERIMENTAL(R.string.reltype_experimental, R.string.reltype_experimental_summary); + + private static final ReleaseType[] sValuesCache = values(); + private final int mTitleId; + private final int mSummaryId; + + ReleaseType(int titleId, int summaryId) { + mTitleId = titleId; + mSummaryId = summaryId; + } + + public static ReleaseType fromString(String value) { + if (value == null || value.equals("stable")) + return STABLE; + else if (value.equals("beta")) + return BETA; + else if (value.equals("experimental")) + return EXPERIMENTAL; + else + return STABLE; + } + + public static ReleaseType fromOrdinal(int ordinal) { + return sValuesCache[ordinal]; + } + + public int getTitleId() { + return mTitleId; + } + + public int getSummaryId() { + return mSummaryId; + } +} diff --git a/app/src/main/java/org/meowcat/edxposed/manager/repo/RepoDb.java b/app/src/main/java/org/meowcat/edxposed/manager/repo/RepoDb.java new file mode 100644 index 00000000..6722812c --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/repo/RepoDb.java @@ -0,0 +1,492 @@ +package org.meowcat.edxposed.manager.repo; + +import android.annotation.SuppressLint; +import android.content.ContentValues; +import android.content.Context; +import android.content.SharedPreferences; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; +import android.text.TextUtils; +import android.util.Pair; + +import org.meowcat.edxposed.manager.App; +import org.meowcat.edxposed.manager.BuildConfig; +import org.meowcat.edxposed.manager.repo.RepoDbDefinitions.InstalledModulesColumns; +import org.meowcat.edxposed.manager.repo.RepoDbDefinitions.InstalledModulesUpdatesColumns; +import org.meowcat.edxposed.manager.repo.RepoDbDefinitions.ModuleVersionsColumns; +import org.meowcat.edxposed.manager.repo.RepoDbDefinitions.ModulesColumns; +import org.meowcat.edxposed.manager.repo.RepoDbDefinitions.MoreInfoColumns; +import org.meowcat.edxposed.manager.repo.RepoDbDefinitions.OverviewColumns; +import org.meowcat.edxposed.manager.repo.RepoDbDefinitions.OverviewColumnsIndexes; +import org.meowcat.edxposed.manager.repo.RepoDbDefinitions.RepositoriesColumns; +import org.meowcat.edxposed.manager.util.ModuleUtil; +import org.meowcat.edxposed.manager.util.ModuleUtil.InstalledModule; +import org.meowcat.edxposed.manager.util.RepoLoader; + +import java.io.File; +import java.util.LinkedHashMap; +import java.util.Map; + +import static android.content.Context.MODE_PRIVATE; + +public final class RepoDb extends SQLiteOpenHelper { + public static final int SORT_STATUS = 0; + public static final int SORT_UPDATED = 1; + private static final int SORT_CREATED = 2; + + @SuppressLint("StaticFieldLeak") + private static Context context; + private static final SQLiteDatabase db; + + static { + RepoDb instance = new RepoDb(App.getInstance()); + db = instance.getWritableDatabase(); + db.execSQL("PRAGMA foreign_keys=ON"); + instance.createTempTables(db); + } + + private RepoDb(Context context) { + super(context, getDbPath(context), null, RepoDbDefinitions.DATABASE_VERSION); + RepoDb.context = context; + } + + private static String getDbPath(Context context) { + return new File(context.getNoBackupFilesDir(), RepoDbDefinitions.DATABASE_NAME).getPath(); + } + + public static void beginTransation() { + db.beginTransaction(); + } + + public static void setTransactionSuccessful() { + db.setTransactionSuccessful(); + } + + public static void endTransation() { + db.endTransaction(); + } + + private static String getString(@SuppressWarnings("SameParameterValue") String table, @SuppressWarnings("SameParameterValue") String searchColumn, String searchValue, @SuppressWarnings("SameParameterValue") String resultColumn) { + String[] projection = new String[]{resultColumn}; + String where = searchColumn + " = ?"; + String[] whereArgs = new String[]{searchValue}; + Cursor c = db.query(table, projection, where, whereArgs, null, null, null, "1"); + if (c.moveToFirst()) { + String result = c.getString(c.getColumnIndexOrThrow(resultColumn)); + c.close(); + return result; + } else { + c.close(); + throw new RowNotFoundException("Could not find " + table + "." + searchColumn + " with value '" + searchValue + "'"); + } + } + + @SuppressWarnings("UnusedReturnValue") + public static long insertRepository(String url) { + ContentValues values = new ContentValues(); + values.put(RepositoriesColumns.URL, url); + return db.insertOrThrow(RepositoriesColumns.TABLE_NAME, null, values); + } + + public static void deleteRepositories() { + if (db != null) + db.delete(RepositoriesColumns.TABLE_NAME, null, null); + } + + public static Map getRepositories() { + Map result = new LinkedHashMap<>(1); + + String[] projection = new String[]{ + RepositoriesColumns._ID, + RepositoriesColumns.URL, + RepositoriesColumns.TITLE, + RepositoriesColumns.PARTIAL_URL, + RepositoriesColumns.VERSION, + }; + + Cursor c = db.query(RepositoriesColumns.TABLE_NAME, projection, null, null, null, null, RepositoriesColumns._ID); + while (c.moveToNext()) { + Repository repo = new Repository(); + long id = c.getLong(c.getColumnIndexOrThrow(RepositoriesColumns._ID)); + repo.url = c.getString(c.getColumnIndexOrThrow(RepositoriesColumns.URL)); + repo.name = c.getString(c.getColumnIndexOrThrow(RepositoriesColumns.TITLE)); + repo.partialUrl = c.getString(c.getColumnIndexOrThrow(RepositoriesColumns.PARTIAL_URL)); + repo.version = c.getString(c.getColumnIndexOrThrow(RepositoriesColumns.VERSION)); + result.put(id, repo); + } + c.close(); + + return result; + } + + public static void updateRepository(long repoId, Repository repository) { + ContentValues values = new ContentValues(); + values.put(RepositoriesColumns.TITLE, repository.name); + values.put(RepositoriesColumns.PARTIAL_URL, repository.partialUrl); + values.put(RepositoriesColumns.VERSION, repository.version); + db.update(RepositoriesColumns.TABLE_NAME, values, RepositoriesColumns._ID + " = ?", new String[]{Long.toString(repoId)}); + } + + public static void updateRepositoryVersion(long repoId, String version) { + ContentValues values = new ContentValues(); + values.put(RepositoriesColumns.VERSION, version); + db.update(RepositoriesColumns.TABLE_NAME, values, RepositoriesColumns._ID + " = ?", new String[]{Long.toString(repoId)}); + } + + @SuppressWarnings("UnusedReturnValue") + public static long insertModule(long repoId, Module mod) { + ContentValues values = new ContentValues(); + values.put(ModulesColumns.REPO_ID, repoId); + values.put(ModulesColumns.PKGNAME, mod.packageName); + values.put(ModulesColumns.TITLE, mod.name); + values.put(ModulesColumns.SUMMARY, mod.summary); + values.put(ModulesColumns.DESCRIPTION, mod.description); + values.put(ModulesColumns.DESCRIPTION_IS_HTML, mod.descriptionIsHtml); + values.put(ModulesColumns.AUTHOR, mod.author); + values.put(ModulesColumns.SUPPORT, mod.support); + values.put(ModulesColumns.CREATED, mod.created); + values.put(ModulesColumns.UPDATED, mod.updated); + + ModuleVersion latestVersion = RepoLoader.getInstance().getLatestVersion(mod); + + db.beginTransaction(); + try { + long moduleId = db.insertOrThrow(ModulesColumns.TABLE_NAME, null, values); + + long latestVersionId = -1; + for (ModuleVersion version : mod.versions) { + long versionId = insertModuleVersion(moduleId, version); + if (latestVersion == version) + latestVersionId = versionId; + } + + if (latestVersionId > -1) { + values = new ContentValues(); + values.put(ModulesColumns.LATEST_VERSION, latestVersionId); + db.update(ModulesColumns.TABLE_NAME, values, ModulesColumns._ID + " = ?", new String[]{Long.toString(moduleId)}); + } + + for (Pair moreInfoEntry : mod.moreInfo) { + insertMoreInfo(moduleId, moreInfoEntry.first, moreInfoEntry.second); + } + + // TODO Add mod.screenshots + + db.setTransactionSuccessful(); + return moduleId; + + } finally { + db.endTransaction(); + } + } + + private static long insertModuleVersion(long moduleId, ModuleVersion version) { + ContentValues values = new ContentValues(); + values.put(ModuleVersionsColumns.MODULE_ID, moduleId); + values.put(ModuleVersionsColumns.NAME, version.name); + values.put(ModuleVersionsColumns.CODE, version.code); + values.put(ModuleVersionsColumns.DOWNLOAD_LINK, version.downloadLink); + values.put(ModuleVersionsColumns.MD5SUM, version.md5sum); + values.put(ModuleVersionsColumns.CHANGELOG, version.changelog); + values.put(ModuleVersionsColumns.CHANGELOG_IS_HTML, version.changelogIsHtml); + values.put(ModuleVersionsColumns.RELTYPE, version.relType.ordinal()); + values.put(ModuleVersionsColumns.UPLOADED, version.uploaded); + return db.insertOrThrow(ModuleVersionsColumns.TABLE_NAME, null, + values); + } + + @SuppressWarnings("UnusedReturnValue") + private static long insertMoreInfo(long moduleId, String title, String value) { + ContentValues values = new ContentValues(); + values.put(MoreInfoColumns.MODULE_ID, moduleId); + values.put(MoreInfoColumns.LABEL, title); + values.put(MoreInfoColumns.VALUE, value); + return db.insertOrThrow(MoreInfoColumns.TABLE_NAME, null, values); + } + + public static void deleteAllModules(long repoId) { + db.delete(ModulesColumns.TABLE_NAME, ModulesColumns.REPO_ID + " = ?", new String[]{Long.toString(repoId)}); + } + + public static void deleteModule(long repoId, String packageName) { + db.delete(ModulesColumns.TABLE_NAME, ModulesColumns.REPO_ID + " = ? AND " + ModulesColumns.PKGNAME + " = ?", new String[]{Long.toString(repoId), packageName}); + } + + public static Module getModuleByPackageName(String packageName) { + // The module itself + String[] projection = new String[]{ + ModulesColumns._ID, + ModulesColumns.REPO_ID, + ModulesColumns.PKGNAME, + ModulesColumns.TITLE, + ModulesColumns.SUMMARY, + ModulesColumns.DESCRIPTION, + ModulesColumns.DESCRIPTION_IS_HTML, + ModulesColumns.AUTHOR, + ModulesColumns.SUPPORT, + ModulesColumns.CREATED, + ModulesColumns.UPDATED, + }; + + String where = ModulesColumns.PREFERRED + " = 1 AND " + ModulesColumns.PKGNAME + " = ?"; + String[] whereArgs = new String[]{packageName}; + + Cursor c = db.query(ModulesColumns.TABLE_NAME, projection, where, whereArgs, null, null, null, "1"); + if (!c.moveToFirst()) { + c.close(); + return null; + } + + long moduleId = c.getLong(c.getColumnIndexOrThrow(ModulesColumns._ID)); + long repoId = c.getLong(c.getColumnIndexOrThrow(ModulesColumns.REPO_ID)); + + Module mod = new Module(RepoLoader.getInstance().getRepository(repoId)); + mod.packageName = c.getString(c.getColumnIndexOrThrow(ModulesColumns.PKGNAME)); + mod.name = c.getString(c.getColumnIndexOrThrow(ModulesColumns.TITLE)); + mod.summary = c.getString(c.getColumnIndexOrThrow(ModulesColumns.SUMMARY)); + mod.description = c.getString(c.getColumnIndexOrThrow(ModulesColumns.DESCRIPTION)); + mod.descriptionIsHtml = c.getInt(c.getColumnIndexOrThrow(ModulesColumns.DESCRIPTION_IS_HTML)) > 0; + mod.author = c.getString(c.getColumnIndexOrThrow(ModulesColumns.AUTHOR)); + mod.support = c.getString(c.getColumnIndexOrThrow(ModulesColumns.SUPPORT)); + mod.created = c.getLong(c.getColumnIndexOrThrow(ModulesColumns.CREATED)); + mod.updated = c.getLong(c.getColumnIndexOrThrow(ModulesColumns.UPDATED)); + + c.close(); + + // Versions + projection = new String[]{ + ModuleVersionsColumns.NAME, + ModuleVersionsColumns.CODE, ModuleVersionsColumns.DOWNLOAD_LINK, + ModuleVersionsColumns.MD5SUM, ModuleVersionsColumns.CHANGELOG, + ModuleVersionsColumns.CHANGELOG_IS_HTML, + ModuleVersionsColumns.RELTYPE, + ModuleVersionsColumns.UPLOADED, + }; + + where = ModuleVersionsColumns.MODULE_ID + " = ?"; + whereArgs = new String[]{Long.toString(moduleId)}; + + c = db.query(ModuleVersionsColumns.TABLE_NAME, projection, where, whereArgs, null, null, null); + while (c.moveToNext()) { + ModuleVersion version = new ModuleVersion(mod); + version.name = c.getString(c.getColumnIndexOrThrow(ModuleVersionsColumns.NAME)); + version.code = c.getInt(c.getColumnIndexOrThrow(ModuleVersionsColumns.CODE)); + version.downloadLink = c.getString(c.getColumnIndexOrThrow(ModuleVersionsColumns.DOWNLOAD_LINK)); + version.md5sum = c.getString(c.getColumnIndexOrThrow(ModuleVersionsColumns.MD5SUM)); + version.changelog = c.getString(c.getColumnIndexOrThrow(ModuleVersionsColumns.CHANGELOG)); + version.changelogIsHtml = c.getInt(c.getColumnIndexOrThrow(ModuleVersionsColumns.CHANGELOG_IS_HTML)) > 0; + version.relType = ReleaseType.fromOrdinal(c.getInt(c.getColumnIndexOrThrow(ModuleVersionsColumns.RELTYPE))); + version.uploaded = c.getLong(c.getColumnIndexOrThrow(ModuleVersionsColumns.UPLOADED)); + mod.versions.add(version); + } + c.close(); + + // MoreInfo + projection = new String[]{ + MoreInfoColumns.LABEL, + MoreInfoColumns.VALUE, + }; + + where = MoreInfoColumns.MODULE_ID + " = ?"; + whereArgs = new String[]{Long.toString(moduleId)}; + + c = db.query(MoreInfoColumns.TABLE_NAME, projection, where, whereArgs, null, null, MoreInfoColumns._ID); + while (c.moveToNext()) { + String label = c.getString(c.getColumnIndexOrThrow(MoreInfoColumns.LABEL)); + String value = c.getString(c.getColumnIndexOrThrow(MoreInfoColumns.VALUE)); + mod.moreInfo.add(new Pair<>(label, value)); + } + c.close(); + + return mod; + } + + public static String getModuleSupport(String packageName) { + return getString(ModulesColumns.TABLE_NAME, ModulesColumns.PKGNAME, packageName, ModulesColumns.SUPPORT); + } + + public static void updateModuleLatestVersion(String packageName) { + int maxShownReleaseType = RepoLoader.getInstance().getMaxShownReleaseType(packageName).ordinal(); + db.execSQL("UPDATE " + ModulesColumns.TABLE_NAME + + " SET " + ModulesColumns.LATEST_VERSION + + " = (SELECT " + ModuleVersionsColumns._ID + " FROM " + ModuleVersionsColumns.TABLE_NAME + " AS v" + + " WHERE v." + ModuleVersionsColumns.MODULE_ID + + " = " + ModulesColumns.TABLE_NAME + "." + ModulesColumns._ID + + " AND reltype <= ? LIMIT 1)" + + " WHERE " + ModulesColumns.PKGNAME + " = ?", + new Object[]{maxShownReleaseType, packageName}); + } + + public static void updateAllModulesLatestVersion() { + db.beginTransaction(); + try { + String[] projection = new String[]{ModulesColumns.PKGNAME}; + Cursor c = db.query(true, ModulesColumns.TABLE_NAME, projection, null, null, null, null, null, null); + while (c.moveToNext()) { + updateModuleLatestVersion(c.getString(0)); + } + c.close(); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + @SuppressWarnings("UnusedReturnValue") + public static long insertInstalledModule(InstalledModule installed) { + ContentValues values = new ContentValues(); + values.put(InstalledModulesColumns.PKGNAME, installed.packageName); + values.put(InstalledModulesColumns.VERSION_CODE, installed.versionCode); + values.put(InstalledModulesColumns.VERSION_NAME, installed.versionName); + return db.insertOrThrow(InstalledModulesColumns.TABLE_NAME, null, values); + } + + public static void deleteInstalledModule(String packageName) { + db.delete(InstalledModulesColumns.TABLE_NAME, InstalledModulesColumns.PKGNAME + " = ?", new String[]{packageName}); + } + + public static void deleteAllInstalledModules() { + db.delete(InstalledModulesColumns.TABLE_NAME, null, null); + } + + public static Cursor queryModuleOverview(int sortingOrder, + CharSequence filterText) { + // Columns + String[] projection = new String[]{ + "m." + ModulesColumns._ID, + "m." + ModulesColumns.PKGNAME, + "m." + ModulesColumns.TITLE, + "m." + ModulesColumns.SUMMARY, + "m." + ModulesColumns.CREATED, + "m." + ModulesColumns.UPDATED, + + "v." + ModuleVersionsColumns.NAME + " AS " + OverviewColumns.LATEST_VERSION, + "i." + InstalledModulesColumns.VERSION_NAME + " AS " + OverviewColumns.INSTALLED_VERSION, + + "(CASE WHEN m." + ModulesColumns.PKGNAME + " = '" + ModuleUtil.getInstance().getFrameworkPackageName() + + "' THEN 1 ELSE 0 END) AS " + OverviewColumns.IS_FRAMEWORK, + + "(CASE WHEN i." + InstalledModulesColumns.VERSION_NAME + " IS NOT NULL" + + " THEN 1 ELSE 0 END) AS " + OverviewColumns.IS_INSTALLED, + + "(CASE WHEN v." + ModuleVersionsColumns.CODE + " > " + InstalledModulesColumns.VERSION_CODE + + " THEN 1 ELSE 0 END) AS " + OverviewColumns.HAS_UPDATE, + }; + + // Conditions + StringBuilder where = new StringBuilder(ModulesColumns.PREFERRED + " = 1"); + String[] whereArgs = null; + if (!TextUtils.isEmpty(filterText)) { + where.append(" AND (m." + ModulesColumns.TITLE + " LIKE ?" + " OR m." + ModulesColumns.SUMMARY + " LIKE ?" + " OR m." + ModulesColumns.DESCRIPTION + " LIKE ?" + " OR m." + ModulesColumns.AUTHOR + " LIKE ?)"); + String filterTextArg = "%" + filterText + "%"; + whereArgs = new String[]{filterTextArg, filterTextArg, filterTextArg, filterTextArg}; + } else { + SharedPreferences prefs = context.getSharedPreferences(BuildConfig.APPLICATION_ID + "_preferences", MODE_PRIVATE); + + if (prefs.getBoolean("ignore_chinese", false)) { + for (char ch : "的一是不了人我在有他这为中设微模块淘".toCharArray()) { + where.append(" AND NOT (m." + ModulesColumns.TITLE + " LIKE '%").append(ch).append("%'").append(" OR m.").append(ModulesColumns.SUMMARY).append(" LIKE '%").append(ch).append("%'").append(" OR m.").append(ModulesColumns.DESCRIPTION).append(" LIKE '%").append(ch).append("%')"); + } + } + } + + // Sorting order + StringBuilder sbOrder = new StringBuilder(); + if (sortingOrder == SORT_CREATED) { + sbOrder.append(OverviewColumns.CREATED); + sbOrder.append(" DESC,"); + } else if (sortingOrder == SORT_UPDATED) { + sbOrder.append(OverviewColumns.UPDATED); + sbOrder.append(" DESC,"); + } + sbOrder.append(OverviewColumns.IS_FRAMEWORK); + sbOrder.append(" DESC, "); + sbOrder.append(OverviewColumns.HAS_UPDATE); + sbOrder.append(" DESC, "); + sbOrder.append(OverviewColumns.IS_INSTALLED); + sbOrder.append(" DESC, "); + sbOrder.append("m."); + sbOrder.append(OverviewColumns.TITLE); + sbOrder.append(" COLLATE NOCASE, "); + sbOrder.append("m."); + sbOrder.append(OverviewColumns.PKGNAME); + + // Query + Cursor c = db.query( + ModulesColumns.TABLE_NAME + " AS m" + + " LEFT JOIN " + ModuleVersionsColumns.TABLE_NAME + " AS v" + + " ON v." + ModuleVersionsColumns._ID + " = m." + ModulesColumns.LATEST_VERSION + + " LEFT JOIN " + InstalledModulesColumns.TABLE_NAME + " AS i" + + " ON i." + InstalledModulesColumns.PKGNAME + " = m." + ModulesColumns.PKGNAME, + projection, where.toString(), whereArgs, null, null, sbOrder.toString()); + + // Cache column indexes + OverviewColumnsIndexes.fillFromCursor(c); + + return c; + } + + public static String getFrameworkUpdateVersion() { + return getFirstUpdate(true); + } + + public static boolean hasModuleUpdates() { + return getFirstUpdate(false) != null; + } + + private static String getFirstUpdate(boolean framework) { + String[] projection = new String[]{InstalledModulesUpdatesColumns.LATEST_NAME}; + String where = ModulesColumns.PKGNAME + (framework ? " = ?" : " != ?"); + String[] whereArgs = new String[]{ModuleUtil.getInstance().getFrameworkPackageName()}; + Cursor c = db.query(InstalledModulesUpdatesColumns.VIEW_NAME, projection, where, whereArgs, null, null, null, "1"); + String latestVersion = null; + if (c.moveToFirst()) + latestVersion = c.getString(c.getColumnIndexOrThrow(InstalledModulesUpdatesColumns.LATEST_NAME)); + c.close(); + return latestVersion; + } + + @Override + public void onCreate(SQLiteDatabase db) { + db.execSQL(RepoDbDefinitions.SQL_CREATE_TABLE_REPOSITORIES); + db.execSQL(RepoDbDefinitions.SQL_CREATE_TABLE_MODULES); + db.execSQL(RepoDbDefinitions.SQL_CREATE_TABLE_MODULE_VERSIONS); + db.execSQL(RepoDbDefinitions.SQL_CREATE_INDEX_MODULE_VERSIONS_MODULE_ID); + db.execSQL(RepoDbDefinitions.SQL_CREATE_TABLE_MORE_INFO); + + RepoLoader.getInstance().clear(false); + } + + private void createTempTables(SQLiteDatabase db) { + db.execSQL(RepoDbDefinitions.SQL_CREATE_TEMP_TABLE_INSTALLED_MODULES); + db.execSQL(RepoDbDefinitions.SQL_CREATE_TEMP_VIEW_INSTALLED_MODULES_UPDATES); + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + // This is only a cache, so simply drop & recreate the tables + db.execSQL("DROP TABLE IF EXISTS " + RepositoriesColumns.TABLE_NAME); + db.execSQL("DROP TABLE IF EXISTS " + ModulesColumns.TABLE_NAME); + db.execSQL("DROP TABLE IF EXISTS " + ModuleVersionsColumns.TABLE_NAME); + db.execSQL("DROP TABLE IF EXISTS " + MoreInfoColumns.TABLE_NAME); + + db.execSQL("DROP TABLE IF EXISTS " + InstalledModulesColumns.TABLE_NAME); + db.execSQL("DROP VIEW IF EXISTS " + InstalledModulesUpdatesColumns.VIEW_NAME); + + onCreate(db); + } + + @Override + public void onDowngrade(SQLiteDatabase db, int oldVersion, int newVersion) { + onUpgrade(db, oldVersion, newVersion); + } + + public static class RowNotFoundException extends RuntimeException { + private static final long serialVersionUID = -396324186622439535L; + + RowNotFoundException(String reason) { + super(reason); + } + } +} diff --git a/app/src/main/java/org/meowcat/edxposed/manager/repo/RepoDbDefinitions.java b/app/src/main/java/org/meowcat/edxposed/manager/repo/RepoDbDefinitions.java new file mode 100644 index 00000000..666b5513 --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/repo/RepoDbDefinitions.java @@ -0,0 +1,216 @@ +package org.meowcat.edxposed.manager.repo; + +import android.database.Cursor; +import android.provider.BaseColumns; + +public class RepoDbDefinitions { + static final int DATABASE_VERSION = 4; + static final String DATABASE_NAME = "repo_cache.db"; + static final String SQL_CREATE_TABLE_REPOSITORIES = "CREATE TABLE " + + RepositoriesColumns.TABLE_NAME + " (" + RepositoriesColumns._ID + + " INTEGER PRIMARY KEY AUTOINCREMENT," + RepositoriesColumns.URL + + " TEXT NOT NULL, " + RepositoriesColumns.TITLE + " TEXT, " + + RepositoriesColumns.PARTIAL_URL + " TEXT, " + + RepositoriesColumns.VERSION + " TEXT, " + "UNIQUE (" + + RepositoriesColumns.URL + ") ON CONFLICT REPLACE)"; + static final String SQL_CREATE_TABLE_MODULES = "CREATE TABLE " + + ModulesColumns.TABLE_NAME + " (" + ModulesColumns._ID + + " INTEGER PRIMARY KEY AUTOINCREMENT," + + + ModulesColumns.REPO_ID + " INTEGER NOT NULL REFERENCES " + + RepositoriesColumns.TABLE_NAME + " ON DELETE CASCADE, " + + ModulesColumns.PKGNAME + " TEXT NOT NULL, " + ModulesColumns.TITLE + + " TEXT NOT NULL, " + ModulesColumns.SUMMARY + " TEXT, " + + ModulesColumns.DESCRIPTION + " TEXT, " + + ModulesColumns.DESCRIPTION_IS_HTML + " INTEGER DEFAULT 0, " + + ModulesColumns.AUTHOR + " TEXT, " + ModulesColumns.SUPPORT + + " TEXT, " + ModulesColumns.CREATED + " INTEGER DEFAULT -1, " + + ModulesColumns.UPDATED + " INTEGER DEFAULT -1, " + + ModulesColumns.PREFERRED + " INTEGER DEFAULT 1, " + + ModulesColumns.LATEST_VERSION + " INTEGER REFERENCES " + + ModuleVersionsColumns.TABLE_NAME + ", " + "UNIQUE (" + + ModulesColumns.PKGNAME + ", " + ModulesColumns.REPO_ID + + ") ON CONFLICT REPLACE)"; + static final String SQL_CREATE_TABLE_MODULE_VERSIONS = "CREATE TABLE " + + ModuleVersionsColumns.TABLE_NAME + " (" + + ModuleVersionsColumns._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," + + ModuleVersionsColumns.MODULE_ID + " INTEGER NOT NULL REFERENCES " + + ModulesColumns.TABLE_NAME + " ON DELETE CASCADE, " + + ModuleVersionsColumns.NAME + " TEXT NOT NULL, " + + ModuleVersionsColumns.CODE + " INTEGER NOT NULL, " + + ModuleVersionsColumns.DOWNLOAD_LINK + " TEXT, " + + ModuleVersionsColumns.MD5SUM + " TEXT, " + + ModuleVersionsColumns.CHANGELOG + " TEXT, " + + ModuleVersionsColumns.CHANGELOG_IS_HTML + " INTEGER DEFAULT 0, " + + ModuleVersionsColumns.RELTYPE + " INTEGER DEFAULT 0, " + + ModuleVersionsColumns.UPLOADED + " INTEGER DEFAULT -1)"; + static final String SQL_CREATE_INDEX_MODULE_VERSIONS_MODULE_ID = "CREATE INDEX " + + ModuleVersionsColumns.IDX_MODULE_ID + " ON " + + ModuleVersionsColumns.TABLE_NAME + " (" + + ModuleVersionsColumns.MODULE_ID + ")"; + static final String SQL_CREATE_TABLE_MORE_INFO = "CREATE TABLE " + + MoreInfoColumns.TABLE_NAME + " (" + MoreInfoColumns._ID + + " INTEGER PRIMARY KEY AUTOINCREMENT," + MoreInfoColumns.MODULE_ID + + " INTEGER NOT NULL REFERENCES " + ModulesColumns.TABLE_NAME + + " ON DELETE CASCADE, " + MoreInfoColumns.LABEL + + " TEXT NOT NULL, " + MoreInfoColumns.VALUE + " TEXT)"; + static final String SQL_CREATE_TEMP_TABLE_INSTALLED_MODULES = "CREATE TEMP TABLE " + + InstalledModulesColumns.TABLE_NAME + " (" + + InstalledModulesColumns.PKGNAME + + " TEXT PRIMARY KEY ON CONFLICT REPLACE, " + + InstalledModulesColumns.VERSION_CODE + " INTEGER NOT NULL, " + + InstalledModulesColumns.VERSION_NAME + " TEXT)"; + static final String SQL_CREATE_TEMP_VIEW_INSTALLED_MODULES_UPDATES = "CREATE TEMP VIEW " + + InstalledModulesUpdatesColumns.VIEW_NAME + " AS SELECT " + "m." + + ModulesColumns._ID + " AS " + + InstalledModulesUpdatesColumns.MODULE_ID + ", " + "i." + + InstalledModulesColumns.PKGNAME + " AS " + + InstalledModulesUpdatesColumns.PKGNAME + ", " + "i." + + InstalledModulesColumns.VERSION_CODE + " AS " + + InstalledModulesUpdatesColumns.INSTALLED_CODE + ", " + "i." + + InstalledModulesColumns.VERSION_NAME + " AS " + + InstalledModulesUpdatesColumns.INSTALLED_NAME + ", " + "v." + + ModuleVersionsColumns._ID + " AS " + + InstalledModulesUpdatesColumns.LATEST_ID + ", " + "v." + + ModuleVersionsColumns.CODE + " AS " + + InstalledModulesUpdatesColumns.LATEST_CODE + ", " + "v." + + ModuleVersionsColumns.NAME + " AS " + + InstalledModulesUpdatesColumns.LATEST_NAME + " FROM " + + InstalledModulesColumns.TABLE_NAME + " AS i" + " INNER JOIN " + + ModulesColumns.TABLE_NAME + " AS m" + " ON m." + + ModulesColumns.PKGNAME + " = i." + InstalledModulesColumns.PKGNAME + + " INNER JOIN " + ModuleVersionsColumns.TABLE_NAME + " AS v" + + " ON v." + ModuleVersionsColumns._ID + " = m." + + ModulesColumns.LATEST_VERSION + " WHERE " + + InstalledModulesUpdatesColumns.LATEST_CODE + " > " + + InstalledModulesUpdatesColumns.INSTALLED_CODE + " AND " + + ModulesColumns.PREFERRED + " = 1"; + + ////////////////////////////////////////////////////////////////////////// + public interface RepositoriesColumns extends BaseColumns { + String TABLE_NAME = "repositories"; + + String URL = "url"; + String TITLE = "title"; + String PARTIAL_URL = "partial_url"; + String VERSION = "version"; + } + + ////////////////////////////////////////////////////////////////////////// + public interface ModulesColumns extends BaseColumns { + String TABLE_NAME = "modules"; + + String REPO_ID = "repo_id"; + String PKGNAME = "pkgname"; + String TITLE = "title"; + String SUMMARY = "summary"; + String DESCRIPTION = "description"; + String DESCRIPTION_IS_HTML = "description_is_html"; + String AUTHOR = "author"; + String SUPPORT = "support"; + String CREATED = "created"; + String UPDATED = "updated"; + + String PREFERRED = "preferred"; + String LATEST_VERSION = "latest_version_id"; + } + + ////////////////////////////////////////////////////////////////////////// + public interface ModuleVersionsColumns extends BaseColumns { + String TABLE_NAME = "module_versions"; + String IDX_MODULE_ID = "module_versions_module_id_idx"; + + String MODULE_ID = "module_id"; + String NAME = "name"; + String CODE = "code"; + String DOWNLOAD_LINK = "download_link"; + String MD5SUM = "md5sum"; + String CHANGELOG = "changelog"; + String CHANGELOG_IS_HTML = "changelog_is_html"; + String RELTYPE = "reltype"; + String UPLOADED = "uploaded"; + } + + ////////////////////////////////////////////////////////////////////////// + public interface MoreInfoColumns extends BaseColumns { + String TABLE_NAME = "more_info"; + + String MODULE_ID = "module_id"; + String LABEL = "label"; + String VALUE = "value"; + } + + ////////////////////////////////////////////////////////////////////////// + public interface InstalledModulesColumns { + String TABLE_NAME = "installed_modules"; + + String PKGNAME = "pkgname"; + String VERSION_CODE = "version_code"; + String VERSION_NAME = "version_name"; + } + + ////////////////////////////////////////////////////////////////////////// + public interface InstalledModulesUpdatesColumns { + String VIEW_NAME = InstalledModulesColumns.TABLE_NAME + "_updates"; + + String MODULE_ID = "module_id"; + String PKGNAME = "pkgname"; + String INSTALLED_CODE = "installed_code"; + String INSTALLED_NAME = "installed_name"; + String LATEST_ID = "latest_id"; + String LATEST_CODE = "latest_code"; + String LATEST_NAME = "latest_name"; + } + + ////////////////////////////////////////////////////////////////////////// + public interface OverviewColumns extends BaseColumns { + String PKGNAME = ModulesColumns.PKGNAME; + String TITLE = ModulesColumns.TITLE; + String SUMMARY = ModulesColumns.SUMMARY; + String CREATED = ModulesColumns.CREATED; + String UPDATED = ModulesColumns.UPDATED; + + String INSTALLED_VERSION = "installed_version"; + String LATEST_VERSION = "latest_version"; + + String IS_FRAMEWORK = "is_framework"; + String IS_INSTALLED = "is_installed"; + String HAS_UPDATE = "has_update"; + } + + public static class OverviewColumnsIndexes { + public static int PKGNAME = -1; + public static int TITLE = -1; + public static int SUMMARY = -1; + public static int CREATED = -1; + public static int UPDATED = -1; + public static int INSTALLED_VERSION = -1; + public static int LATEST_VERSION = -1; + public static int IS_FRAMEWORK = -1; + public static int IS_INSTALLED = -1; + public static int HAS_UPDATE = -1; + private static boolean isFilled = false; + + private OverviewColumnsIndexes() { + } + + static void fillFromCursor(Cursor cursor) { + if (isFilled || cursor == null) + return; + + PKGNAME = cursor.getColumnIndexOrThrow(OverviewColumns.PKGNAME); + TITLE = cursor.getColumnIndexOrThrow(OverviewColumns.TITLE); + SUMMARY = cursor.getColumnIndexOrThrow(OverviewColumns.SUMMARY); + CREATED = cursor.getColumnIndexOrThrow(OverviewColumns.CREATED); + UPDATED = cursor.getColumnIndexOrThrow(OverviewColumns.UPDATED); + INSTALLED_VERSION = cursor.getColumnIndexOrThrow(OverviewColumns.INSTALLED_VERSION); + LATEST_VERSION = cursor.getColumnIndexOrThrow(OverviewColumns.LATEST_VERSION); + INSTALLED_VERSION = cursor.getColumnIndexOrThrow(OverviewColumns.INSTALLED_VERSION); + IS_FRAMEWORK = cursor.getColumnIndexOrThrow(OverviewColumns.IS_FRAMEWORK); + IS_INSTALLED = cursor.getColumnIndexOrThrow(OverviewColumns.IS_INSTALLED); + HAS_UPDATE = cursor.getColumnIndexOrThrow(OverviewColumns.HAS_UPDATE); + + isFilled = true; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/meowcat/edxposed/manager/repo/RepoParser.java b/app/src/main/java/org/meowcat/edxposed/manager/repo/RepoParser.java new file mode 100644 index 00000000..2cb16b2c --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/repo/RepoParser.java @@ -0,0 +1,320 @@ +package org.meowcat.edxposed.manager.repo; + +import android.app.Activity; +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Point; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.LevelListDrawable; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.util.Log; +import android.util.Pair; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.text.HtmlCompat; + +import com.bumptech.glide.Glide; +import com.bumptech.glide.request.target.CustomTarget; +import com.bumptech.glide.request.transition.Transition; + +import org.meowcat.edxposed.manager.App; +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; +import org.xmlpull.v1.XmlPullParserFactory; + +import java.io.IOException; +import java.io.InputStream; + +public class RepoParser { + public final static String TAG = App.TAG; + private final static String NS = null; + private final XmlPullParser parser; + private final RepoParserCallback callback; + private boolean mRepoEventTriggered = false; + + private RepoParser(InputStream is, RepoParserCallback callback) throws XmlPullParserException, IOException { + XmlPullParserFactory factory = XmlPullParserFactory.newInstance(); + parser = factory.newPullParser(); + parser.setInput(is, null); + parser.nextTag(); + this.callback = callback; + } + + public static void parse(InputStream is, RepoParserCallback callback) throws XmlPullParserException, IOException { + new RepoParser(is, callback).readRepo(); + } + + public static Spanned parseSimpleHtml(final Context context, String source, final TextView textView) { + source = source.replaceAll("
  • ", "\t\u0095 "); + source = source.replaceAll("
  • ", "
    "); + Spanned html = HtmlCompat.fromHtml(source, HtmlCompat.FROM_HTML_MODE_LEGACY, source1 -> { + + LevelListDrawable levelListDrawable = new LevelListDrawable(); + final Drawable[] drawable = new Drawable[1]; + Glide.with(context).asBitmap().load(source1).into(new CustomTarget() { + @Override + public void onResourceReady(@NonNull Bitmap bitmap, @Nullable Transition transition) { + try { + drawable[0] = new BitmapDrawable(context.getResources(), bitmap); + Point size = new Point(); + ((Activity) context).getWindowManager().getDefaultDisplay().getSize(size); + int multiplier = size.x / bitmap.getWidth(); + if (multiplier <= 0) multiplier = 1; + levelListDrawable.addLevel(1, 1, drawable[0]); + levelListDrawable.setBounds(0, 0, bitmap.getWidth() * multiplier, bitmap.getHeight() * multiplier); + levelListDrawable.setLevel(1); + textView.setText(textView.getText()); + } catch (Exception ignored) { /* Like a null bitmap, etc. */ + } + } + + @Override + public void onLoadCleared(@Nullable Drawable placeholder) { + } + }); + return drawable[0]; + }, null); + + // trim trailing newlines + int len = html.length(); + int end = len; + for (int i = len - 1; i >= 0; i--) { + if (html.charAt(i) != '\n') + break; + end = i; + } + + if (end == len) + return html; + else + return new SpannableStringBuilder(html, 0, end); + } + + private void readRepo() throws XmlPullParserException, IOException { + parser.require(XmlPullParser.START_TAG, NS, "repository"); + Repository repository = new Repository(); + repository.isPartial = "true".equals(parser.getAttributeValue(NS, "partial")); + repository.partialUrl = parser.getAttributeValue(NS, "partial-url"); + repository.version = parser.getAttributeValue(NS, "version"); + + while (parser.nextTag() == XmlPullParser.START_TAG) { + String tagName = parser.getName(); + switch (tagName) { + case "name": + repository.name = parser.nextText(); + break; + case "module": + triggerRepoEvent(repository); + Module module = readModule(repository); + if (module != null) + callback.onNewModule(module); + break; + case "remove-module": + triggerRepoEvent(repository); + String packageName = readRemoveModule(); + if (packageName != null) + callback.onRemoveModule(packageName); + break; + default: + //skip(true); + skip(false); + break; + } + } + + callback.onCompleted(repository); + } + + private void triggerRepoEvent(Repository repository) { + if (mRepoEventTriggered) + return; + + callback.onRepositoryMetadata(repository); + mRepoEventTriggered = true; + } + + private Module readModule(Repository repository) throws XmlPullParserException, IOException { + parser.require(XmlPullParser.START_TAG, NS, "module"); + final int startDepth = parser.getDepth(); + + Module module = new Module(repository); + module.packageName = parser.getAttributeValue(NS, "package"); + if (module.packageName == null) { + logError("no package name defined"); + leave(startDepth); + return null; + } + + module.created = parseTimestamp("created"); + module.updated = parseTimestamp("updated"); + + while (parser.nextTag() == XmlPullParser.START_TAG) { + String tagName = parser.getName(); + switch (tagName) { + case "name": + module.name = parser.nextText(); + break; + case "author": + module.author = parser.nextText(); + break; + case "summary": + module.summary = parser.nextText(); + break; + case "description": + String isHtml = parser.getAttributeValue(NS, "html"); + if (isHtml != null && isHtml.equals("true")) + module.descriptionIsHtml = true; + module.description = parser.nextText(); + break; + case "screenshot": + module.screenshots.add(parser.nextText()); + break; + case "moreinfo": + String label = parser.getAttributeValue(NS, "label"); + String role = parser.getAttributeValue(NS, "role"); + String value = parser.nextText(); + module.moreInfo.add(new Pair<>(label, value)); + + if (role != null && role.contains("support")) + module.support = value; + break; + case "version": + ModuleVersion version = readModuleVersion(module); + if (version != null) + module.versions.add(version); + break; + default: + //skip(true); + skip(false); + break; + } + } + + if (module.name == null) { + logError("packages need at least a name"); + return null; + } + + return module; + } + + private long parseTimestamp(String attName) { + String value = parser.getAttributeValue(NS, attName); + if (value == null) + return -1; + try { + return Long.parseLong(value) * 1000L; + } catch (NumberFormatException ex) { + return -1; + } + } + + private ModuleVersion readModuleVersion(Module module) throws XmlPullParserException, IOException { + parser.require(XmlPullParser.START_TAG, NS, "version"); + final int startDepth = parser.getDepth(); + ModuleVersion version = new ModuleVersion(module); + + version.uploaded = parseTimestamp("uploaded"); + + while (parser.nextTag() == XmlPullParser.START_TAG) { + String tagName = parser.getName(); + switch (tagName) { + case "name": + version.name = parser.nextText(); + break; + case "code": + try { + version.code = Integer.parseInt(parser.nextText()); + } catch (NumberFormatException nfe) { + logError(nfe.getMessage()); + leave(startDepth); + return null; + } + break; + case "reltype": + version.relType = ReleaseType.fromString(parser.nextText()); + break; + case "download": + version.downloadLink = parser.nextText(); + break; + case "md5sum": + version.md5sum = parser.nextText(); + break; + case "changelog": + String isHtml = parser.getAttributeValue(NS, "html"); + if (isHtml != null && isHtml.equals("true")) + version.changelogIsHtml = true; + version.changelog = parser.nextText(); + break; + case "branch": + // obsolete +// skip(false); +// break; + default: + skip(false); + //skip(true); + break; + } + } + + return version; + } + + private String readRemoveModule() throws XmlPullParserException, IOException { + parser.require(XmlPullParser.START_TAG, NS, "remove-module"); + final int startDepth = parser.getDepth(); + + String packageName = parser.getAttributeValue(NS, "package"); + if (packageName == null) { + logError("no package name defined"); + leave(startDepth); + return null; + } + + return packageName; + } + + private void skip(@SuppressWarnings("SameParameterValue") boolean showWarning) throws XmlPullParserException, IOException { + parser.require(XmlPullParser.START_TAG, null, null); + if (showWarning) + Log.w(TAG, "skipping unknown/erronous tag: " + parser.getPositionDescription()); + int level = 1; + while (level > 0) { + int eventType = parser.next(); + if (eventType == XmlPullParser.END_TAG) { + level--; + } else if (eventType == XmlPullParser.START_TAG) { + level++; + } + } + } + + private void leave(int targetDepth) throws XmlPullParserException, IOException { + Log.w(TAG, "leaving up to level " + targetDepth + ": " + parser.getPositionDescription()); + while (parser.getDepth() > targetDepth) { + //noinspection StatementWithEmptyBody + while (parser.next() != XmlPullParser.END_TAG) { + // do nothing + } + } + } + + private void logError(String error) { + Log.e(TAG, parser.getPositionDescription() + ": " + error); + } + + public interface RepoParserCallback { + void onRepositoryMetadata(Repository repository); + + void onNewModule(Module module); + + void onRemoveModule(String packageName); + + void onCompleted(Repository repository); + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/meowcat/edxposed/manager/repo/Repository.java b/app/src/main/java/org/meowcat/edxposed/manager/repo/Repository.java new file mode 100644 index 00000000..f4d851ff --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/repo/Repository.java @@ -0,0 +1,12 @@ +package org.meowcat.edxposed.manager.repo; + +public class Repository { + public String name; + public String url; + public boolean isPartial = false; + public String partialUrl; + public String version; + + Repository() { + } +} diff --git a/app/src/main/java/org/meowcat/edxposed/manager/ui/activity/AboutActivity.java b/app/src/main/java/org/meowcat/edxposed/manager/ui/activity/AboutActivity.java new file mode 100644 index 00000000..a74581dc --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/ui/activity/AboutActivity.java @@ -0,0 +1,83 @@ +package org.meowcat.edxposed.manager.ui.activity; + +import android.content.Intent; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.view.View; + +import androidx.appcompat.app.ActionBar; +import androidx.core.text.HtmlCompat; + +import com.bumptech.glide.Glide; +import com.google.android.gms.oss.licenses.OssLicensesMenuActivity; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import org.meowcat.edxposed.manager.BuildConfig; +import org.meowcat.edxposed.manager.R; +import org.meowcat.edxposed.manager.databinding.ActivityAboutBinding; +import org.meowcat.edxposed.manager.util.GlideHelper; +import org.meowcat.edxposed.manager.util.NavUtil; + +public class AboutActivity extends BaseActivity { + ActivityAboutBinding binding; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + binding = ActivityAboutBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + setSupportActionBar(binding.toolbar); + binding.toolbar.setNavigationOnClickListener(view -> finish()); + ActionBar bar = getSupportActionBar(); + if (bar != null) { + bar.setDisplayHomeAsUpEnabled(true); + } + setupWindowInsets(binding.snackbar, null); + + String packageName = getPackageName(); + String translator = getResources().getString(R.string.translator); + + SharedPreferences prefs = getSharedPreferences(packageName + "_preferences", MODE_PRIVATE); + + final String changes = prefs.getString("changelog", null); + + if (changes == null) { + binding.changelogView.setVisibility(View.GONE); + } else { + binding.changelogView.setOnClickListener(v1 -> new MaterialAlertDialogBuilder(this) + .setTitle(R.string.changes) + .setMessage(HtmlCompat.fromHtml(changes, HtmlCompat.FROM_HTML_MODE_LEGACY)) + .setPositiveButton(android.R.string.ok, null).show()); + } + binding.appVersion.setText(BuildConfig.VERSION_NAME); + + binding.licensesView.setOnClickListener(v12 -> startActivity(new Intent(this, OssLicensesMenuActivity.class))); + + binding.tabSupportModuleDescription.setText(getString(R.string.support_modules_description, + getString(R.string.module_support))); + + setupView(binding.installerSupportView, R.string.support_material_xda); + setupView(binding.faqView, R.string.support_faq_url); + setupView(binding.tgGroupView, R.string.group_telegram_link); + setupView(binding.qqGroupView, R.string.group_qq_link); + setupView(binding.donateView, R.string.support_donate_url); + setupView(binding.sourceCodeView, R.string.about_source); + setupView(binding.tgChannelView, R.string.group_telegram_channel_link); + + if (translator.isEmpty()) { + binding.translatorsView.setVisibility(View.GONE); + } + + Glide.with(binding.appIcon) + .load(GlideHelper.wrapApplicationInfoForIconLoader(getApplicationInfo())) + .into(binding.appIcon); + } + + void setupView(View v, final int url) { + v.setOnClickListener(v1 -> NavUtil.startURL(this, getString(url))); + } + + public void openLink(View view) { + NavUtil.startURL(this, view.getTag().toString()); + } +} diff --git a/app/src/main/java/org/meowcat/edxposed/manager/ui/activity/BaseActivity.java b/app/src/main/java/org/meowcat/edxposed/manager/ui/activity/BaseActivity.java new file mode 100644 index 00000000..60105cf1 --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/ui/activity/BaseActivity.java @@ -0,0 +1,293 @@ +package org.meowcat.edxposed.manager.ui.activity; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.DialogInterface; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.content.res.TypedArray; +import android.os.Bundle; +import android.os.Looper; +import android.text.TextUtils; +import android.util.DisplayMetrics; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StyleRes; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.app.AppCompatDelegate; +import androidx.core.content.ContextCompat; +import androidx.core.view.ViewCompat; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.topjohnwu.superuser.Shell; + +import org.meowcat.edxposed.manager.App; +import org.meowcat.edxposed.manager.R; +import org.meowcat.edxposed.manager.util.CustomThemeColor; +import org.meowcat.edxposed.manager.util.CustomThemeColors; +import org.meowcat.edxposed.manager.util.NavUtil; + +import java.util.LinkedList; +import java.util.List; +import java.util.Objects; + +@SuppressLint("Registered") +public class BaseActivity extends AppCompatActivity { + + private static final String THEME_DEFAULT = "DEFAULT"; + private static final String THEME_BLACK = "BLACK"; + private String theme; + + public static boolean isBlackNightTheme() { + return App.getPreferences().getBoolean("black_dark_theme", false) || App.getPreferences().getBoolean("md2", false); + } + + public static String getTheme(Context context) { + if (isBlackNightTheme() + && isNightMode(context.getResources().getConfiguration())) + return THEME_BLACK; + + return THEME_DEFAULT; + } + + public static boolean isNightMode(Configuration configuration) { + return (configuration.uiMode & Configuration.UI_MODE_NIGHT_YES) > 0; + } + + @Override + public void setContentView(View view) { + FrameLayout frameLayout = new FrameLayout(this); + frameLayout.setLayoutParams(new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + DisplayMetrics displayMetrics = getResources().getDisplayMetrics(); + if (displayMetrics.widthPixels > displayMetrics.heightPixels) { + int padding = (displayMetrics.widthPixels - displayMetrics.heightPixels) / 2; + frameLayout.setPadding(padding, 0, padding, 0); + } + frameLayout.addView(view); + super.setContentView(frameLayout); + } + + @StyleRes + public int getThemeStyleRes(Context context) { + switch (getTheme(context)) { + case THEME_BLACK: + return R.style.ThemeOverlay_Black; + case THEME_DEFAULT: + default: + return R.style.ThemeOverlay; + } + } + + @StyleRes + private int getCustomTheme() { + String baseThemeName = App.getPreferences().getBoolean("colorized_action_bar", false) && !App.getPreferences().getBoolean("md2", false) ? + "ThemeOverlay.ActionBarPrimaryColor" : "ThemeOverlay"; + String customThemeName; + String primaryColorEntryName = "colorPrimary"; + for (CustomThemeColor color : CustomThemeColors.Primary.values()) { + if (App.getPreferences().getInt("primary_color", ContextCompat.getColor(this, R.color.colorPrimary)) + == ContextCompat.getColor(this, color.getResourceId())) { + primaryColorEntryName = color.getResourceEntryName(); + } + } + String accentColorEntryName = "colorAccent"; + for (CustomThemeColor color : CustomThemeColors.Accent.values()) { + if (App.getPreferences().getInt("accent_color", ContextCompat.getColor(this, R.color.colorAccent)) + == ContextCompat.getColor(this, color.getResourceId())) { + accentColorEntryName = color.getResourceEntryName(); + } + } + customThemeName = baseThemeName + "." + primaryColorEntryName + "." + accentColorEntryName; + return getResources().getIdentifier(customThemeName, "style", getPackageName()); + } + + protected void setupWindowInsets(View rootView, View secondView) { + // TODO: + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + AppCompatDelegate.setDefaultNightMode(App.getPreferences().getInt("theme", -1)); + theme = getTheme(this) + getCustomTheme() + App.getPreferences().getBoolean("md2", false); + } + + public int getThemedColor(int id) { + TypedArray typedArray = getTheme().obtainStyledAttributes(new int[]{id}); + int color = typedArray.getColor(0, 0); + typedArray.recycle(); + return color; + } + + @Override + protected void onResume() { + super.onResume(); + if (!(this instanceof MainActivity)) { + if (App.getPreferences().getBoolean("transparent_status_bar", false)) { + getWindow().setStatusBarColor(getThemedColor(R.attr.colorActionBar)); + } else { + getWindow().setStatusBarColor(getThemedColor(R.attr.colorPrimaryDark)); + } + } + if (!Objects.equals(theme, getTheme(this) + getCustomTheme() + App.getPreferences().getBoolean("md2", false))) { + recreate(); + } + } + + @Override + protected void onApplyThemeResource(Resources.Theme theme, int resid, boolean first) { + // apply real style and our custom style + if (getParent() == null) { + theme.applyStyle(resid, true); + } else { + try { + theme.setTo(getParent().getTheme()); + } catch (Exception e) { + // Empty + } + theme.applyStyle(resid, false); + } + theme.applyStyle(getCustomTheme(), true); + if (App.getPreferences().getBoolean("md2", false) && !(this instanceof MainActivity)) { + theme.applyStyle(R.style.ThemeOverlay_Md2, true); + } + if (this instanceof MainActivity) { + theme.applyStyle(R.style.ThemeOverlay_ActivityMain, true); + } + theme.applyStyle(getThemeStyleRes(this), true); + // only pass theme style to super, so styled theme will not be overwritten + super.onApplyThemeResource(theme, R.style.ThemeOverlay, first); + } + + private void areYouSure(int contentTextId, DialogInterface.OnClickListener listener) { + new MaterialAlertDialogBuilder(this).setTitle(R.string.areyousure) + .setMessage(contentTextId) + .setPositiveButton(android.R.string.yes, listener) + .setNegativeButton(android.R.string.no, null) + .show(); + } + + void softReboot() { + if (!Shell.rootAccess()) { + showAlert(getString(R.string.root_failed)); + return; + } + + List messages = new LinkedList<>(); + Shell.Result result = Shell.su("setprop ctl.restart surfaceflinger; setprop ctl.restart zygote").exec(); + if (result.getCode() != 0) { + messages.add(result.getOut().toString()); + messages.add(""); + messages.add(getString(R.string.reboot_failed)); + showAlert(TextUtils.join("\n", messages).trim()); + } + } + + void showAlert(final String result) { + if (Looper.myLooper() != Looper.getMainLooper()) { + runOnUiThread(() -> showAlert(result)); + return; + } + + new MaterialAlertDialogBuilder(this) + .setMessage(result) + .setPositiveButton(android.R.string.ok, null) + .show(); + } + + void reboot(String mode) { + if (!Shell.rootAccess()) { + showAlert(getString(R.string.root_failed)); + return; + } + + List messages = new LinkedList<>(); + + String command = "/system/bin/svc power reboot"; + if (mode != null) { + command += " " + mode; + if (mode.equals("recovery")) + // create a flag used by some kernels to boot into recovery + Shell.su("touch /cache/recovery/boot").exec(); + } + Shell.Result result = Shell.su(command).exec(); + if (result.getCode() != 0) { + messages.add(result.getOut().toString()); + messages.add(""); + messages.add(getString(R.string.reboot_failed)); + showAlert(TextUtils.join("\n", messages).trim()); + } + } + + @Override + public boolean onOptionsItemSelected(@NonNull MenuItem item) { + int itemId = item.getItemId(); + if (itemId == R.id.dexopt_all) { + areYouSure(R.string.take_while_cannot_resore, (dialog, which) -> { + new MaterialAlertDialogBuilder(this) + .setTitle(R.string.dexopt_now) + .setMessage(R.string.this_may_take_a_while) + .setCancelable(false) + .show(); + new Thread("dexopt") { + @Override + public void run() { + if (!Shell.rootAccess()) { + dialog.dismiss(); + NavUtil.showMessage(BaseActivity.this, getString(R.string.root_failed)); + return; + } + + Shell.su("cmd package bg-dexopt-job").exec(); + + dialog.dismiss(); + App.runOnUiThread(() -> Toast.makeText(BaseActivity.this, R.string.done, Toast.LENGTH_LONG).show()); + } + }.start(); + }); + } else if (itemId == R.id.speed_all) { + areYouSure(R.string.take_while_cannot_resore, (dialog, which) -> { + new MaterialAlertDialogBuilder(this) + .setTitle(R.string.speed_now) + .setMessage(R.string.this_may_take_a_while) + .setCancelable(false) + .show(); + new Thread("dex2oat") { + @Override + public void run() { + if (!Shell.rootAccess()) { + dialog.dismiss(); + NavUtil.showMessage(BaseActivity.this, getString(R.string.root_failed)); + return; + } + + Shell.su("cmd package compile -m speed -a").exec(); + + dialog.dismiss(); + App.runOnUiThread(() -> Toast.makeText(BaseActivity.this, R.string.done, Toast.LENGTH_LONG).show()); + } + }; + }); + } else if (itemId == R.id.reboot) { + areYouSure(R.string.reboot, (dialog, which) -> reboot(null)); + } else if (itemId == R.id.soft_reboot) { + areYouSure(R.string.soft_reboot, (dialog, which) -> softReboot()); + } else if (itemId == R.id.reboot_recovery) { + areYouSure(R.string.reboot_recovery, (dialog, which) -> reboot("recovery")); + } else if (itemId == R.id.reboot_bootloader) { + areYouSure(R.string.reboot_bootloader, (dialog, which) -> reboot("bootloader")); + } else if (itemId == R.id.reboot_download) { + areYouSure(R.string.reboot_download, (dialog, which) -> reboot("download")); + } else if (itemId == R.id.reboot_edl) { + areYouSure(R.string.reboot_edl, (dialog, which) -> reboot("edl")); + } + + return super.onOptionsItemSelected(item); + } +} diff --git a/app/src/main/java/org/meowcat/edxposed/manager/ui/activity/BlackListActivity.java b/app/src/main/java/org/meowcat/edxposed/manager/ui/activity/BlackListActivity.java new file mode 100644 index 00000000..b05ff3e4 --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/ui/activity/BlackListActivity.java @@ -0,0 +1,156 @@ +package org.meowcat.edxposed.manager.ui.activity; + +import android.content.Intent; +import android.content.pm.ApplicationInfo; +import android.os.Bundle; +import android.os.Handler; +import android.view.Menu; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.widget.SearchView; +import androidx.recyclerview.widget.DividerItemDecoration; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import org.meowcat.edxposed.manager.App; +import org.meowcat.edxposed.manager.R; +import org.meowcat.edxposed.manager.adapters.AppAdapter; +import org.meowcat.edxposed.manager.adapters.AppHelper; +import org.meowcat.edxposed.manager.adapters.BlackListAdapter; +import org.meowcat.edxposed.manager.adapters.CompatListAdapter; +import org.meowcat.edxposed.manager.databinding.ActivityBlackListBinding; +import org.meowcat.edxposed.manager.util.LinearLayoutManagerFix; + +import me.zhanghai.android.fastscroll.FastScrollerBuilder; + +public class BlackListActivity extends BaseActivity implements AppAdapter.Callback { + private SearchView searchView; + private AppAdapter appAdapter; + + private SearchView.OnQueryTextListener searchListener; + private ActivityBlackListBinding binding; + private final Runnable runnable = new Runnable() { + @Override + public void run() { + binding.swipeRefreshLayout.setRefreshing(true); + } + }; + private final Handler handler = new Handler(); + private boolean isCompat; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + isCompat = getIntent().getBooleanExtra("compat_list", false); + binding = ActivityBlackListBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + setSupportActionBar(binding.toolbar); + binding.toolbar.setNavigationOnClickListener(view -> finish()); + ActionBar bar = getSupportActionBar(); + if (bar != null) { + bar.setDisplayHomeAsUpEnabled(true); + } + setupWindowInsets(binding.snackbar, binding.recyclerView); + final boolean isWhiteListMode = isWhiteListMode(); + appAdapter = isCompat ? new CompatListAdapter(this) : new BlackListAdapter(this, isWhiteListMode); + appAdapter.setHasStableIds(true); + binding.recyclerView.setAdapter(appAdapter); + binding.recyclerView.setLayoutManager(new LinearLayoutManagerFix(this)); + FastScrollerBuilder fastScrollerBuilder = new FastScrollerBuilder(binding.recyclerView); + if (!App.getPreferences().getBoolean("md2", false)) { + DividerItemDecoration dividerItemDecoration = new DividerItemDecoration(this, + DividerItemDecoration.VERTICAL); + binding.recyclerView.addItemDecoration(dividerItemDecoration); + } else { + fastScrollerBuilder.useMd2Style(); + } + fastScrollerBuilder.build(); + appAdapter.setCallback(this); + handler.postDelayed(runnable, 300); + binding.swipeRefreshLayout.setOnRefreshListener(appAdapter::refresh); + + searchListener = new SearchView.OnQueryTextListener() { + @Override + public boolean onQueryTextSubmit(String query) { + appAdapter.filter(query); + return false; + } + + @Override + public boolean onQueryTextChange(String newText) { + appAdapter.filter(newText); + return false; + } + }; + } + + @Override + public boolean onCreateOptionsMenu(@NonNull Menu menu) { + getMenuInflater().inflate(R.menu.menu_app_list, menu); + searchView = (SearchView) menu.findItem(R.id.menu_search).getActionView(); + searchView.setOnQueryTextListener(searchListener); + return super.onCreateOptionsMenu(menu); + } + + @Override + public void onResume() { + super.onResume(); + if (!isCompat && !AppHelper.isBlackListMode()) { + new MaterialAlertDialogBuilder(this) + .setMessage(R.string.warning_list_not_enabled) + .setPositiveButton(R.string.Settings, (dialog, which) -> { + Intent intent = new Intent(); + intent.setClass(BlackListActivity.this, SettingsActivity.class); + startActivity(intent); + }) + .setNegativeButton(android.R.string.cancel, (dialog, which) -> finish()) + .setCancelable(false) + .show(); + } + changeTitle(isBlackListMode(), isWhiteListMode()); + } + + + private void changeTitle(boolean isBlackListMode, boolean isWhiteListMode) { + if (isCompat) { + setTitle(R.string.nav_title_compat_list); + } else if (isBlackListMode) { + setTitle(isWhiteListMode ? R.string.title_white_list : R.string.title_black_list); + } else { + setTitle(R.string.nav_title_black_list); + } + } + + private boolean isWhiteListMode() { + return AppHelper.isWhiteListMode(); + } + + private boolean isBlackListMode() { + return AppHelper.isBlackListMode(); + } + + @Override + public void onDataReady() { + handler.removeCallbacks(runnable); + binding.swipeRefreshLayout.setRefreshing(false); + String queryStr = searchView != null ? searchView.getQuery().toString() : ""; + runOnUiThread(() -> appAdapter.getFilter().filter(queryStr)); + } + + @Override + public void onItemClick(View v, ApplicationInfo info) { + AppHelper.showMenu(this, getSupportFragmentManager(), v, info); + } + + @Override + public void onBackPressed() { + if (searchView.isIconified()) { + super.onBackPressed(); + } else { + searchView.setIconified(true); + } + } +} diff --git a/app/src/main/java/org/meowcat/edxposed/manager/ui/activity/CrashReportActivity.java b/app/src/main/java/org/meowcat/edxposed/manager/ui/activity/CrashReportActivity.java new file mode 100644 index 00000000..d7f8fcf4 --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/ui/activity/CrashReportActivity.java @@ -0,0 +1,126 @@ +package org.meowcat.edxposed.manager.ui.activity; + +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Intent; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageInfo; +import android.os.Build; +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; + +import com.google.android.material.snackbar.Snackbar; + +import org.meowcat.edxposed.manager.BuildConfig; +import org.meowcat.edxposed.manager.R; +import org.meowcat.edxposed.manager.databinding.ActivityCrashReportBinding; + +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +public class CrashReportActivity extends AppCompatActivity { + ActivityCrashReportBinding binding; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + binding = ActivityCrashReportBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + binding.copyLogs.setOnClickListener(v -> { + ClipboardManager clipboard = (ClipboardManager) getSystemService(CLIPBOARD_SERVICE); + //Are there any devices without clipboard...? + if (clipboard != null) { + ClipData clip = ClipData.newPlainText("edcrash", getAllErrorDetailsFromIntent(getIntent())); + clipboard.setPrimaryClip(clip); + Snackbar.make(binding.snackbar, R.string.copy_toast_msg, Snackbar.LENGTH_SHORT).show(); + } + }); + + } + + public String getAllErrorDetailsFromIntent(@NonNull Intent intent) { + Date currentDate = new Date(); + DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US); + + String buildDateAsString = getBuildDateAsString(dateFormat); + + String versionName = getVersionName(); + + String errorDetails = ""; + + errorDetails += "Build version: " + versionName + " \n"; + if (buildDateAsString != null) { + errorDetails += "Build date: " + buildDateAsString + " \n"; + } + errorDetails += "Current date: " + dateFormat.format(currentDate) + " \n"; + errorDetails += "Device: " + getDeviceModelName() + " \n \n"; + errorDetails += "Stack trace: \n"; + errorDetails += getStackTraceFromIntent(intent); + return errorDetails; + } + + private String getBuildDateAsString(@NonNull DateFormat dateFormat) { + long buildDate; + try { + ApplicationInfo ai = getPackageManager().getApplicationInfo(getPackageName(), 0); + ZipFile zf = new ZipFile(ai.sourceDir); + + ZipEntry ze = zf.getEntry("classes.dex"); + buildDate = ze.getTime(); + + + zf.close(); + } catch (Exception e) { + buildDate = 0; + } + + if (buildDate > 312764400000L) { + return dateFormat.format(new Date(buildDate)); + } else { + return null; + } + } + + private String getVersionName() { + try { + PackageInfo packageInfo = getPackageManager().getPackageInfo(getPackageName(), 0); + return packageInfo.versionName; + } catch (Exception e) { + return "Unknown"; + } + } + + private String getDeviceModelName() { + String manufacturer = Build.MANUFACTURER; + String model = Build.MODEL; + if (model.startsWith(manufacturer)) { + return capitalize(model); + } else { + return capitalize(manufacturer) + " " + model; + } + } + + private String capitalize(@Nullable String s) { + if (s == null || s.length() == 0) { + return ""; + } + char first = s.charAt(0); + if (Character.isUpperCase(first)) { + return s; + } else { + return Character.toUpperCase(first) + s.substring(1); + } + } + + public String getStackTraceFromIntent(@NonNull Intent intent) { + return intent.getStringExtra(BuildConfig.APPLICATION_ID + ".EXTRA_STACK_TRACE"); + } + +} diff --git a/app/src/main/java/org/meowcat/edxposed/manager/ui/activity/DownloadActivity.java b/app/src/main/java/org/meowcat/edxposed/manager/ui/activity/DownloadActivity.java new file mode 100644 index 00000000..b5d626a6 --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/ui/activity/DownloadActivity.java @@ -0,0 +1,393 @@ +package org.meowcat.edxposed.manager.ui.activity; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.content.res.Resources; +import android.database.Cursor; +import android.net.ConnectivityManager; +import android.net.Uri; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.widget.SearchView; +import androidx.core.content.ContextCompat; +import androidx.recyclerview.widget.DividerItemDecoration; +import androidx.recyclerview.widget.RecyclerView; +import androidx.transition.TransitionManager; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.timehop.stickyheadersrecyclerview.StickyRecyclerHeadersAdapter; +import com.timehop.stickyheadersrecyclerview.StickyRecyclerHeadersDecoration; + +import org.meowcat.edxposed.manager.App; +import org.meowcat.edxposed.manager.R; +import org.meowcat.edxposed.manager.adapters.CursorRecyclerViewAdapter; +import org.meowcat.edxposed.manager.databinding.ActivityDownloadBinding; +import org.meowcat.edxposed.manager.databinding.ItemDownloadBinding; +import org.meowcat.edxposed.manager.repo.RepoDb; +import org.meowcat.edxposed.manager.repo.RepoDbDefinitions; +import org.meowcat.edxposed.manager.util.LinearLayoutManagerFix; +import org.meowcat.edxposed.manager.util.ModuleUtil; +import org.meowcat.edxposed.manager.util.RepoLoader; + +import java.text.DateFormat; +import java.util.Date; + +import me.zhanghai.android.fastscroll.FastScrollerBuilder; + +public class DownloadActivity extends BaseActivity implements RepoLoader.RepoListener, ModuleUtil.ModuleListener, SharedPreferences.OnSharedPreferenceChangeListener { + private DownloadsAdapter adapter; + private String filterText; + private RepoLoader repoLoader; + private ModuleUtil moduleUtil; + private int sortingOrder; + private SearchView searchView; + private SharedPreferences ignoredUpdatesPref; + private boolean changed = false; + private final BroadcastReceiver connectionListener = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (repoLoader != null) { + repoLoader.triggerReload(true); + } + } + }; + private ActivityDownloadBinding binding; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + binding = ActivityDownloadBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + setSupportActionBar(binding.toolbar); + binding.toolbar.setNavigationOnClickListener(view -> finish()); + ActionBar bar = getSupportActionBar(); + if (bar != null) { + bar.setDisplayHomeAsUpEnabled(true); + } + + setupWindowInsets(binding.snackbar, binding.recyclerView); + repoLoader = RepoLoader.getInstance(); + moduleUtil = ModuleUtil.getInstance(); + adapter = new DownloadsAdapter(this, RepoDb.queryModuleOverview(sortingOrder, filterText)); + /*adapter.setFilterQueryProvider(new FilterQueryProvider() { + @Override + public Cursor runQuery(CharSequence constraint) { + return RepoDb.queryModuleOverview(sortingOrder, constraint); + } + });*/ + + sortingOrder = App.getPreferences().getInt("download_sorting_order", RepoDb.SORT_STATUS); + + ignoredUpdatesPref = getSharedPreferences("update_ignored", MODE_PRIVATE); + binding.recyclerView.setImportantForAutofill(View.IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS); + binding.swipeRefreshLayout.setOnRefreshListener(() -> { + repoLoader.setSwipeRefreshLayout(binding.swipeRefreshLayout); + repoLoader.triggerReload(true); + }); + repoLoader.addListener(this, true); + moduleUtil.addListener(this); + binding.recyclerView.setAdapter(adapter); + + binding.recyclerView.setLayoutManager(new LinearLayoutManagerFix(this)); + StickyRecyclerHeadersDecoration headersDecor = new StickyRecyclerHeadersDecoration(adapter); + binding.recyclerView.addItemDecoration(headersDecor); + adapter.registerAdapterDataObserver(new RecyclerView.AdapterDataObserver() { + @Override + public void onChanged() { + headersDecor.invalidateHeaders(); + } + }); + FastScrollerBuilder fastScrollerBuilder = new FastScrollerBuilder(binding.recyclerView); + if (!App.getPreferences().getBoolean("md2", false)) { + DividerItemDecoration dividerItemDecoration = new DividerItemDecoration(this, + DividerItemDecoration.VERTICAL); + binding.recyclerView.addItemDecoration(dividerItemDecoration); + } else { + fastScrollerBuilder.useMd2Style(); + } + fastScrollerBuilder.build(); + } + + + @Override + public void onResume() { + super.onResume(); + + ignoredUpdatesPref.registerOnSharedPreferenceChangeListener(this); + if (changed) { + reloadItems(); + changed = !changed; + } + + registerReceiver(connectionListener, new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)); + } + + @Override + public void onPause() { + super.onPause(); + + unregisterReceiver(connectionListener); + } + + @Override + public void onDestroy() { + super.onDestroy(); + + repoLoader.removeListener(this); + moduleUtil.removeListener(this); + ignoredUpdatesPref.unregisterOnSharedPreferenceChangeListener(this); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.menu_download, menu); + + // Setup search button + searchView = (SearchView) menu.findItem(R.id.menu_search).getActionView(); + searchView.setIconifiedByDefault(true); + searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() { + @Override + public boolean onQueryTextSubmit(String query) { + setFilter(query); + searchView.clearFocus(); + return true; + } + + @Override + public boolean onQueryTextChange(String newText) { + setFilter(newText); + return true; + } + }); + return super.onCreateOptionsMenu(menu); + } + + private void setFilter(String filterText) { + this.filterText = filterText; + reloadItems(); + } + + private void reloadItems() { + runOnUiThread(() -> { + adapter.changeCursor(RepoDb.queryModuleOverview(sortingOrder, filterText)); + TransitionManager.beginDelayedTransition(binding.recyclerView); + adapter.notifyDataSetChanged(); + }); + } + + @Override + public boolean onOptionsItemSelected(@NonNull MenuItem item) { + if (item.getItemId() == R.id.menu_sort) { + new MaterialAlertDialogBuilder(this) + .setTitle(R.string.download_sorting_title) + .setSingleChoiceItems(R.array.download_sort_order, sortingOrder, (dialog, which) -> { + sortingOrder = which; + App.getPreferences().edit().putInt("download_sorting_order", sortingOrder).apply(); + reloadItems(); + dialog.dismiss(); + }) + .show(); + return true; + } + return super.onOptionsItemSelected(item); + } + + @Override + public void onRepoReloaded(final RepoLoader loader) { + reloadItems(); + } + + @Override + public void onSingleInstalledModuleReloaded(ModuleUtil moduleUtil, String packageName, ModuleUtil.InstalledModule module) { + reloadItems(); + } + + @Override + public void onInstalledModulesReloaded(ModuleUtil moduleUtil) { + reloadItems(); + } + + @Override + public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { + changed = true; + } + + @Override + public void onBackPressed() { + if (searchView.isIconified()) { + super.onBackPressed(); + } else { + searchView.setIconified(true); + } + } + + private class DownloadsAdapter extends CursorRecyclerViewAdapter implements StickyRecyclerHeadersAdapter { + private final Context context; + private final DateFormat dateFormatter = DateFormat.getDateInstance(DateFormat.SHORT); + private final SharedPreferences prefs; + private final String[] sectionHeaders; + + DownloadsAdapter(Context context, Cursor cursor) { + super(cursor); + this.context = context; + prefs = context.getSharedPreferences("update_ignored", MODE_PRIVATE); + + Resources res = context.getResources(); + sectionHeaders = new String[]{ + res.getString(R.string.download_section_framework), + res.getString(R.string.download_section_update_available), + res.getString(R.string.download_section_installed), + res.getString(R.string.download_section_not_installed), + res.getString(R.string.download_section_24h), + res.getString(R.string.download_section_7d), + res.getString(R.string.download_section_30d), + res.getString(R.string.download_section_older)}; + } + + @Override + public long getHeaderId(int position) { + Cursor cursor = getCursor(); + cursor.moveToPosition(position); + long created = cursor.getLong(RepoDbDefinitions.OverviewColumnsIndexes.CREATED); + long updated = cursor.getLong(RepoDbDefinitions.OverviewColumnsIndexes.UPDATED); + boolean isFramework = cursor.getInt(RepoDbDefinitions.OverviewColumnsIndexes.IS_FRAMEWORK) > 0; + boolean isInstalled = cursor.getInt(RepoDbDefinitions.OverviewColumnsIndexes.IS_INSTALLED) > 0; + boolean updateIgnored = prefs.getBoolean(cursor.getString(RepoDbDefinitions.OverviewColumnsIndexes.PKGNAME), false); + boolean updateIgnorePreference = App.getPreferences().getBoolean("ignore_updates", false); + boolean hasUpdate = cursor.getInt(RepoDbDefinitions.OverviewColumnsIndexes.HAS_UPDATE) > 0; + + if (hasUpdate && updateIgnored && updateIgnorePreference) { + hasUpdate = false; + } + + if (sortingOrder != RepoDb.SORT_STATUS) { + long timestamp = (sortingOrder == RepoDb.SORT_UPDATED) ? updated : created; + long age = System.currentTimeMillis() - timestamp; + final long mSecsPerDay = 24 * 60 * 60 * 1000L; + if (age < mSecsPerDay) + return 4; + if (age < 7 * mSecsPerDay) + return 5; + if (age < 30 * mSecsPerDay) + return 6; + return 7; + } else { + if (isFramework) + return 0; + + if (hasUpdate) + return 1; + else if (isInstalled) + return 2; + else + return 3; + } + } + + @Override + public HeaderViewHolder onCreateHeaderViewHolder(ViewGroup parent) { + View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.sticky_header_download, parent, false); + return new HeaderViewHolder(view); + } + + @Override + public void onBindHeaderViewHolder(HeaderViewHolder viewHolder, int position) { + long section = getHeaderId(position); + viewHolder.title.setText(sectionHeaders[(int) section]); + } + + @NonNull + @Override + public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + ItemDownloadBinding binding = ItemDownloadBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false); + return new ViewHolder(binding); + } + + @Override + public void onBindViewHolder(ViewHolder holder, Cursor cursor) { + String title = cursor.getString(RepoDbDefinitions.OverviewColumnsIndexes.TITLE); + String summary = cursor.getString(RepoDbDefinitions.OverviewColumnsIndexes.SUMMARY); + String installedVersion = cursor.getString(RepoDbDefinitions.OverviewColumnsIndexes.INSTALLED_VERSION); + String latestVersion = cursor.getString(RepoDbDefinitions.OverviewColumnsIndexes.LATEST_VERSION); + long created = cursor.getLong(RepoDbDefinitions.OverviewColumnsIndexes.CREATED); + long updated = cursor.getLong(RepoDbDefinitions.OverviewColumnsIndexes.UPDATED); + boolean isInstalled = cursor.getInt(RepoDbDefinitions.OverviewColumnsIndexes.IS_INSTALLED) > 0; + boolean updateIgnored = prefs.getBoolean(cursor.getString(RepoDbDefinitions.OverviewColumnsIndexes.PKGNAME), false); + boolean updateIgnorePreference = App.getPreferences().getBoolean("ignore_updates", false); + boolean hasUpdate = cursor.getInt(RepoDbDefinitions.OverviewColumnsIndexes.HAS_UPDATE) > 0; + + if (hasUpdate && updateIgnored && updateIgnorePreference) { + hasUpdate = false; + } + + TextView txtTitle = holder.appName; + txtTitle.setText(title); + + TextView txtSummary = holder.appDescription; + txtSummary.setText(summary); + + TextView txtStatus = holder.downloadStatus; + if (hasUpdate) { + txtStatus.setText(context.getString( + R.string.download_status_update_available, + installedVersion, latestVersion)); + txtStatus.setTextColor(ContextCompat.getColor(DownloadActivity.this, R.color.download_status_update_available)); + txtStatus.setVisibility(View.VISIBLE); + } else if (isInstalled) { + txtStatus.setText(context.getString( + R.string.download_status_installed, installedVersion)); + txtStatus.setTextColor(ContextCompat.getColor(DownloadActivity.this, R.color.warning)); + txtStatus.setTextColor(getThemedColor(android.R.attr.textColorHighlight)); + txtStatus.setVisibility(View.VISIBLE); + } else { + txtStatus.setVisibility(View.GONE); + } + + String creationDate = dateFormatter.format(new Date(created)); + String updateDate = dateFormatter.format(new Date(updated)); + holder.timestamps.setText(getString(R.string.download_timestamps, creationDate, updateDate)); + String packageName = cursor.getString(RepoDbDefinitions.OverviewColumnsIndexes.PKGNAME); + holder.itemView.setOnClickListener(v -> { + Intent detailsIntent = new Intent(DownloadActivity.this, DownloadDetailsActivity.class); + detailsIntent.setData(Uri.fromParts("package", packageName, null)); + startActivity(detailsIntent); + }); + } + + class ViewHolder extends RecyclerView.ViewHolder { + TextView appName; + TextView appDescription; + TextView downloadStatus; + TextView timestamps; + + ViewHolder(ItemDownloadBinding binding) { + super(binding.getRoot()); + appName = binding.title; + appDescription = binding.description; + downloadStatus = binding.downloadStatus; + timestamps = binding.timestamps; + } + } + + class HeaderViewHolder extends RecyclerView.ViewHolder { + TextView title; + + HeaderViewHolder(View view) { + super(view); + title = view.findViewById(android.R.id.title); + } + } + } +} + diff --git a/app/src/main/java/org/meowcat/edxposed/manager/ui/activity/DownloadDetailsActivity.java b/app/src/main/java/org/meowcat/edxposed/manager/ui/activity/DownloadDetailsActivity.java new file mode 100644 index 00000000..3cf20a06 --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/ui/activity/DownloadDetailsActivity.java @@ -0,0 +1,274 @@ +package org.meowcat.edxposed.manager.ui.activity; + +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.os.Bundle; +import android.text.TextUtils; +import android.util.Log; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.ActionBar; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import androidx.fragment.app.FragmentPagerAdapter; + +import org.meowcat.edxposed.manager.App; +import org.meowcat.edxposed.manager.R; +import org.meowcat.edxposed.manager.databinding.ActivityDownloadDetailsBinding; +import org.meowcat.edxposed.manager.databinding.ActivityDownloadDetailsNotFoundBinding; +import org.meowcat.edxposed.manager.repo.Module; +import org.meowcat.edxposed.manager.ui.fragment.DownloadDetailsFragment; +import org.meowcat.edxposed.manager.ui.fragment.DownloadDetailsSettingsFragment; +import org.meowcat.edxposed.manager.ui.fragment.DownloadDetailsVersionsFragment; +import org.meowcat.edxposed.manager.util.ModuleUtil; +import org.meowcat.edxposed.manager.util.RepoLoader; + +import java.util.List; +import java.util.Objects; + +public class DownloadDetailsActivity extends BaseActivity implements RepoLoader.RepoListener, ModuleUtil.ModuleListener { + + public static final int DOWNLOAD_DESCRIPTION = 0; + public static final int DOWNLOAD_VERSIONS = 1; + public static final int DOWNLOAD_SETTINGS = 2; + static final String XPOSED_REPO_LINK = "http://repo.xposed.info/module/%s"; + static final String PLAY_STORE_PACKAGE = "com.android.vending"; + static final String PLAY_STORE_LINK = "https://play.google.com/store/apps/details?id=%s"; + private static final String TAG = "DownloadDetailsActivity"; + private static final RepoLoader repoLoader = RepoLoader.getInstance(); + private static final ModuleUtil moduleUtil = ModuleUtil.getInstance(); + private String packageName; + private Module module; + private ModuleUtil.InstalledModule installedModule; + private ActivityDownloadDetailsBinding binding; + + @Override + public void onCreate(Bundle savedInstanceState) { + + packageName = getModulePackageName(); + try { + module = repoLoader.getModule(packageName); + } catch (Exception e) { + Log.i(App.TAG, "DownloadDetailsActivity -> " + e.getMessage()); + + module = null; + } + + installedModule = ModuleUtil.getInstance().getModule(packageName); + + super.onCreate(savedInstanceState); + repoLoader.addListener(this, false); + moduleUtil.addListener(this); + + if (module != null) { + binding = ActivityDownloadDetailsBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + + setSupportActionBar(binding.toolbar); + binding.toolbar.setNavigationOnClickListener(view -> finish()); + + ActionBar ab = getSupportActionBar(); + + if (ab != null) { + ab.setDisplayHomeAsUpEnabled(true); + } + + setupTabs(); + + boolean directDownload = getIntent().getBooleanExtra("direct_download", false); + // Updates available => start on the versions page + if (installedModule != null && installedModule.isUpdate(repoLoader.getLatestVersion(module)) || directDownload) + binding.downloadPager.setCurrentItem(DOWNLOAD_VERSIONS); + + } else { + ActivityDownloadDetailsNotFoundBinding binding = ActivityDownloadDetailsNotFoundBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + + binding.message.setText(getResources().getString(R.string.download_details_not_found, packageName)); + + binding.reload.setOnClickListener(v -> { + v.setEnabled(false); + repoLoader.triggerReload(true); + }); + } + setupWindowInsets(binding.snackbar, null); + } + + private void setupTabs() { + binding.downloadPager.setAdapter(new SwipeFragmentPagerAdapter(getSupportFragmentManager())); + binding.slidingTabs.setupWithViewPager(binding.downloadPager); + } + + private String getModulePackageName() { + Uri uri = getIntent().getData(); + if (uri == null) + return null; + + String scheme = uri.getScheme(); + if (TextUtils.isEmpty(scheme)) { + return null; + } else switch (Objects.requireNonNull(scheme)) { + case "xposed": + case "package": + return uri.getSchemeSpecificPart().replace("//", ""); + case "http": + List segments = uri.getPathSegments(); + if (segments.size() > 1) + return segments.get(1); + break; + } + return null; + } + + @Override + protected void onDestroy() { + super.onDestroy(); + repoLoader.removeListener(this); + moduleUtil.removeListener(this); + } + + public Module getModule() { + return module; + } + + public ModuleUtil.InstalledModule getInstalledModule() { + return installedModule; + } + + public void gotoPage(int page) { + binding.downloadPager.setCurrentItem(page); + } + + private void reload() { + runOnUiThread(this::recreate); + } + + @Override + public void onRepoReloaded(RepoLoader loader) { + reload(); + } + + @Override + public void onInstalledModulesReloaded(ModuleUtil moduleUtil) { + reload(); + } + + @Override + public void onSingleInstalledModuleReloaded(ModuleUtil moduleUtil, String packageName, ModuleUtil.InstalledModule module) { + if (this.packageName.equals(packageName)) + reload(); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + MenuInflater inflater = getMenuInflater(); + inflater.inflate(R.menu.menu_download_details, menu); + + boolean updateIgnorePreference = App.getPreferences().getBoolean("ignore_updates", false); + if (updateIgnorePreference) { + SharedPreferences prefs = getSharedPreferences("update_ignored", MODE_PRIVATE); + + boolean ignored = prefs.getBoolean(module.packageName, false); + menu.findItem(R.id.ignoreUpdate).setChecked(ignored); + } else { + menu.removeItem(R.id.ignoreUpdate); + } + return true; + } + + @Override + public boolean onOptionsItemSelected(@NonNull MenuItem item) { + int itemId = item.getItemId(); + if (itemId == R.id.menu_refresh) { + RepoLoader.getInstance().triggerReload(true); + return true; + } else if (itemId == R.id.menu_share) { + String text = module.name + " - "; + + if (isPackageInstalled(packageName, this)) { + String s = getPackageManager().getInstallerPackageName(packageName); + boolean playStore; + + try { + playStore = PLAY_STORE_PACKAGE.equals(s); + } catch (NullPointerException e) { + playStore = false; + } + + if (playStore) { + text += String.format(PLAY_STORE_LINK, packageName); + } else { + text += String.format(XPOSED_REPO_LINK, packageName); + } + } else { + text += String.format(XPOSED_REPO_LINK, + packageName); + } + + Intent sharingIntent = new Intent(Intent.ACTION_SEND); + sharingIntent.setType("text/plain"); + sharingIntent.putExtra(Intent.EXTRA_TEXT, text); + startActivity(Intent.createChooser(sharingIntent, getString(R.string.share))); + return true; + } else if (itemId == R.id.ignoreUpdate) { + SharedPreferences prefs = getSharedPreferences("update_ignored", MODE_PRIVATE); + + boolean ignored = prefs.getBoolean(module.packageName, false); + prefs.edit().putBoolean(module.packageName, !ignored).apply(); + item.setChecked(!ignored); + } + return super.onOptionsItemSelected(item); + } + + private boolean isPackageInstalled(String packagename, Context context) { + PackageManager pm = context.getPackageManager(); + try { + pm.getPackageInfo(packagename, PackageManager.GET_ACTIVITIES); + return true; + } catch (PackageManager.NameNotFoundException e) { + return false; + } + } + + class SwipeFragmentPagerAdapter extends FragmentPagerAdapter { + final int PAGE_COUNT = 3; + private final String[] tabTitles = new String[]{getString(R.string.download_details_page_description), getString(R.string.download_details_page_versions), getString(R.string.download_details_page_settings),}; + + SwipeFragmentPagerAdapter(FragmentManager fm) { + super(fm, FragmentPagerAdapter.BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT); + } + + @Override + public int getCount() { + return PAGE_COUNT; + } + + @NonNull + @Override + public Fragment getItem(int position) { + switch (position) { + case DOWNLOAD_DESCRIPTION: + return new DownloadDetailsFragment(); + case DOWNLOAD_VERSIONS: + return new DownloadDetailsVersionsFragment(); + case DOWNLOAD_SETTINGS: + return new DownloadDetailsSettingsFragment(); + default: + //noinspection ConstantConditions + return null; + } + } + + @Override + public CharSequence getPageTitle(int position) { + // Generate title based on item position + return tabTitles[position]; + } + } +} diff --git a/app/src/main/java/org/meowcat/edxposed/manager/ui/activity/EdDownloadActivity.java b/app/src/main/java/org/meowcat/edxposed/manager/ui/activity/EdDownloadActivity.java new file mode 100644 index 00000000..a25e54bc --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/ui/activity/EdDownloadActivity.java @@ -0,0 +1,170 @@ +package org.meowcat.edxposed.manager.ui.activity; + +import android.annotation.SuppressLint; +import android.content.SharedPreferences; +import android.os.AsyncTask; +import android.os.Build; +import android.os.Bundle; +import android.util.Log; +import android.view.Menu; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.ActionBar; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import androidx.fragment.app.FragmentPagerAdapter; +import androidx.transition.TransitionManager; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.google.gson.Gson; + +import org.meowcat.edxposed.manager.App; +import org.meowcat.edxposed.manager.BuildConfig; +import org.meowcat.edxposed.manager.R; +import org.meowcat.edxposed.manager.databinding.ActivityEdDownloadBinding; +import org.meowcat.edxposed.manager.databinding.DialogInstallWarningBinding; +import org.meowcat.edxposed.manager.ui.fragment.BaseAdvancedInstaller; +import org.meowcat.edxposed.manager.ui.fragment.StatusInstallerFragment; +import org.meowcat.edxposed.manager.util.json.JSONUtils; +import org.meowcat.edxposed.manager.util.json.XposedTab; + +import java.util.ArrayList; + +public class EdDownloadActivity extends BaseActivity { + ActivityEdDownloadBinding binding; + private TabsAdapter tabsAdapter; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + binding = ActivityEdDownloadBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + setSupportActionBar(binding.toolbar); + binding.toolbar.setNavigationOnClickListener(view -> finish()); + ActionBar bar = getSupportActionBar(); + if (bar != null) { + bar.setDisplayHomeAsUpEnabled(true); + } + + tabsAdapter = new TabsAdapter(getSupportFragmentManager()); + tabsAdapter.notifyDataSetChanged(); + binding.pager.setAdapter(tabsAdapter); + binding.tabLayout.setupWithViewPager(binding.pager); + new JSONParser().execute(); + + if (!App.getPreferences().getBoolean("hide_install_warning", false)) { + DialogInstallWarningBinding binding = DialogInstallWarningBinding.inflate(getLayoutInflater()); + new MaterialAlertDialogBuilder(this) + .setTitle(R.string.install_warning_title) + .setMessage(R.string.install_warning) + .setView(binding.getRoot()) + .setPositiveButton(android.R.string.ok, (dialog, which) -> { + if (binding.checkbox.isChecked()) + App.getPreferences().edit().putBoolean("hide_install_warning", true).apply(); + }) + .setCancelable(false) + .show(); + } + + } + + @Override + public boolean onCreateOptionsMenu(@NonNull Menu menu) { + getMenuInflater().inflate(R.menu.menu_installer, menu); + return super.onCreateOptionsMenu(menu); + } + + @SuppressWarnings("deprecation") + @SuppressLint("StaticFieldLeak") + private class JSONParser extends AsyncTask { + + @Override + protected String doInBackground(Void... params) { + try { + return JSONUtils.getFileContent(JSONUtils.JSON_LINK); + } catch (Exception e) { + e.printStackTrace(); + Log.e(App.TAG, "AdvancedInstallerFragment -> " + e.getMessage()); + return null; + } + } + + @Override + protected void onPostExecute(String result) { + super.onPostExecute(result); + if (result == null) { + return; + } + try { + final JSONUtils.XposedJson xposedJson = new Gson().fromJson(result, JSONUtils.XposedJson.class); + + TransitionManager.beginDelayedTransition(binding.tabLayout); + for (XposedTab tab : xposedJson.tabs) { + if (tab.installers.size() > 0 && tab.sdks.contains(Build.VERSION.SDK_INT)) { + tabsAdapter.addFragment(tab.name, BaseAdvancedInstaller.newInstance(tab)); + tabsAdapter.notifyDataSetChanged(); + } + } + + String newApkVersion = xposedJson.apk.version; + String newApkLink = xposedJson.apk.link; + String newApkChangelog = xposedJson.apk.changelog; + + if (newApkVersion == null) { + return; + } + + SharedPreferences prefs; + try { + prefs = EdDownloadActivity.this.getSharedPreferences(EdDownloadActivity.this.getPackageName() + "_preferences", MODE_PRIVATE); + + prefs.edit().putString("changelog", newApkChangelog).apply(); + } catch (NullPointerException ignored) { + } + + Integer a = BuildConfig.VERSION_CODE; + Integer b = Integer.valueOf(newApkVersion); + + if (a.compareTo(b) < 0) { + StatusInstallerFragment.setUpdate(newApkLink, newApkChangelog, EdDownloadActivity.this); + } + + } catch (Exception ignored) { + } + + } + } + + private class TabsAdapter extends FragmentPagerAdapter { + + private final ArrayList titles = new ArrayList<>(); + private final ArrayList listFragment = new ArrayList<>(); + + @SuppressWarnings("deprecation") + TabsAdapter(FragmentManager mgr) { + super(mgr); + addFragment(getString(R.string.tabInstall), new StatusInstallerFragment()); + } + + void addFragment(String title, Fragment fragment) { + titles.add(title); + listFragment.add(fragment); + } + + @Override + public int getCount() { + return listFragment.size(); + } + + @NonNull + @Override + public Fragment getItem(int position) { + return listFragment.get(position); + } + + @Override + public String getPageTitle(int position) { + return titles.get(position); + } + } +} diff --git a/app/src/main/java/org/meowcat/edxposed/manager/ui/activity/LogsActivity.java b/app/src/main/java/org/meowcat/edxposed/manager/ui/activity/LogsActivity.java new file mode 100644 index 00000000..16c274ba --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/ui/activity/LogsActivity.java @@ -0,0 +1,336 @@ +package org.meowcat.edxposed.manager.ui.activity; + +import android.annotation.SuppressLint; +import android.content.Intent; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Bundle; +import android.os.Handler; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AlertDialog; +import androidx.core.content.FileProvider; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.google.android.material.snackbar.Snackbar; +import com.google.android.material.tabs.TabLayout; + +import org.meowcat.edxposed.manager.App; +import org.meowcat.edxposed.manager.BuildConfig; +import org.meowcat.edxposed.manager.Constants; +import org.meowcat.edxposed.manager.R; +import org.meowcat.edxposed.manager.databinding.ActivityLogsBinding; +import org.meowcat.edxposed.manager.databinding.DialogInstallWarningBinding; +import org.meowcat.edxposed.manager.databinding.ItemLogBinding; +import org.meowcat.edxposed.manager.util.LinearLayoutManagerFix; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Calendar; +import java.util.Scanner; + +public class LogsActivity extends BaseActivity { + private boolean allLog = false; + private final File fileErrorLog = new File(Constants.getBaseDir() + "log/error.log"); + private final File fileErrorLogOld = new File( + Constants.getBaseDir() + "log/error.log.old"); + private final File fileAllLog = new File(Constants.getBaseDir() + "log/all.log"); + private final File fileAllLogOld = new File(Constants.getBaseDir() + "log/all.log.old"); + private LogsAdapter adapter; + private final Handler handler = new Handler(); + private ActivityLogsBinding binding; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + binding = ActivityLogsBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + setSupportActionBar(binding.toolbar); + binding.toolbar.setNavigationOnClickListener(view -> finish()); + ActionBar bar = getSupportActionBar(); + if (bar != null) { + bar.setDisplayHomeAsUpEnabled(true); + } + setupWindowInsets(binding.snackbar, binding.recyclerView); + + if (!App.getPreferences().getBoolean("hide_logcat_warning", false)) { + DialogInstallWarningBinding binding = DialogInstallWarningBinding.inflate(getLayoutInflater()); + new MaterialAlertDialogBuilder(this) + .setTitle(R.string.install_warning_title) + .setMessage(R.string.not_logcat) + .setView(binding.getRoot()) + .setPositiveButton(android.R.string.ok, (dialog, which) -> { + if (binding.checkbox.isChecked()) + App.getPreferences().edit().putBoolean("hide_logcat_warning", true).apply(); + }) + .setCancelable(false) + .show(); + } + adapter = new LogsAdapter(); + binding.recyclerView.setAdapter(adapter); + binding.recyclerView.setLayoutManager(new LinearLayoutManagerFix(this)); + if (App.getPreferences().getBoolean("disable_verbose_log", false)) { + binding.slidingTabs.setVisibility(View.GONE); + } + binding.slidingTabs.addOnTabSelectedListener(new TabLayout.OnTabSelectedListener() { + @Override + public void onTabSelected(TabLayout.Tab tab) { + allLog = tab.getPosition() != 0; + reloadErrorLog(); + } + + @Override + public void onTabUnselected(TabLayout.Tab tab) { + + } + + @Override + public void onTabReselected(TabLayout.Tab tab) { + + } + }); + } + + @Override + public void onResume() { + super.onResume(); + reloadErrorLog(); + } + + @Override + public boolean onCreateOptionsMenu(@NonNull Menu menu) { + getMenuInflater().inflate(R.menu.menu_logs, menu); + return super.onCreateOptionsMenu(menu); + } + + @Override + public boolean onOptionsItemSelected(@NonNull MenuItem item) { + int itemId = item.getItemId(); + if (itemId == R.id.menu_scroll_top) { + scrollTop(); + } else if (itemId == R.id.menu_scroll_down) { + scrollDown(); + } else if (itemId == R.id.menu_refresh) { + reloadErrorLog(); + return true; + } else if (itemId == R.id.menu_send) { + try { + send(); + } catch (Exception e) { + e.printStackTrace(); + } + return true; + } else if (itemId == R.id.menu_save) { + save(); + return true; + } else if (itemId == R.id.menu_clear) { + clear(); + return true; + } + return super.onOptionsItemSelected(item); + } + + private void scrollTop() { + binding.recyclerView.smoothScrollToPosition(0); + } + + private void scrollDown() { + binding.recyclerView.smoothScrollToPosition(adapter.getItemCount() - 1); + } + + private void reloadErrorLog() { + new LogsReader().execute(allLog ? fileAllLog : fileErrorLog); + } + + private void clear() { + try { + new FileOutputStream(allLog ? fileAllLog : fileErrorLog).close(); + //noinspection ResultOfMethodCallIgnored + (allLog ? fileAllLogOld : fileErrorLogOld).delete(); + adapter.setEmpty(); + Snackbar.make(binding.snackbar, R.string.logs_cleared, Snackbar.LENGTH_SHORT).show(); + reloadErrorLog(); + } catch (IOException e) { + Snackbar.make(binding.snackbar, getResources().getString(R.string.logs_clear_failed) + "n" + e.getMessage(), Snackbar.LENGTH_LONG).show(); + } + } + + private void send() { + Uri uri = FileProvider.getUriForFile(this, BuildConfig.APPLICATION_ID + ".fileprovider", allLog ? fileAllLog : fileErrorLog); + Intent sendIntent = new Intent(); + sendIntent.setAction(Intent.ACTION_SEND); + sendIntent.putExtra(Intent.EXTRA_STREAM, uri); + sendIntent.setDataAndType(uri, "text/plain"); + sendIntent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + startActivity(Intent.createChooser(sendIntent, getResources().getString(R.string.menuSend))); + } + + @SuppressLint("DefaultLocale") + private void save() { + Calendar now = Calendar.getInstance(); + String filename = String.format( + "EdXposed_Verbose_%04d%02d%02d_%02d%02d%02d.log", + now.get(Calendar.YEAR), now.get(Calendar.MONTH) + 1, + now.get(Calendar.DAY_OF_MONTH), now.get(Calendar.HOUR_OF_DAY), + now.get(Calendar.MINUTE), now.get(Calendar.SECOND)); + + Intent exportIntent = new Intent(Intent.ACTION_CREATE_DOCUMENT); + exportIntent.addCategory(Intent.CATEGORY_OPENABLE); + exportIntent.setType("text/*"); + exportIntent.putExtra(Intent.EXTRA_TITLE, filename); + startActivityForResult(exportIntent, 42); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (resultCode != RESULT_OK) { + return; + } + if (requestCode == 42) { + if (data != null) { + Uri uri = data.getData(); + if (uri != null) { + try { + OutputStream os = getContentResolver().openOutputStream(uri); + if (os != null) { + FileInputStream in = new FileInputStream(allLog ? fileAllLog : fileErrorLog); + byte[] buffer = new byte[1024]; + int len; + while ((len = in.read(buffer)) > 0) { + os.write(buffer, 0, len); + } + os.close(); + } + } catch (Exception e) { + Snackbar.make(binding.snackbar, getResources().getString(R.string.logs_save_failed) + "\n" + e.getMessage(), Snackbar.LENGTH_LONG).show(); + } + } + } + } + } + + @SuppressWarnings("deprecation") + @SuppressLint("StaticFieldLeak") + private class LogsReader extends AsyncTask> { + private AlertDialog mProgressDialog; + private final Runnable mRunnable = new Runnable() { + @Override + public void run() { + mProgressDialog.show(); + } + }; + + @Override + protected void onPreExecute() { + mProgressDialog = new MaterialAlertDialogBuilder(LogsActivity.this).create(); + mProgressDialog.setMessage(getString(R.string.loading)); + mProgressDialog.setCancelable(false); + handler.postDelayed(mRunnable, 300); + } + + @Override + protected ArrayList doInBackground(File... log) { + Thread.currentThread().setPriority(Thread.NORM_PRIORITY + 2); + + ArrayList logs = new ArrayList<>(); + + try { + File logfile = log[0]; + try (Scanner scanner = new Scanner(logfile)) { + while (scanner.hasNextLine()) { + logs.add(scanner.nextLine()); + } + } + return logs; + } catch (IOException e) { + logs.add(LogsActivity.this.getResources().getString(R.string.logs_cannot_read)); + if (e.getMessage() != null) { + logs.addAll(Arrays.asList(e.getMessage().split("\n"))); + } + } + + return logs; + } + + @Override + protected void onPostExecute(ArrayList logs) { + if (logs.size() == 0) { + adapter.setEmpty(); + } else { + adapter.setLogs(logs); + } + handler.removeCallbacks(mRunnable);//It loaded so fast that no need to show progress + if (mProgressDialog.isShowing()) { + mProgressDialog.dismiss(); + } + } + } + + private class LogsAdapter extends RecyclerView.Adapter { + ArrayList logs = new ArrayList<>(); + + @NonNull + @Override + public LogsAdapter.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + ItemLogBinding binding = ItemLogBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false); + return new LogsAdapter.ViewHolder(binding); + } + + @Override + public void onBindViewHolder(@NonNull LogsAdapter.ViewHolder holder, int position) { + TextView view = holder.textView; + view.setText(logs.get(position)); + view.measure(0, 0); + int desiredWidth = view.getMeasuredWidth(); + ViewGroup.LayoutParams layoutParams = view.getLayoutParams(); + layoutParams.width = desiredWidth; + if (binding.recyclerView.getWidth() < desiredWidth) { + binding.recyclerView.requestLayout(); + } + + } + + void setLogs(ArrayList logs) { + this.logs.clear(); + this.logs.addAll(logs); + notifyDataSetChanged(); + } + + void setEmpty() { + logs.clear(); + logs.add(getString(R.string.log_is_empty)); + notifyDataSetChanged(); + } + + @Override + public int getItemCount() { + return logs.size(); + } + + class ViewHolder extends RecyclerView.ViewHolder { + TextView textView; + + ViewHolder(ItemLogBinding binding) { + super(binding.getRoot()); + textView = binding.log; + } + } + } + + +} diff --git a/app/src/main/java/org/meowcat/edxposed/manager/ui/activity/MainActivity.java b/app/src/main/java/org/meowcat/edxposed/manager/ui/activity/MainActivity.java new file mode 100644 index 00000000..17df6fb5 --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/ui/activity/MainActivity.java @@ -0,0 +1,169 @@ +package org.meowcat.edxposed.manager.ui.activity; + +import android.annotation.SuppressLint; +import android.content.Intent; +import android.os.Bundle; + +import androidx.appcompat.widget.PopupMenu; +import androidx.appcompat.widget.TooltipCompat; +import androidx.core.content.ContextCompat; + +import com.bumptech.glide.Glide; + +import org.meowcat.edxposed.manager.Constants; +import org.meowcat.edxposed.manager.R; +import org.meowcat.edxposed.manager.adapters.AppHelper; +import org.meowcat.edxposed.manager.adapters.BlackListAdapter; +import org.meowcat.edxposed.manager.databinding.ActivityMainBinding; +import org.meowcat.edxposed.manager.util.GlideHelper; +import org.meowcat.edxposed.manager.util.ModuleUtil; +import org.meowcat.edxposed.manager.util.RepoLoader; +import org.meowcat.edxposed.manager.util.light.Light; + +public class MainActivity extends BaseActivity implements RepoLoader.RepoListener, ModuleUtil.ModuleListener { + ActivityMainBinding binding; + private RepoLoader repoLoader; + + @SuppressLint("PrivateResource") + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + binding = ActivityMainBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + getWindow().getDecorView().post(() -> { + if (Light.setLightSourceAlpha(getWindow().getDecorView(), 0.01f, 0.029f)) { + binding.status.setElevation(24); + binding.modules.setElevation(12); + binding.downloads.setElevation(12); + } + }); + setupWindowInsets(binding.snackbar, null); + repoLoader = RepoLoader.getInstance(); + ModuleUtil.getInstance().addListener(this); + repoLoader.addListener(this, false); + binding.modules.setOnClickListener(v -> { + Intent intent = new Intent(); + intent.setClass(getApplicationContext(), ModulesActivity.class); + startActivity(intent); + }); + binding.downloads.setOnClickListener(v -> { + Intent intent = new Intent(); + intent.setClass(getApplicationContext(), DownloadActivity.class); + startActivity(intent); + }); + binding.apps.setOnClickListener(v -> { + Intent intent = new Intent(); + intent.setClass(getApplicationContext(), BlackListActivity.class); + startActivity(intent); + }); + binding.status.setOnClickListener(v -> { + Intent intent = new Intent(); + intent.setClass(getApplicationContext(), EdDownloadActivity.class); + startActivity(intent); + }); + binding.settings.setOnClickListener(v -> { + Intent intent = new Intent(); + intent.setClass(getApplicationContext(), SettingsActivity.class); + startActivity(intent); + }); + binding.logs.setOnClickListener(v -> { + Intent intent = new Intent(); + intent.setClass(getApplicationContext(), LogsActivity.class); + startActivity(intent); + }); + binding.about.setOnClickListener(v -> { + Intent intent = new Intent(); + intent.setClass(getApplicationContext(), AboutActivity.class); + startActivity(intent); + }); + TooltipCompat.setTooltipText(binding.menuMore, getString(androidx.appcompat.R.string.abc_action_menu_overflow_description)); + binding.menuMore.setOnClickListener(v -> { + PopupMenu appMenu = new PopupMenu(MainActivity.this, binding.menuMore); + appMenu.inflate(R.menu.menu_installer); + appMenu.setOnMenuItemClickListener(this::onOptionsItemSelected); + appMenu.show(); + }); + Glide.with(binding.appIcon) + .load(GlideHelper.wrapApplicationInfoForIconLoader(getApplicationInfo())) + .into(binding.appIcon); + String installedXposedVersion = Constants.getXposedVersion(); + if (installedXposedVersion != null) { + if (Constants.getXposedApiVersion() != -1) { + binding.statusTitle.setText(R.string.Activated); + binding.statusSummary.setText(installedXposedVersion + " (" + Constants.getXposedVariant() + ")"); + binding.status.setCardBackgroundColor(ContextCompat.getColor(this, R.color.download_status_update_available)); + binding.statusIcon.setImageResource(R.drawable.ic_check_circle); + } else { + binding.statusTitle.setText(R.string.Inactivate); + binding.statusSummary.setText(R.string.installed_lollipop_inactive); + binding.status.setCardBackgroundColor(ContextCompat.getColor(this, R.color.amber_500)); + binding.statusIcon.setImageResource(R.drawable.ic_warning); + } + } else if (Constants.getXposedApiVersion() > 0) { + binding.statusTitle.setText(R.string.Activated); + binding.statusSummary.setText(getString(R.string.version_x, Constants.getXposedApiVersion())); + binding.status.setCardBackgroundColor(ContextCompat.getColor(this, R.color.download_status_update_available)); + binding.statusIcon.setImageResource(R.drawable.ic_check_circle); + } else { + binding.statusTitle.setText(R.string.Install); + binding.statusSummary.setText(R.string.InstallDetail); + binding.status.setCardBackgroundColor(ContextCompat.getColor(this, R.color.colorPrimary)); + binding.statusIcon.setImageResource(R.drawable.ic_error); + } + notifyDataSetChanged(); + new Thread(() -> new BlackListAdapter(getApplicationContext(), AppHelper.isWhiteListMode()).generateCheckedList()); + } + + private int extractIntPart(String str) { + int result = 0, length = str.length(); + for (int offset = 0; offset < length; offset++) { + char c = str.charAt(offset); + if ('0' <= c && c <= '9') + result = result * 10 + (c - '0'); + else + break; + } + return result; + } + + private void notifyDataSetChanged() { + runOnUiThread(() -> { + String frameworkUpdateVersion = repoLoader.getFrameworkUpdateVersion(); + boolean moduleUpdateAvailable = repoLoader.hasModuleUpdates(); + ModuleUtil.getInstance().getEnabledModules().size(); + binding.modulesSummary.setText(String.format(getString(R.string.ModulesDetail), ModuleUtil.getInstance().getEnabledModules().size())); + if (frameworkUpdateVersion != null) { + binding.statusSummary.setText(String.format(getString(R.string.welcome_framework_update_available), frameworkUpdateVersion)); + } + if (moduleUpdateAvailable) { + binding.downloadSummary.setText(R.string.modules_updates_available); + } else { + binding.downloadSummary.setText(R.string.ModuleUptodate); + } + }); + } + + + @Override + public void onInstalledModulesReloaded(ModuleUtil moduleUtil) { + notifyDataSetChanged(); + } + + @Override + public void onSingleInstalledModuleReloaded(ModuleUtil moduleUtil, String packageName, ModuleUtil.InstalledModule module) { + notifyDataSetChanged(); + } + + @Override + public void onRepoReloaded(RepoLoader loader) { + notifyDataSetChanged(); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + ModuleUtil.getInstance().removeListener(this); + repoLoader.removeListener(this); + } + +} diff --git a/app/src/main/java/org/meowcat/edxposed/manager/ui/activity/ModuleScopeActivity.java b/app/src/main/java/org/meowcat/edxposed/manager/ui/activity/ModuleScopeActivity.java new file mode 100644 index 00000000..27c57616 --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/ui/activity/ModuleScopeActivity.java @@ -0,0 +1,115 @@ +package org.meowcat.edxposed.manager.ui.activity; + +import android.content.pm.ApplicationInfo; +import android.os.Bundle; +import android.os.Handler; +import android.view.Menu; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.widget.SearchView; +import androidx.recyclerview.widget.DividerItemDecoration; + +import org.meowcat.edxposed.manager.App; +import org.meowcat.edxposed.manager.R; +import org.meowcat.edxposed.manager.adapters.AppAdapter; +import org.meowcat.edxposed.manager.adapters.AppHelper; +import org.meowcat.edxposed.manager.adapters.ScopeAdapter; +import org.meowcat.edxposed.manager.databinding.ActivityScopeListBinding; +import org.meowcat.edxposed.manager.util.LinearLayoutManagerFix; + +import me.zhanghai.android.fastscroll.FastScrollerBuilder; + +public class ModuleScopeActivity extends BaseActivity implements AppAdapter.Callback { + private SearchView searchView; + private ScopeAdapter appAdapter; + + private SearchView.OnQueryTextListener searchListener; + private ActivityScopeListBinding binding; + private final Runnable runnable = new Runnable() { + @Override + public void run() { + binding.swipeRefreshLayout.setRefreshing(true); + } + }; + private final Handler handler = new Handler(); + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + String modulePackageName = getIntent().getStringExtra("modulePackageName"); + String moduleName = getIntent().getStringExtra("moduleName"); + binding = ActivityScopeListBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + setSupportActionBar(binding.toolbar); + binding.toolbar.setNavigationOnClickListener(view -> finish()); + ActionBar bar = getSupportActionBar(); + if (bar != null) { + bar.setDisplayHomeAsUpEnabled(true); + bar.setSubtitle(moduleName); + } + setupWindowInsets(binding.snackbar, binding.recyclerView); + appAdapter = new ScopeAdapter(this, modulePackageName, binding.masterSwitch); + appAdapter.setHasStableIds(true); + binding.recyclerView.setAdapter(appAdapter); + binding.recyclerView.setLayoutManager(new LinearLayoutManagerFix(this)); + FastScrollerBuilder fastScrollerBuilder = new FastScrollerBuilder(binding.recyclerView); + if (!App.getPreferences().getBoolean("md2", false)) { + DividerItemDecoration dividerItemDecoration = new DividerItemDecoration(this, + DividerItemDecoration.VERTICAL); + binding.recyclerView.addItemDecoration(dividerItemDecoration); + } else { + fastScrollerBuilder.useMd2Style(); + } + fastScrollerBuilder.build(); + appAdapter.setCallback(this); + handler.postDelayed(runnable, 300); + binding.swipeRefreshLayout.setOnRefreshListener(() -> appAdapter.refresh()); + + searchListener = new SearchView.OnQueryTextListener() { + @Override + public boolean onQueryTextSubmit(String query) { + appAdapter.filter(query); + return false; + } + + @Override + public boolean onQueryTextChange(String newText) { + appAdapter.filter(newText); + return false; + } + }; + } + + @Override + public boolean onCreateOptionsMenu(@NonNull Menu menu) { + getMenuInflater().inflate(R.menu.menu_app_list, menu); + searchView = (SearchView) menu.findItem(R.id.menu_search).getActionView(); + searchView.setOnQueryTextListener(searchListener); + return super.onCreateOptionsMenu(menu); + } + + @Override + public void onDataReady() { + handler.removeCallbacks(runnable); + binding.swipeRefreshLayout.setRefreshing(false); + String queryStr = searchView != null ? searchView.getQuery().toString() : ""; + runOnUiThread(() -> appAdapter.getFilter().filter(queryStr)); + } + + @Override + public void onItemClick(View v, ApplicationInfo info) { + AppHelper.showMenu(this, getSupportFragmentManager(), v, info); + } + + @Override + public void onBackPressed() { + if (searchView.isIconified()) { + super.onBackPressed(); + } else { + searchView.setIconified(true); + } + } +} diff --git a/app/src/main/java/org/meowcat/edxposed/manager/ui/activity/ModulesActivity.java b/app/src/main/java/org/meowcat/edxposed/manager/ui/activity/ModulesActivity.java new file mode 100644 index 00000000..87688427 --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/ui/activity/ModulesActivity.java @@ -0,0 +1,690 @@ +package org.meowcat.edxposed.manager.ui.activity; + +import android.content.Intent; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.net.Uri; +import android.os.Bundle; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Filter; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.widget.SearchView; +import androidx.appcompat.widget.SwitchCompat; +import androidx.core.content.ContextCompat; +import androidx.recyclerview.widget.DividerItemDecoration; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.google.android.material.snackbar.Snackbar; + +import org.meowcat.edxposed.manager.App; +import org.meowcat.edxposed.manager.BuildConfig; +import org.meowcat.edxposed.manager.Constants; +import org.meowcat.edxposed.manager.R; +import org.meowcat.edxposed.manager.databinding.ActivityModulesBinding; +import org.meowcat.edxposed.manager.repo.Module; +import org.meowcat.edxposed.manager.repo.ModuleVersion; +import org.meowcat.edxposed.manager.repo.ReleaseType; +import org.meowcat.edxposed.manager.repo.RepoDb; +import org.meowcat.edxposed.manager.util.GlideApp; +import org.meowcat.edxposed.manager.util.InstallApkUtil; +import org.meowcat.edxposed.manager.util.LinearLayoutManagerFix; +import org.meowcat.edxposed.manager.util.ModuleUtil; +import org.meowcat.edxposed.manager.util.NavUtil; +import org.meowcat.edxposed.manager.util.RepoLoader; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.PrintWriter; +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.Date; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +import me.zhanghai.android.fastscroll.FastScrollerBuilder; + +import static android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS; + +public class ModulesActivity extends BaseActivity implements ModuleUtil.ModuleListener { + + public static final String SETTINGS_CATEGORY = "de.robv.android.xposed.category.MODULE_SETTINGS"; + ActivityModulesBinding binding; + private int installedXposedVersion; + private ApplicationFilter filter; + private SearchView searchView; + private ApplicationInfo.DisplayNameComparator displayNameComparator; + private SearchView.OnQueryTextListener mSearchListener; + private PackageManager pm; + private Comparator cmp; + private final DateFormat dateformat = new SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()); + private ModuleUtil moduleUtil; + private ModuleAdapter adapter = null; + private final Runnable reloadModules = new Runnable() { + public void run() { + String queryStr = searchView != null ? searchView.getQuery().toString() : ""; + ArrayList showList; + ArrayList fullList = new ArrayList<>(moduleUtil.getModules().values()); + if (queryStr.length() == 0) { + showList = fullList; + } else { + showList = new ArrayList<>(); + String filter = queryStr.toLowerCase(); + for (ModuleUtil.InstalledModule info : fullList) { + if (lowercaseContains(InstallApkUtil.getAppLabel(info.app, pm), filter) + || lowercaseContains(info.packageName, filter)) { + showList.add(info); + } + } + } + switch (App.getPreferences().getInt("list_sort", 0)) { + case 7: + cmp = Collections.reverseOrder((ApplicationInfo a, ApplicationInfo b) -> { + try { + return Long.compare(pm.getPackageInfo(a.packageName, 0).lastUpdateTime, pm.getPackageInfo(b.packageName, 0).lastUpdateTime); + } catch (PackageManager.NameNotFoundException e) { + e.printStackTrace(); + return displayNameComparator.compare(a, b); + } + }); + break; + case 6: + cmp = (ApplicationInfo a, ApplicationInfo b) -> { + try { + return Long.compare(pm.getPackageInfo(a.packageName, 0).lastUpdateTime, pm.getPackageInfo(b.packageName, 0).lastUpdateTime); + } catch (PackageManager.NameNotFoundException e) { + e.printStackTrace(); + return displayNameComparator.compare(a, b); + } + }; + break; + case 5: + cmp = Collections.reverseOrder((ApplicationInfo a, ApplicationInfo b) -> { + try { + return Long.compare(pm.getPackageInfo(a.packageName, 0).firstInstallTime, pm.getPackageInfo(b.packageName, 0).firstInstallTime); + } catch (PackageManager.NameNotFoundException e) { + e.printStackTrace(); + return displayNameComparator.compare(a, b); + } + }); + break; + case 4: + cmp = (ApplicationInfo a, ApplicationInfo b) -> { + try { + return Long.compare(pm.getPackageInfo(a.packageName, 0).firstInstallTime, pm.getPackageInfo(b.packageName, 0).firstInstallTime); + } catch (PackageManager.NameNotFoundException e) { + e.printStackTrace(); + return displayNameComparator.compare(a, b); + } + }; + break; + case 3: + cmp = Collections.reverseOrder((a, b) -> a.packageName.compareTo(b.packageName)); + break; + case 2: + cmp = (a, b) -> a.packageName.compareTo(b.packageName); + break; + case 1: + cmp = Collections.reverseOrder(displayNameComparator); + break; + case 0: + default: + cmp = displayNameComparator; + break; + } + fullList.sort((a, b) -> { + boolean aChecked = moduleUtil.isModuleEnabled(a.packageName); + boolean bChecked = moduleUtil.isModuleEnabled(b.packageName); + if (aChecked == bChecked) { + return cmp.compare(a.app, b.app); + } else if (aChecked) { + return -1; + } else { + return 1; + } + }); + adapter.addAll(showList); + adapter.notifyDataSetChanged(); + moduleUtil.updateModulesList(false); + binding.swipeRefreshLayout.setRefreshing(false); + } + }; + private String selectedPackageName; + + private void filter(String constraint) { + filter.filter(constraint); + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + binding = ActivityModulesBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + setSupportActionBar(binding.toolbar); + binding.toolbar.setNavigationOnClickListener(view -> finish()); + ActionBar bar = getSupportActionBar(); + if (bar != null) { + bar.setDisplayHomeAsUpEnabled(true); + } + setupWindowInsets(binding.snackbar, binding.recyclerView); + filter = new ApplicationFilter(); + moduleUtil = ModuleUtil.getInstance(); + pm = getPackageManager(); + displayNameComparator = new ApplicationInfo.DisplayNameComparator(pm); + cmp = displayNameComparator; + installedXposedVersion = Constants.getXposedApiVersion(); + if (installedXposedVersion <= 0) { + Snackbar.make(binding.snackbar, R.string.xposed_not_active, Snackbar.LENGTH_LONG).setAction(R.string.Settings, v -> { + Intent intent = new Intent(); + intent.setClass(ModulesActivity.this, SettingsActivity.class); + startActivity(intent); + }).show(); + } + adapter = new ModuleAdapter(); + adapter.setHasStableIds(true); + moduleUtil.addListener(this); + binding.recyclerView.setAdapter(adapter); + binding.recyclerView.setLayoutManager(new LinearLayoutManagerFix(this)); + FastScrollerBuilder fastScrollerBuilder = new FastScrollerBuilder(binding.recyclerView); + if (!App.getPreferences().getBoolean("md2", false)) { + DividerItemDecoration dividerItemDecoration = new DividerItemDecoration(this, + DividerItemDecoration.VERTICAL); + binding.recyclerView.addItemDecoration(dividerItemDecoration); + } else { + fastScrollerBuilder.useMd2Style(); + } + fastScrollerBuilder.build(); + binding.swipeRefreshLayout.setOnRefreshListener(reloadModules::run); + reloadModules.run(); + mSearchListener = new SearchView.OnQueryTextListener() { + @Override + public boolean onQueryTextSubmit(String query) { + filter(query); + return false; + } + + @Override + public boolean onQueryTextChange(String newText) { + filter(newText); + return false; + } + }; + + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.menu_modules, menu); + searchView = (SearchView) menu.findItem(R.id.menu_search).getActionView(); + searchView.setOnQueryTextListener(mSearchListener); + return super.onCreateOptionsMenu(menu); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (resultCode != RESULT_OK) { + return; + } + if (requestCode == 42) { + File listModules = new File(Constants.getEnabledModulesListFile()); + if (data != null) { + Uri uri = data.getData(); + if (uri != null) { + try { + OutputStream os = getContentResolver().openOutputStream(uri); + if (os != null) { + FileInputStream in = new FileInputStream(listModules); + byte[] buffer = new byte[1024]; + int len; + while ((len = in.read(buffer)) > 0) { + os.write(buffer, 0, len); + } + os.close(); + } + } catch (Exception e) { + Snackbar.make(binding.snackbar, getResources().getString(R.string.logs_save_failed) + "\n" + e.getMessage(), Snackbar.LENGTH_LONG).show(); + } + } + } + } else if (requestCode == 43) { + if (data != null) { + Uri uri = data.getData(); + if (uri != null) { + try { + OutputStream os = getContentResolver().openOutputStream(uri); + if (os != null) { + PrintWriter fileOut = new PrintWriter(os); + + Set keys = ModuleUtil.getInstance().getModules().keySet(); + for (String key1 : keys) { + fileOut.println(key1); + } + fileOut.close(); + os.close(); + } + } catch (Exception e) { + Snackbar.make(binding.snackbar, getResources().getString(R.string.logs_save_failed) + "\n" + e.getMessage(), Snackbar.LENGTH_LONG).show(); + } + } + } + } else if (requestCode == 44) { + if (data != null) { + Uri uri = data.getData(); + if (uri != null) { + try { + importModules(uri); + } catch (Exception e) { + e.printStackTrace(); + } + } + } + } + } + + @Override + public boolean onOptionsItemSelected(@NonNull MenuItem item) { + Intent intent; + int itemId = item.getItemId(); + if (itemId == R.id.export_enabled_modules) { + if (ModuleUtil.getInstance().getEnabledModules().isEmpty()) { + Snackbar.make(binding.snackbar, R.string.no_enabled_modules, Snackbar.LENGTH_SHORT).show(); + return false; + } + intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType("text/*"); + intent.putExtra(Intent.EXTRA_TITLE, "enabled_modules.list"); + startActivityForResult(intent, 42); + return true; + } else if (itemId == R.id.export_installed_modules) { + Map installedModules = ModuleUtil.getInstance().getModules(); + + if (installedModules.isEmpty()) { + Snackbar.make(binding.snackbar, R.string.no_installed_modules, Snackbar.LENGTH_SHORT).show(); + return false; + } + intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType("text/*"); + intent.putExtra(Intent.EXTRA_TITLE, "installed_modules.list"); + startActivityForResult(intent, 43); + return true; + } else if (itemId == R.id.import_installed_modules || itemId == R.id.import_enabled_modules) { + intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType("*/*"); + startActivityForResult(intent, 44); + return true; + } + return super.onOptionsItemSelected(item); + } + + private void importModules(Uri uri) { + RepoLoader repoLoader = RepoLoader.getInstance(); + List list = new ArrayList<>(); + + try { + InputStream inputStream = getContentResolver().openInputStream(uri); + InputStreamReader isr = new InputStreamReader(inputStream); + BufferedReader br = new BufferedReader(isr); + String line; + while ((line = br.readLine()) != null) { + Module m = repoLoader.getModule(line); + + if (m == null) { + Snackbar.make(binding.snackbar, getString(R.string.download_details_not_found, line), Snackbar.LENGTH_SHORT).show(); + } else { + list.add(m); + } + } + br.close(); + } catch (Exception e) { + e.printStackTrace(); + } + + for (final Module m : list) { + if (moduleUtil.getModule(m.packageName) != null) { + continue; + } + ModuleVersion mv = null; + for (int i = 0; i < m.versions.size(); i++) { + ModuleVersion mvTemp = m.versions.get(i); + + if (mvTemp.relType == ReleaseType.STABLE) { + mv = mvTemp; + break; + } + } + + if (mv != null) { + NavUtil.startURL(this, mv.downloadLink); + } + } + + ModuleUtil.getInstance().reloadInstalledModules(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + moduleUtil.removeListener(this); + binding.recyclerView.setAdapter(null); + adapter = null; + } + + @Override + public void onSingleInstalledModuleReloaded(ModuleUtil moduleUtil, String packageName, ModuleUtil.InstalledModule module) { + moduleUtil.updateModulesList(false); + runOnUiThread(reloadModules); + } + + @Override + public void onInstalledModulesReloaded(ModuleUtil moduleUtil) { + moduleUtil.updateModulesList(false); + runOnUiThread(reloadModules); + } + + @Override + public boolean onContextItemSelected(@NonNull MenuItem item) { + ModuleUtil.InstalledModule module = ModuleUtil.getInstance().getModule(selectedPackageName); + if (module == null) { + return false; + } + int itemId = item.getItemId(); + if (itemId == R.id.menu_launch) { + String packageName = module.packageName; + if (packageName == null) { + return false; + } + Intent intent = getSettingsIntent(packageName); + if (intent != null) { + startActivity(intent); + } else { + Snackbar.make(binding.snackbar, R.string.module_no_ui, Snackbar.LENGTH_LONG).show(); + } + return true; + } else if (itemId == R.id.menu_download_updates) { + Intent intent = new Intent(this, DownloadDetailsActivity.class); + intent.setData(Uri.fromParts("package", module.packageName, null)); + startActivity(intent); + return true; + } else if (itemId == R.id.menu_support) { + NavUtil.startURL(this, Uri.parse(RepoDb.getModuleSupport(module.packageName))); + return true; + } else if (itemId == R.id.menu_app_store) { + Uri uri = Uri.parse("market://details?id=" + module.packageName); + Intent intent = new Intent(Intent.ACTION_VIEW, uri); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + try { + startActivity(intent); + } catch (Exception e) { + e.printStackTrace(); + } + return true; + } else if (itemId == R.id.menu_app_info) { + startActivity(new Intent(ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", module.packageName, null))); + return true; + } else if (itemId == R.id.menu_uninstall) { + startActivity(new Intent(Intent.ACTION_UNINSTALL_PACKAGE, Uri.fromParts("package", module.packageName, null))); + return true; + } else if (itemId == R.id.menu_scope) { + if (App.supportScope()) { + Intent intent = new Intent(this, ModuleScopeActivity.class); + intent.putExtra("modulePackageName", module.packageName); + intent.putExtra("moduleName", module.getAppName()); + startActivity(intent); + } else { + new MaterialAlertDialogBuilder(this) + .setMessage(R.string.scope_not_supported) + .setPositiveButton(R.string.download_view_download, (dialog, which) -> { + Intent intent = new Intent(); + intent.setClass(this, EdDownloadActivity.class); + startActivity(intent); + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + return true; + } + return super.onContextItemSelected(item); + } + + private Intent getSettingsIntent(String packageName) { + // taken from + // ApplicationPackageManager.getLaunchIntentForPackage(String) + // first looks for an Xposed-specific category, falls back to + // getLaunchIntentForPackage + PackageManager pm = getPackageManager(); + + Intent intentToResolve = new Intent(Intent.ACTION_MAIN); + intentToResolve.addCategory(SETTINGS_CATEGORY); + intentToResolve.setPackage(packageName); + List ris = pm.queryIntentActivities(intentToResolve, 0); + + if (ris.size() <= 0) { + return pm.getLaunchIntentForPackage(packageName); + } + + Intent intent = new Intent(intentToResolve); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.setClassName(ris.get(0).activityInfo.packageName, ris.get(0).activityInfo.name); + return intent; + } + + private boolean lowercaseContains(String s, CharSequence filter) { + return !TextUtils.isEmpty(s) && s.toLowerCase().contains(filter); + } + + @Override + public void onBackPressed() { + if (searchView.isIconified()) { + super.onBackPressed(); + } else { + searchView.setIconified(true); + } + } + + private class ModuleAdapter extends RecyclerView.Adapter { + ArrayList items = new ArrayList<>(); + + @NonNull + @Override + public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_module, parent, false); + return new ViewHolder(v); + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, int position) { + ModuleUtil.InstalledModule item = items.get(position); + holder.itemView.setOnClickListener(v -> { + String packageName = item.packageName; + if (packageName == null || packageName.equals(BuildConfig.APPLICATION_ID)) { + return; + } + Intent launchIntent = getSettingsIntent(packageName); + if (launchIntent != null) { + startActivity(launchIntent); + } else { + Snackbar.make(findViewById(R.id.snackbar), R.string.module_no_ui, Snackbar.LENGTH_LONG).show(); + } + }); + + holder.itemView.setOnLongClickListener(v -> { + selectedPackageName = item.packageName; + return false; + }); + + holder.itemView.setOnCreateContextMenuListener((menu, v, menuInfo) -> { + getMenuInflater().inflate(R.menu.context_menu_modules, menu); + ModuleUtil.InstalledModule installedModule = ModuleUtil.getInstance().getModule(item.packageName); + if (installedModule == null) { + return; + } + try { + String support = RepoDb.getModuleSupport(installedModule.packageName); + if (NavUtil.parseURL(support) == null) { + menu.removeItem(R.id.menu_support); + } + } catch (RepoDb.RowNotFoundException e) { + menu.removeItem(R.id.menu_download_updates); + menu.removeItem(R.id.menu_support); + } + if (installedModule.packageName.equals(BuildConfig.APPLICATION_ID)) { + menu.removeItem(R.id.menu_launch); + menu.removeItem(R.id.menu_scope); + menu.removeItem(R.id.menu_uninstall); + } + }); + holder.appName.setText(item.getAppName()); + + TextView version = holder.appVersion; + version.setText(Objects.requireNonNull(item).versionName); + version.setSelected(true); + + TextView packageTv = holder.appPackage; + packageTv.setText(item.packageName); + + String creationDate = dateformat.format(new Date(item.installTime)); + String updateDate = dateformat.format(new Date(item.updateTime)); + holder.timestamps.setText(getString(R.string.install_timestamps, creationDate, updateDate)); + + GlideApp.with(holder.appIcon) + .load(item.getPackageInfo()) + .into(holder.appIcon); + + TextView descriptionText = holder.appDescription; + descriptionText.setVisibility(View.VISIBLE); + if (!item.getDescription().isEmpty()) { + descriptionText.setText(item.getDescription()); + } else { + descriptionText.setText(getString(R.string.module_empty_description)); + descriptionText.setTextColor(ContextCompat.getColor(ModulesActivity.this, R.color.warning)); + } + + SwitchCompat mSwitch = holder.mSwitch; + mSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> { + String packageName = item.packageName; + boolean changed = moduleUtil.isModuleEnabled(packageName) ^ isChecked; + if (changed) { + moduleUtil.setModuleEnabled(packageName, isChecked); + moduleUtil.updateModulesList(true, binding); + } + }); + mSwitch.setChecked(moduleUtil.isModuleEnabled(item.packageName)); + TextView warningText = holder.warningText; + + if (item.minVersion == 0) { + if (!App.getPreferences().getBoolean("skip_xposedminversion_check", false)) { + mSwitch.setEnabled(false); + } + warningText.setText(getString(R.string.no_min_version_specified)); + warningText.setVisibility(View.VISIBLE); + } else if (installedXposedVersion > 0 && item.minVersion > installedXposedVersion) { + if (!App.getPreferences().getBoolean("skip_xposedminversion_check", false)) { + mSwitch.setEnabled(false); + } + warningText.setText(String.format(getString(R.string.warning_xposed_min_version), item.minVersion)); + warningText.setVisibility(View.VISIBLE); + } else if (item.minVersion < ModuleUtil.MIN_MODULE_VERSION) { + if (!App.getPreferences().getBoolean("skip_xposedminversion_check", false)) { + mSwitch.setEnabled(false); + } + warningText.setText(String.format(getString(R.string.warning_min_version_too_low), item.minVersion, ModuleUtil.MIN_MODULE_VERSION)); + warningText.setVisibility(View.VISIBLE); + } else if (item.isInstalledOnExternalStorage()) { + if (!App.getPreferences().getBoolean("skip_xposedminversion_check", false)) { + mSwitch.setEnabled(false); + } + warningText.setText(getString(R.string.warning_installed_on_external_storage)); + warningText.setVisibility(View.VISIBLE); + } else if (installedXposedVersion == 0 || (installedXposedVersion == -1)) { + if (!App.getPreferences().getBoolean("skip_xposedminversion_check", false)) { + mSwitch.setEnabled(false); + } + warningText.setText(getString(R.string.not_installed_no_lollipop)); + warningText.setVisibility(View.VISIBLE); + } else { + mSwitch.setEnabled(true); + warningText.setVisibility(View.GONE); + } + } + + void addAll(ArrayList items) { + this.items = items; + notifyDataSetChanged(); + } + + @Override + public int getItemCount() { + if (items != null) { + return items.size(); + } else { + return 0; + } + } + + @Override + public long getItemId(int position) { + return items.get(position).hashCode(); + } + + class ViewHolder extends RecyclerView.ViewHolder { + ImageView appIcon; + TextView appName; + TextView appPackage; + TextView appDescription; + TextView appVersion; + TextView timestamps; + TextView warningText; + SwitchCompat mSwitch; + + ViewHolder(View itemView) { + super(itemView); + appIcon = itemView.findViewById(R.id.app_icon); + appName = itemView.findViewById(R.id.app_name); + appDescription = itemView.findViewById(R.id.description); + appPackage = itemView.findViewById(R.id.package_name); + appVersion = itemView.findViewById(R.id.version_name); + timestamps = itemView.findViewById(R.id.timestamps); + warningText = itemView.findViewById(R.id.warning); + mSwitch = itemView.findViewById(R.id.checkbox); + } + } + } + + class ApplicationFilter extends Filter { + + @Override + protected FilterResults performFiltering(CharSequence constraint) { + runOnUiThread(reloadModules); + return null; + } + + @Override + protected void publishResults(CharSequence constraint, FilterResults results) { + runOnUiThread(reloadModules); + } + } +} diff --git a/app/src/main/java/org/meowcat/edxposed/manager/ui/activity/SettingsActivity.java b/app/src/main/java/org/meowcat/edxposed/manager/ui/activity/SettingsActivity.java new file mode 100644 index 00000000..ae83fb2b --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/ui/activity/SettingsActivity.java @@ -0,0 +1,507 @@ +package org.meowcat.edxposed.manager.ui.activity; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.os.Bundle; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.LinearLayout; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AppCompatDelegate; +import androidx.preference.Preference; +import androidx.preference.SwitchPreferenceCompat; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.takisoft.preferencex.PreferenceFragmentCompat; +import com.topjohnwu.superuser.Shell; + +import org.meowcat.edxposed.manager.Constants; +import org.meowcat.edxposed.manager.R; +import org.meowcat.edxposed.manager.databinding.ActivitySettingsBinding; +import org.meowcat.edxposed.manager.ui.widget.IntegerListPreference; +import org.meowcat.edxposed.manager.util.RepoLoader; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; + +public class SettingsActivity extends BaseActivity { + private static final String KEY_PREFIX = SettingsActivity.class.getName() + '.'; + private static final String EXTRA_SAVED_INSTANCE_STATE = KEY_PREFIX + "SAVED_INSTANCE_STATE"; + ActivitySettingsBinding binding; + private boolean restarting; + + @NonNull + public static Intent newIntent(@NonNull Context context) { + return new Intent(context, SettingsActivity.class); + } + + @NonNull + private static Intent newIntent(@NonNull Bundle savedInstanceState, @NonNull Context context) { + return newIntent(context) + .putExtra(EXTRA_SAVED_INSTANCE_STATE, savedInstanceState); + } + + @Override + public void onCreate(Bundle savedInstanceState) { + if (savedInstanceState == null) { + savedInstanceState = getIntent().getBundleExtra(EXTRA_SAVED_INSTANCE_STATE); + } + super.onCreate(savedInstanceState); + binding = ActivitySettingsBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + setSupportActionBar(binding.toolbar); + binding.toolbar.setNavigationOnClickListener(view -> finish()); + ActionBar bar = getSupportActionBar(); + if (bar != null) { + bar.setDisplayHomeAsUpEnabled(true); + } + setupWindowInsets(binding.snackbar, null); + if (savedInstanceState == null) { + getSupportFragmentManager().beginTransaction() + .add(R.id.container, new SettingsFragment()).commit(); + } + } + + private void restart() { + Bundle savedInstanceState = new Bundle(); + onSaveInstanceState(savedInstanceState); + finish(); + startActivity(newIntent(savedInstanceState, this)); + overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out); + restarting = true; + } + + @Override + public boolean dispatchKeyEvent(@NonNull KeyEvent event) { + return restarting || super.dispatchKeyEvent(event); + } + + @SuppressLint("RestrictedApi") + @Override + public boolean dispatchKeyShortcutEvent(@NonNull KeyEvent event) { + return restarting || super.dispatchKeyShortcutEvent(event); + } + + @Override + public boolean dispatchTouchEvent(@NonNull MotionEvent event) { + return restarting || super.dispatchTouchEvent(event); + } + + @Override + public boolean dispatchTrackballEvent(@NonNull MotionEvent event) { + return restarting || super.dispatchTrackballEvent(event); + } + + @Override + public boolean dispatchGenericMotionEvent(@NonNull MotionEvent event) { + return restarting || super.dispatchGenericMotionEvent(event); + } + + @SuppressWarnings({"ResultOfMethodCallIgnored"}) + public static class SettingsFragment extends PreferenceFragmentCompat { + private static final File enableResourcesFlag = new File(Constants.getBaseDir() + "conf/enable_resources"); + private static final File dynamicModulesFlag = new File(Constants.getBaseDir() + "conf/dynamicmodules"); + private static final File deoptBootFlag = new File(Constants.getBaseDir() + "conf/deoptbootimage"); + private static final File whiteListModeFlag = new File(Constants.getBaseDir() + "conf/usewhitelist"); + private static final File blackWhiteListModeFlag = new File(Constants.getBaseDir() + "conf/blackwhitelist"); + private static final File disableVerboseLogsFlag = new File(Constants.getBaseDir() + "conf/disable_verbose_log"); + private static final File disableModulesLogsFlag = new File(Constants.getBaseDir() + "conf/disable_modules_log"); + private static final File verboseLogProcessID = new File(Constants.getBaseDir() + "log/all.pid"); + private static final File modulesLogProcessID = new File(Constants.getBaseDir() + "log/error.pid"); + + @SuppressLint({"ObsoleteSdkInt", "WorldReadableFiles"}) + @Override + public void onCreatePreferencesFix(Bundle savedInstanceState, String rootKey) { + addPreferencesFromResource(R.xml.prefs); + + Preference stopVerboseLog = findPreference("stop_verbose_log"); + if (stopVerboseLog != null) { + stopVerboseLog.setOnPreferenceClickListener(preference -> { + areYouSure(R.string.stop_verbose_log_summary, (dialog, which) -> Shell.su("pkill -P $(cat " + verboseLogProcessID.getAbsolutePath() + ")").exec()); + return true; + }); + } + Preference stopLog = findPreference("stop_log"); + if (stopLog != null) { + stopLog.setOnPreferenceClickListener(preference -> { + areYouSure(R.string.stop_log_summary, (dialog, which) -> Shell.su("pkill -P $(cat " + modulesLogProcessID.getAbsolutePath() + ")").exec()); + return true; + }); + } + + Preference releaseType = findPreference("release_type_global"); + if (releaseType != null) { + releaseType.setOnPreferenceChangeListener((preference, newValue) -> { + RepoLoader.getInstance().setReleaseTypeGlobal((String) newValue); + return true; + }); + } + + SwitchPreferenceCompat prefWhiteListMode = findPreference("white_list_switch"); + if (prefWhiteListMode != null) { + prefWhiteListMode.setChecked(whiteListModeFlag.exists()); + prefWhiteListMode.setOnPreferenceChangeListener((preference, newValue) -> { + boolean enabled = (Boolean) newValue; + if (enabled) { + FileOutputStream fos = null; + try { + fos = new FileOutputStream(whiteListModeFlag.getPath()); + } catch (FileNotFoundException e) { + Toast.makeText(getActivity(), e.getMessage(), Toast.LENGTH_SHORT).show(); + } finally { + if (fos != null) { + try { + fos.close(); + } catch (IOException e) { + Toast.makeText(getActivity(), e.getMessage(), Toast.LENGTH_SHORT).show(); + try { + whiteListModeFlag.createNewFile(); + } catch (IOException e1) { + Toast.makeText(getActivity(), e1.getMessage(), Toast.LENGTH_SHORT).show(); + } + } + } + } + } else { + whiteListModeFlag.delete(); + } + return (enabled == whiteListModeFlag.exists()); + }); + } + + SwitchPreferenceCompat prefVerboseLogs = findPreference("disable_verbose_log"); + if (prefVerboseLogs != null) { + prefVerboseLogs.setChecked(disableVerboseLogsFlag.exists()); + prefVerboseLogs.setOnPreferenceChangeListener((preference, newValue) -> { + boolean enabled = (Boolean) newValue; + if (enabled) { + FileOutputStream fos = null; + try { + fos = new FileOutputStream(disableVerboseLogsFlag.getPath()); + } catch (FileNotFoundException e) { + Toast.makeText(getActivity(), e.getMessage(), Toast.LENGTH_SHORT).show(); + } finally { + if (fos != null) { + try { + fos.close(); + } catch (IOException e) { + Toast.makeText(getActivity(), e.getMessage(), Toast.LENGTH_SHORT).show(); + try { + disableVerboseLogsFlag.createNewFile(); + } catch (IOException e1) { + Toast.makeText(getActivity(), e1.getMessage(), Toast.LENGTH_SHORT).show(); + } + } + } + } + } else { + disableVerboseLogsFlag.delete(); + } + return (enabled == disableVerboseLogsFlag.exists()); + }); + } + + SwitchPreferenceCompat prefModulesLogs = findPreference("disable_modules_log"); + if (prefModulesLogs != null) { + prefModulesLogs.setChecked(disableModulesLogsFlag.exists()); + prefModulesLogs.setOnPreferenceChangeListener((preference, newValue) -> { + boolean enabled = (Boolean) newValue; + if (enabled) { + FileOutputStream fos = null; + try { + fos = new FileOutputStream(disableModulesLogsFlag.getPath()); + } catch (FileNotFoundException e) { + Toast.makeText(getActivity(), e.getMessage(), Toast.LENGTH_SHORT).show(); + } finally { + if (fos != null) { + try { + fos.close(); + } catch (IOException e) { + Toast.makeText(getActivity(), e.getMessage(), Toast.LENGTH_SHORT).show(); + try { + disableModulesLogsFlag.createNewFile(); + } catch (IOException e1) { + Toast.makeText(getActivity(), e1.getMessage(), Toast.LENGTH_SHORT).show(); + } + } + } + } + } else { + disableModulesLogsFlag.delete(); + } + return (enabled == disableModulesLogsFlag.exists()); + }); + } + + SwitchPreferenceCompat prefBlackWhiteListMode = findPreference("black_white_list_switch"); + if (prefBlackWhiteListMode != null) { + prefBlackWhiteListMode.setChecked(blackWhiteListModeFlag.exists()); + prefBlackWhiteListMode.setOnPreferenceChangeListener((preference, newValue) -> { + boolean enabled = (Boolean) newValue; + if (enabled) { + FileOutputStream fos = null; + try { + fos = new FileOutputStream(blackWhiteListModeFlag.getPath()); + } catch (FileNotFoundException e) { + Toast.makeText(getActivity(), e.getMessage(), Toast.LENGTH_SHORT).show(); + } finally { + if (fos != null) { + try { + fos.close(); + } catch (IOException e) { + Toast.makeText(getActivity(), e.getMessage(), Toast.LENGTH_SHORT).show(); + try { + blackWhiteListModeFlag.createNewFile(); + } catch (IOException e1) { + Toast.makeText(getActivity(), e1.getMessage(), Toast.LENGTH_SHORT).show(); + } + } + } + } + } else { + blackWhiteListModeFlag.delete(); + } + return (enabled == blackWhiteListModeFlag.exists()); + }); + } + + SwitchPreferenceCompat prefEnableDeopt = findPreference("enable_boot_image_deopt"); + if (prefEnableDeopt != null) { + prefEnableDeopt.setChecked(deoptBootFlag.exists()); + prefEnableDeopt.setOnPreferenceChangeListener((preference, newValue) -> { + boolean enabled = (Boolean) newValue; + if (enabled) { + FileOutputStream fos = null; + try { + fos = new FileOutputStream(deoptBootFlag.getPath()); + } catch (FileNotFoundException e) { + Toast.makeText(getActivity(), e.getMessage(), Toast.LENGTH_SHORT).show(); + } finally { + if (fos != null) { + try { + fos.close(); + } catch (IOException e) { + Toast.makeText(getActivity(), e.getMessage(), Toast.LENGTH_SHORT).show(); + try { + deoptBootFlag.createNewFile(); + } catch (IOException e1) { + Toast.makeText(getActivity(), e1.getMessage(), Toast.LENGTH_SHORT).show(); + } + } + } + } + } else { + deoptBootFlag.delete(); + } + return (enabled == deoptBootFlag.exists()); + }); + } + + SwitchPreferenceCompat prefDynamicResources = findPreference("is_dynamic_modules"); + if (prefDynamicResources != null) { + prefDynamicResources.setChecked(dynamicModulesFlag.exists()); + prefDynamicResources.setOnPreferenceChangeListener((preference, newValue) -> { + boolean enabled = (Boolean) newValue; + if (enabled) { + FileOutputStream fos = null; + try { + fos = new FileOutputStream(dynamicModulesFlag.getPath()); + } catch (FileNotFoundException e) { + Toast.makeText(getActivity(), e.getMessage(), Toast.LENGTH_SHORT).show(); + } finally { + if (fos != null) { + try { + fos.close(); + } catch (IOException e) { + Toast.makeText(getActivity(), e.getMessage(), Toast.LENGTH_SHORT).show(); + try { + dynamicModulesFlag.createNewFile(); + } catch (IOException e1) { + Toast.makeText(getActivity(), e1.getMessage(), Toast.LENGTH_SHORT).show(); + } + } + } + } + } else { + dynamicModulesFlag.delete(); + } + return (enabled == dynamicModulesFlag.exists()); + }); + } + + SwitchPreferenceCompat prefDisableResources = findPreference("disable_resources"); + if (prefDisableResources != null) { + prefDisableResources.setChecked(!enableResourcesFlag.exists()); + prefDisableResources.setOnPreferenceChangeListener((preference, newValue) -> { + boolean enabled = (Boolean) newValue; + if (!enabled) { + FileOutputStream fos = null; + try { + fos = new FileOutputStream(enableResourcesFlag.getPath()); + } catch (FileNotFoundException e) { + Toast.makeText(getActivity(), e.getMessage(), Toast.LENGTH_SHORT).show(); + } finally { + if (fos != null) { + try { + fos.close(); + } catch (IOException e) { + Toast.makeText(getActivity(), e.getMessage(), Toast.LENGTH_SHORT).show(); + try { + enableResourcesFlag.createNewFile(); + } catch (IOException e1) { + Toast.makeText(getActivity(), e1.getMessage(), Toast.LENGTH_SHORT).show(); + } + } + } + } + } else { + enableResourcesFlag.delete(); + } + return (enabled != enableResourcesFlag.exists()); + }); + } + + SwitchPreferenceCompat transparent = findPreference("transparent_status_bar"); + if (transparent != null) { + transparent.setOnPreferenceChangeListener((preference, newValue) -> { + boolean enabled = (Boolean) newValue; + SettingsActivity activity = (SettingsActivity) getActivity(); + if (activity != null) { + if (enabled) { + activity.getWindow().setStatusBarColor(activity.getThemedColor(R.attr.colorActionBar)); + } else { + activity.getWindow().setStatusBarColor(activity.getThemedColor(R.attr.colorPrimaryDark)); + } + } + return true; + }); + } + + Preference compat_mode = findPreference("compat_mode"); + if (compat_mode != null) { + compat_mode.setOnPreferenceClickListener(preference -> { + Activity activity = getActivity(); + if (activity != null) { + Intent intent = new Intent(); + intent.putExtra("compat_list", true); + intent.setClass(activity, BlackListActivity.class); + activity.startActivity(intent); + } + return true; + }); + } + + IntegerListPreference theme = findPreference("theme"); + if (theme != null) { + theme.setOnPreferenceChangeListener((preference, newValue) -> { + AppCompatDelegate.setDefaultNightMode(Integer.parseInt((String) newValue)); + return true; + }); + } + + SwitchPreferenceCompat black_dark_theme = findPreference("black_dark_theme"); + if (black_dark_theme != null) { + black_dark_theme.setOnPreferenceChangeListener((preference, newValue) -> { + SettingsActivity activity = (SettingsActivity) getActivity(); + if (activity != null && isNightMode(getResources().getConfiguration())) { + activity.restart(); + } + return true; + }); + } + + Preference primary_color = findPreference("primary_color"); + if (primary_color != null) { + primary_color.setOnPreferenceChangeListener((preference, newValue) -> { + SettingsActivity activity = (SettingsActivity) getActivity(); + if (activity != null) { + activity.restart(); + } + return true; + }); + } + + Preference accent_color = findPreference("accent_color"); + if (accent_color != null) { + accent_color.setOnPreferenceChangeListener((preference, newValue) -> { + SettingsActivity activity = (SettingsActivity) getActivity(); + if (activity != null) { + activity.restart(); + } + return true; + }); + } + + Preference colorized_action_bar = findPreference("colorized_action_bar"); + if (colorized_action_bar != null) { + colorized_action_bar.setOnPreferenceChangeListener((preference, newValue) -> { + SettingsActivity activity = (SettingsActivity) getActivity(); + if (activity != null && !(isBlackNightTheme() && isNightMode(getResources().getConfiguration()))) { + activity.restart(); + } + return true; + }); + } + + SwitchPreferenceCompat md2 = findPreference("md2"); + if (md2 != null) { + md2.setOnPreferenceChangeListener((preference, newValue) -> { + SettingsActivity activity = (SettingsActivity) getActivity(); + if (activity != null) { + updatePreference(!md2.isChecked()); + activity.restart(); + } + return true; + }); + updatePreference(!md2.isChecked()); + } + } + + private void areYouSure(int contentTextId, DialogInterface.OnClickListener listener) { + Activity activity = getActivity(); + if (activity != null) { + new MaterialAlertDialogBuilder(activity) + .setTitle(R.string.areyousure) + .setMessage(contentTextId) + .setPositiveButton(android.R.string.yes, listener) + .setNegativeButton(android.R.string.no, null) + .show(); + } + } + + private void updatePreference(boolean show) { + Preference black_dark_theme = findPreference("black_dark_theme"); + if (black_dark_theme != null) { + black_dark_theme.setVisible(show); + } + Preference transparent_status_bar = findPreference("transparent_status_bar"); + if (transparent_status_bar != null) { + transparent_status_bar.setVisible(show); + } + Preference colorized_action_bar = findPreference("colorized_action_bar"); + if (colorized_action_bar != null) { + colorized_action_bar.setVisible(show); + } + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + ((LinearLayout) view).setClipToPadding(false); + ((LinearLayout) view).setClipChildren(false); + ((FrameLayout) getListView().getParent()).setClipChildren(false); + ((FrameLayout) getListView().getParent()).setClipToPadding(false); + } + } +} diff --git a/app/src/main/java/org/meowcat/edxposed/manager/ui/fragment/BaseAdvancedInstaller.java b/app/src/main/java/org/meowcat/edxposed/manager/ui/fragment/BaseAdvancedInstaller.java new file mode 100644 index 00000000..da1c019d --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/ui/fragment/BaseAdvancedInstaller.java @@ -0,0 +1,148 @@ +package org.meowcat.edxposed.manager.ui.fragment; + +import android.app.Activity; +import android.content.DialogInterface; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.TooltipCompat; +import androidx.core.text.HtmlCompat; +import androidx.fragment.app.Fragment; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import org.meowcat.edxposed.manager.R; +import org.meowcat.edxposed.manager.databinding.SingleInstallerViewBinding; +import org.meowcat.edxposed.manager.ui.activity.BaseActivity; +import org.meowcat.edxposed.manager.util.NavUtil; +import org.meowcat.edxposed.manager.util.json.XposedTab; +import org.meowcat.edxposed.manager.util.json.XposedZip; + +import java.util.Objects; + +public class BaseAdvancedInstaller extends Fragment { + private SingleInstallerViewBinding binding; + + public static BaseAdvancedInstaller newInstance(XposedTab tab) { + BaseAdvancedInstaller myFragment = new BaseAdvancedInstaller(); + + Bundle args = new Bundle(); + args.putParcelable("tab", tab); + myFragment.setArguments(args); + + return myFragment; + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + + Bundle arguments = getArguments(); + if (arguments == null) { + return null; + } else if (arguments.getParcelable("tab") == null) { + return null; + } + + XposedTab tab = arguments.getParcelable("tab"); + + if (tab == null) { + return null; + } + + binding = SingleInstallerViewBinding.inflate(inflater, container, false); + TooltipCompat.setTooltipText(binding.infoInstaller, getString(R.string.info)); + TooltipCompat.setTooltipText(binding.infoUninstaller, getString(R.string.info)); + try { + binding.chooserInstallers.setAdapter(new XposedZip.MyAdapter(getContext(), tab.installers)); + binding.chooserUninstallers.setAdapter(new XposedZip.MyAdapter(getContext(), tab.uninstallers)); + } catch (Exception ignored) { + } + binding.infoInstaller.setOnClickListener(v -> { + XposedZip selectedInstaller = (XposedZip) binding.chooserInstallers.getSelectedItem(); + String s = getString(R.string.infoInstaller, + selectedInstaller.name, + selectedInstaller.version); + + new MaterialAlertDialogBuilder(Objects.requireNonNull(getContext())).setTitle(R.string.info) + .setMessage(s).setPositiveButton(android.R.string.ok, null).show(); + }); + binding.infoUninstaller.setOnClickListener(v -> { + XposedZip selectedUninstaller = (XposedZip) binding.chooserUninstallers.getSelectedItem(); + String s = getString(R.string.infoUninstaller, + selectedUninstaller.name, + selectedUninstaller.version); + + new MaterialAlertDialogBuilder(Objects.requireNonNull(getContext())).setTitle(R.string.info) + .setMessage(s).setPositiveButton(android.R.string.ok, null).show(); + }); + + binding.btnInstall.setOnClickListener(v -> warningArchitecture( + (dialog, which) -> { + XposedZip selectedInstaller = (XposedZip) binding.chooserInstallers.getSelectedItem(); + Uri uri = Uri.parse(selectedInstaller.link); + Intent intent = new Intent(Intent.ACTION_VIEW, uri); + startActivity(intent); + }, tab.description)); + + binding.btnUninstall.setOnClickListener(v -> warningArchitecture( + (dialog, which) -> { + XposedZip selectedUninstaller = (XposedZip) binding.chooserUninstallers.getSelectedItem(); + Uri uri = Uri.parse(selectedUninstaller.link); + Intent intent = new Intent(Intent.ACTION_VIEW, uri); + startActivity(intent); + }, tab.description)); + + binding.noticeTv.setText(HtmlCompat.fromHtml(tab.notice, HtmlCompat.FROM_HTML_MODE_LEGACY)); + binding.author.setText(getString(R.string.download_author, tab.author)); + + try { + if (tab.uninstallers.size() == 0) { + binding.infoUninstaller.setVisibility(View.GONE); + binding.chooserUninstallers.setVisibility(View.GONE); + binding.btnUninstall.setVisibility(View.GONE); + } + } catch (Exception ignored) { + } + + if (!tab.stable) { + binding.warningUnstable.setVisibility(View.VISIBLE); + } + + if (!tab.official) { + binding.warningUnofficial.setVisibility(View.VISIBLE); + } + + binding.showOnXda.setOnClickListener(v -> NavUtil.startURL((BaseActivity) getActivity(), tab.support)); + binding.updateDescription.setOnClickListener(v -> new MaterialAlertDialogBuilder(Objects.requireNonNull(getContext())) + .setTitle(R.string.changes) + .setMessage(HtmlCompat.fromHtml(tab.description, HtmlCompat.FROM_HTML_MODE_LEGACY)) + .setPositiveButton(android.R.string.ok, null).show()); + + return binding.getRoot(); + } + + private void warningArchitecture(DialogInterface.OnClickListener listener, String description) { + Activity activity = getActivity(); + StringBuilder sb = new StringBuilder(); + sb.append(getString(R.string.warningArchitecture)); + sb.append("\n\n"); + sb.append(getString(R.string.changes)); + sb.append("\n"); + sb.append(HtmlCompat.fromHtml(description, HtmlCompat.FROM_HTML_MODE_LEGACY)); + if (activity != null) { + new MaterialAlertDialogBuilder(activity) + .setTitle(R.string.areyousure) + .setMessage(sb.toString()) + .setPositiveButton(android.R.string.yes, listener) + .setNegativeButton(android.R.string.no, null) + .show(); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/meowcat/edxposed/manager/ui/fragment/CompileDialogFragment.java b/app/src/main/java/org/meowcat/edxposed/manager/ui/fragment/CompileDialogFragment.java new file mode 100644 index 00000000..a426c391 --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/ui/fragment/CompileDialogFragment.java @@ -0,0 +1,139 @@ +package org.meowcat.edxposed.manager.ui.fragment; + +import android.app.Dialog; +import android.content.Context; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.os.AsyncTask; +import android.os.Bundle; +import android.text.TextUtils; +import android.view.LayoutInflater; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatDialogFragment; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.topjohnwu.superuser.Shell; + +import org.meowcat.edxposed.manager.R; +import org.meowcat.edxposed.manager.databinding.FragmentCompileDialogBinding; +import org.meowcat.edxposed.manager.util.ToastUtil; + +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.List; + +public class CompileDialogFragment extends AppCompatDialogFragment { + + private static final String KEY_APP_INFO = "app_info"; + private static final String KEY_MSG = "msg"; + private static final String KEY_COMMANDS = "commands"; + private ApplicationInfo appInfo; + + + public CompileDialogFragment() { + } + + public static CompileDialogFragment newInstance(ApplicationInfo appInfo, + String msg, String[] commands) { + Bundle arguments = new Bundle(); + arguments.putParcelable(KEY_APP_INFO, appInfo); + arguments.putString(KEY_MSG, msg); + arguments.putStringArray(KEY_COMMANDS, commands); + CompileDialogFragment fragment = new CompileDialogFragment(); + fragment.setArguments(arguments); + fragment.setCancelable(false); + return fragment; + } + + @Override + @NonNull + public Dialog onCreateDialog(Bundle savedInstanceState) { + Bundle arguments = getArguments(); + if (arguments == null) { + throw new IllegalStateException("arguments should not be null."); + } + appInfo = arguments.getParcelable(KEY_APP_INFO); + if (appInfo == null) { + throw new IllegalStateException("appInfo should not be null."); + } + String msg = arguments.getString(KEY_MSG, getString(R.string.compile_speed_msg)); + final PackageManager pm = requireContext().getPackageManager(); + MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(requireContext()) + .setIcon(appInfo.loadIcon(pm)) + .setTitle(appInfo.loadLabel(pm)) + .setCancelable(false); + FragmentCompileDialogBinding binding = FragmentCompileDialogBinding.inflate(LayoutInflater.from(requireContext()), null, false); + builder.setView(binding.getRoot()); + binding.message.setText(msg); + AlertDialog alertDialog = builder.create(); + alertDialog.setCanceledOnTouchOutside(false); + return alertDialog; + } + + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + Bundle arguments = getArguments(); + if (arguments != null) { + String[] commandPrefixes = arguments.getStringArray(KEY_COMMANDS); + appInfo = arguments.getParcelable(KEY_APP_INFO); + if (commandPrefixes == null || commandPrefixes.length == 0 || appInfo == null) { + ToastUtil.showShortToast(context, R.string.empty_param); + dismissAllowingStateLoss(); + return; + } + String[] commands = new String[commandPrefixes.length]; + for (int i = 0; i < commandPrefixes.length; i++) { + commands[i] = commandPrefixes[i] + appInfo.packageName; + } + new CompileTask(this).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, commands); + } else { + dismissAllowingStateLoss(); + } + } + + private static class CompileTask extends AsyncTask { + + WeakReference outerRef; + + CompileTask(CompileDialogFragment fragment) { + outerRef = new WeakReference<>(fragment); + } + + @Override + protected String doInBackground(String... commands) { + if (outerRef.get() == null) { + return outerRef.get().requireContext().getString(R.string.compile_failed); + } + // Also get STDERR + List stdout = new ArrayList<>(); + List stderr = new ArrayList<>(); + Shell.Result result = Shell.su(commands).to(stdout, stderr).exec(); + if (stderr.size() > 0) { + return "Error: " + TextUtils.join("\n", stderr); + } else if (!result.isSuccess()) { // they might don't write to stderr + return "Error: " + TextUtils.join("\n", stdout); + } else { + return TextUtils.join("\n", stdout); + } + } + + @Override + protected void onPostExecute(String result) { + if (outerRef.get() == null || !outerRef.get().isAdded()) { + return; + } + Context ctx = outerRef.get().requireContext(); + if (result.length() == 0) { + ToastUtil.showLongToast(ctx, R.string.compile_failed); + } else if (result.length() >= 5 && "Error".equals(result.substring(0, 5))) { + ToastUtil.showLongToast(ctx, ctx.getString(R.string.compile_failed_with_info) + " " + result.substring(6)); + } else { + ToastUtil.showLongToast(ctx, R.string.done); + } + outerRef.get().dismissAllowingStateLoss(); + } + } +} diff --git a/app/src/main/java/org/meowcat/edxposed/manager/ui/fragment/DownloadDetailsFragment.java b/app/src/main/java/org/meowcat/edxposed/manager/ui/fragment/DownloadDetailsFragment.java new file mode 100644 index 00000000..60331d5a --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/ui/fragment/DownloadDetailsFragment.java @@ -0,0 +1,77 @@ +package org.meowcat.edxposed.manager.ui.fragment; + +import android.annotation.SuppressLint; +import android.net.Uri; +import android.os.Bundle; +import android.text.method.LinkMovementMethod; +import android.util.Pair; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; + +import org.meowcat.edxposed.manager.R; +import org.meowcat.edxposed.manager.databinding.DownloadDetailsBinding; +import org.meowcat.edxposed.manager.databinding.DownloadMoreinfoBinding; +import org.meowcat.edxposed.manager.repo.Module; +import org.meowcat.edxposed.manager.repo.RepoParser; +import org.meowcat.edxposed.manager.ui.activity.BaseActivity; +import org.meowcat.edxposed.manager.ui.activity.DownloadDetailsActivity; +import org.meowcat.edxposed.manager.util.NavUtil; +import org.meowcat.edxposed.manager.util.chrome.LinkTransformationMethod; + +public class DownloadDetailsFragment extends Fragment { + + @SuppressLint("SetTextI18n") + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + DownloadDetailsActivity mActivity = (DownloadDetailsActivity) getActivity(); + if (mActivity == null) { + return null; + } + final Module module = mActivity.getModule(); + if (module == null) { + return null; + } + DownloadDetailsBinding binding = DownloadDetailsBinding.inflate(inflater, container, false); + binding.downloadTitle.setText(module.name); + binding.downloadTitle.setTextIsSelectable(true); + + if (module.author != null && !module.author.isEmpty()) + binding.downloadAuthor.setText(getString(R.string.download_author, module.author)); + else + binding.downloadAuthor.setText(R.string.download_unknown_author); + + if (module.description != null) { + if (module.descriptionIsHtml) { + binding.downloadDescription.setText(RepoParser.parseSimpleHtml(getActivity(), module.description, binding.downloadDescription)); + binding.downloadDescription.setTransformationMethod(new LinkTransformationMethod((BaseActivity) getActivity())); + binding.downloadDescription.setMovementMethod(LinkMovementMethod.getInstance()); + } else { + binding.downloadDescription.setText(module.description); + } + binding.downloadDescription.setTextIsSelectable(true); + } else { + binding.downloadDescription.setVisibility(View.GONE); + } + + for (Pair moreInfoEntry : module.moreInfo) { + DownloadMoreinfoBinding moreinfoBinding = DownloadMoreinfoBinding.inflate(inflater, binding.downloadMoreinfoContainer, false); + + moreinfoBinding.title.setText(moreInfoEntry.first + ":"); + moreinfoBinding.message.setText(moreInfoEntry.second); + + final Uri link = NavUtil.parseURL(moreInfoEntry.second); + if (link != null) { + moreinfoBinding.message.setTextColor(moreinfoBinding.message.getLinkTextColors()); + moreinfoBinding.getRoot().setOnClickListener(v -> NavUtil.startURL((BaseActivity) getActivity(), link)); + } + + binding.downloadMoreinfoContainer.addView(moreinfoBinding.getRoot()); + } + + return binding.getRoot(); + } +} \ No newline at end of file diff --git a/app/src/main/java/org/meowcat/edxposed/manager/ui/fragment/DownloadDetailsSettingsFragment.java b/app/src/main/java/org/meowcat/edxposed/manager/ui/fragment/DownloadDetailsSettingsFragment.java new file mode 100644 index 00000000..ab8741e8 --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/ui/fragment/DownloadDetailsSettingsFragment.java @@ -0,0 +1,62 @@ +package org.meowcat.edxposed.manager.ui.fragment; + +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Bundle; + +import androidx.preference.Preference; +import androidx.preference.PreferenceManager; + +import com.takisoft.preferencex.PreferenceFragmentCompat; + +import org.meowcat.edxposed.manager.R; +import org.meowcat.edxposed.manager.repo.Module; +import org.meowcat.edxposed.manager.ui.activity.DownloadDetailsActivity; +import org.meowcat.edxposed.manager.util.PrefixedSharedPreferences; +import org.meowcat.edxposed.manager.util.RepoLoader; + +import java.util.Map; + +public class DownloadDetailsSettingsFragment extends PreferenceFragmentCompat { + + @Override + public void onCreatePreferencesFix(Bundle savedInstanceState, String rootKey) { + DownloadDetailsActivity mActivity = (DownloadDetailsActivity) getActivity(); + if (mActivity == null) { + return; + } + final Module module = mActivity.getModule(); + if (module == null) { + return; + } + + final String packageName = module.packageName; + + PreferenceManager prefManager = getPreferenceManager(); + prefManager.setSharedPreferencesName("module_settings"); + PrefixedSharedPreferences.injectToPreferenceManager(prefManager, module.packageName); + addPreferencesFromResource(R.xml.module_prefs); + + SharedPreferences prefs = getActivity().getSharedPreferences("module_settings", Context.MODE_PRIVATE); + SharedPreferences.Editor editor = prefs.edit(); + + if (prefs.getBoolean("no_global", true)) { + for (Map.Entry k : prefs.getAll().entrySet()) { + if (("global").equals(prefs.getString(k.getKey(), ""))) { + editor.putString(k.getKey(), "").apply(); + } + } + + editor.putBoolean("no_global", false).apply(); + } + + Preference releaseType = findPreference("release_type"); + if (releaseType != null) { + releaseType.setOnPreferenceChangeListener((preference, newValue) -> { + RepoLoader.getInstance().setReleaseTypeLocal(packageName, (String) newValue); + return true; + }); + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/meowcat/edxposed/manager/ui/fragment/DownloadDetailsVersionsFragment.java b/app/src/main/java/org/meowcat/edxposed/manager/ui/fragment/DownloadDetailsVersionsFragment.java new file mode 100644 index 00000000..8f42223a --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/ui/fragment/DownloadDetailsVersionsFragment.java @@ -0,0 +1,188 @@ +package org.meowcat.edxposed.manager.ui.fragment; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.res.Resources; +import android.os.Bundle; +import android.text.method.LinkMovementMethod; +import android.util.TypedValue; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; +import androidx.fragment.app.ListFragment; + +import org.meowcat.edxposed.manager.R; +import org.meowcat.edxposed.manager.repo.Module; +import org.meowcat.edxposed.manager.repo.ModuleVersion; +import org.meowcat.edxposed.manager.repo.ReleaseType; +import org.meowcat.edxposed.manager.repo.RepoParser; +import org.meowcat.edxposed.manager.ui.activity.BaseActivity; +import org.meowcat.edxposed.manager.ui.activity.DownloadDetailsActivity; +import org.meowcat.edxposed.manager.ui.widget.DownloadView; +import org.meowcat.edxposed.manager.util.ModuleUtil.InstalledModule; +import org.meowcat.edxposed.manager.util.RepoLoader; +import org.meowcat.edxposed.manager.util.chrome.LinkTransformationMethod; + +import java.text.DateFormat; +import java.util.Date; + +public class DownloadDetailsVersionsFragment extends ListFragment { + @SuppressLint("StaticFieldLeak") + private DownloadDetailsActivity activity; + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + activity = (DownloadDetailsActivity) getActivity(); + if (activity == null) { + return; + } + Module module = activity.getModule(); + if (module == null) + return; + + if (module.versions.isEmpty()) { + setEmptyText(getString(R.string.download_no_versions)); + setListShown(true); + } else { + RepoLoader repoLoader = RepoLoader.getInstance(); + if (!repoLoader.isVersionShown(module.versions.get(0))) { + TextView txtHeader = new TextView(getActivity()); + txtHeader.setText(R.string.download_test_version_not_shown); + txtHeader.setTextColor(ContextCompat.getColor(activity, R.color.warning)); + txtHeader.setOnClickListener(v -> activity.gotoPage(DownloadDetailsActivity.DOWNLOAD_SETTINGS)); + getListView().addHeaderView(txtHeader); + } + + VersionsAdapter sAdapter = new VersionsAdapter(activity, activity.getInstalledModule()/*, activity.findViewById(R.id.snackbar)*/); + for (ModuleVersion version : module.versions) { + if (repoLoader.isVersionShown(version)) + sAdapter.add(version); + } + setListAdapter(sAdapter); + } + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + setListAdapter(null); + } + + static class ViewHolder { + TextView txtStatus; + TextView txtVersion; + TextView txtRelType; + TextView txtUploadDate; + DownloadView downloadView; + TextView txtChangesTitle; + TextView txtChanges; + } + + private class VersionsAdapter extends ArrayAdapter { + private final DateFormat dateFormatter = DateFormat + .getDateInstance(DateFormat.SHORT); + private final int colorRelTypeStable; + private final int colorRelTypeOthers; + private final int colorInstalled; + private final int colorUpdateAvailable; + private final String textInstalled; + private final String textUpdateAvailable; + private final long installedVersionCode; + + VersionsAdapter(Context context, InstalledModule installed) { + super(context, R.layout.item_version); + TypedValue typedValue = new TypedValue(); + Resources.Theme theme = context.getTheme(); + theme.resolveAttribute(android.R.attr.textColorPrimary, typedValue, true); + int color = ContextCompat.getColor(context, typedValue.resourceId); + colorRelTypeStable = color; + colorRelTypeOthers = ContextCompat.getColor(activity, R.color.warning); + colorInstalled = color; + colorUpdateAvailable = ContextCompat.getColor(activity, R.color.download_status_update_available); + textInstalled = getString(R.string.download_section_installed) + ":"; + textUpdateAvailable = getString(R.string.download_section_update_available) + ":"; + installedVersionCode = (installed != null) ? installed.versionCode : -1; + } + + @SuppressLint("InflateParams") + @Override + @NonNull + public View getView(int position, View convertView, @NonNull ViewGroup parent) { + View view = convertView; + if (view == null) { + LayoutInflater inflater = (LayoutInflater) getContext().getSystemService(Context.LAYOUT_INFLATER_SERVICE); + view = inflater.inflate(R.layout.item_version, null, true); + ViewHolder viewHolder = new ViewHolder(); + viewHolder.txtStatus = view.findViewById(R.id.txtStatus); + viewHolder.txtVersion = view.findViewById(R.id.txtVersion); + viewHolder.txtRelType = view.findViewById(R.id.txtRelType); + viewHolder.txtUploadDate = view.findViewById(R.id.txtUploadDate); + viewHolder.downloadView = view.findViewById(R.id.downloadView); + viewHolder.txtChangesTitle = view.findViewById(R.id.txtChangesTitle); + viewHolder.txtChanges = view.findViewById(R.id.txtChanges); + viewHolder.downloadView.fragment = DownloadDetailsVersionsFragment.this; + view.setTag(viewHolder); + } + + ViewHolder holder = (ViewHolder) view.getTag(); + ModuleVersion item = getItem(position); + if (item == null) { + return view; + } + holder.txtVersion.setText(item.name); + holder.txtRelType.setText(item.relType.getTitleId()); + holder.txtRelType.setTextColor(item.relType == ReleaseType.STABLE + ? colorRelTypeStable : colorRelTypeOthers); + + if (item.uploaded > 0) { + holder.txtUploadDate.setText( + dateFormatter.format(new Date(item.uploaded))); + holder.txtUploadDate.setVisibility(View.VISIBLE); + } else { + holder.txtUploadDate.setVisibility(View.GONE); + } + + if (item.code <= 0 || installedVersionCode <= 0 + || item.code < installedVersionCode) { + holder.txtStatus.setVisibility(View.GONE); + } else if (item.code == installedVersionCode) { + holder.txtStatus.setText(textInstalled); + holder.txtStatus.setTextColor(colorInstalled); + holder.txtStatus.setVisibility(View.VISIBLE); + } else { // item.code > installedVersionCode + holder.txtStatus.setText(textUpdateAvailable); + holder.txtStatus.setTextColor(colorUpdateAvailable); + holder.txtStatus.setVisibility(View.VISIBLE); + } + + holder.downloadView.setUrl(item.downloadLink); + holder.downloadView.setTitle(activity.getModule().name); + + if (item.changelog != null && !item.changelog.isEmpty()) { + holder.txtChangesTitle.setVisibility(View.VISIBLE); + holder.txtChanges.setVisibility(View.VISIBLE); + + if (item.changelogIsHtml) { + holder.txtChanges.setText(RepoParser.parseSimpleHtml(getActivity(), item.changelog, holder.txtChanges)); + holder.txtChanges.setTransformationMethod(new LinkTransformationMethod((BaseActivity) getActivity())); + holder.txtChanges.setMovementMethod(LinkMovementMethod.getInstance()); + } else { + holder.txtChanges.setText(item.changelog); + holder.txtChanges.setMovementMethod(null); + } + + } else { + holder.txtChangesTitle.setVisibility(View.GONE); + holder.txtChanges.setVisibility(View.GONE); + } + + return view; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/meowcat/edxposed/manager/ui/fragment/StatusInstallerFragment.java b/app/src/main/java/org/meowcat/edxposed/manager/ui/fragment/StatusInstallerFragment.java new file mode 100644 index 00000000..d68c4c3b --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/ui/fragment/StatusInstallerFragment.java @@ -0,0 +1,289 @@ +package org.meowcat.edxposed.manager.ui.fragment; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; +import androidx.core.text.HtmlCompat; +import androidx.fragment.app.Fragment; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import org.meowcat.edxposed.manager.App; +import org.meowcat.edxposed.manager.BuildConfig; +import org.meowcat.edxposed.manager.Constants; +import org.meowcat.edxposed.manager.R; +import org.meowcat.edxposed.manager.databinding.StatusInstallerBinding; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileReader; +import java.io.IOException; +import java.lang.reflect.Method; + +@SuppressLint("StaticFieldLeak") +public class StatusInstallerFragment extends Fragment { + private static StatusInstallerBinding binding; + private static String updateLink; + + public static void setUpdate(final String link, final String changelog, Context context) { + updateLink = link; + + binding.updateView.setVisibility(View.VISIBLE); + binding.clickToUpdate.setVisibility(View.VISIBLE); + binding.clickToUpdate.setOnClickListener(v -> new MaterialAlertDialogBuilder(context) + .setTitle(R.string.changes) + .setMessage(HtmlCompat.fromHtml(changelog, HtmlCompat.FROM_HTML_MODE_LEGACY)) + .setPositiveButton(R.string.update, (dialog, which) -> update(context)) + .setNegativeButton(R.string.later, null).show()); + } + + private static void update(Context context) { + Uri uri = Uri.parse(updateLink); + Intent intent = new Intent(Intent.ACTION_VIEW, uri); + context.startActivity(intent); + } + + private static String getCompleteArch() { + String info = ""; + + try { + FileReader fr = new FileReader("/proc/cpuinfo"); + BufferedReader br = new BufferedReader(fr); + String text; + while ((text = br.readLine()) != null) { + if (!text.startsWith("processor")) break; + } + br.close(); + String[] array = text != null ? text.split(":\\s+", 2) : new String[0]; + if (array.length >= 2) { + info += array[1] + " "; + } + } catch (IOException ignored) { + } + + info += Build.SUPPORTED_ABIS[0]; + return info + " (" + getArch() + ")"; + } + + @SuppressWarnings("deprecation") + private static String getArch() { + if (Build.CPU_ABI.equals("arm64-v8a")) { + return "arm64"; + } else if (Build.CPU_ABI.equals("x86_64")) { + return "x86_64"; + } else if (Build.CPU_ABI.equals("mips64")) { + return "mips64"; + } else if (Build.CPU_ABI.startsWith("x86") || Build.CPU_ABI2.startsWith("x86")) { + return "x86"; + } else if (Build.CPU_ABI.startsWith("mips")) { + return "mips"; + } else if (Build.CPU_ABI.startsWith("armeabi-v5") || Build.CPU_ABI.startsWith("armeabi-v6")) { + return "armv5"; + } else { + return "arm"; + } + } + + @SuppressLint("WorldReadableFiles") + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + binding = StatusInstallerBinding.inflate(inflater, container, false); + + String installedXposedVersion = Constants.getXposedVersion(); + String mAppVer = String.format("v%s (%s)", BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE); + binding.manager.setText(mAppVer); + if (installedXposedVersion != null) { + binding.api.setText(Constants.getXposedApiVersion() + ".0"); + binding.framework.setText(installedXposedVersion + " (" + Constants.getXposedVariant() + ")"); + } + + binding.androidVersion.setText(getString(R.string.android_sdk, getAndroidVersion(), Build.VERSION.RELEASE, Build.VERSION.SDK_INT)); + binding.manufacturer.setText(getUIFramework()); + binding.cpu.setText(getCompleteArch()); + + determineVerifiedBootState(binding); + return binding.getRoot(); + } + + private void determineVerifiedBootState(StatusInstallerBinding binding) { + try { + @SuppressLint("PrivateApi") Class c = Class.forName("android.os.SystemProperties"); + Method m = c.getDeclaredMethod("get", String.class, String.class); + m.setAccessible(true); + + String propSystemVerified = (String) m.invoke(null, "partition.system.verified", "0"); + String propState = (String) m.invoke(null, "ro.boot.verifiedbootstate", ""); + File fileDmVerityModule = new File("/sys/module/dm_verity"); + + boolean verified = false; + if (propSystemVerified != null) { + verified = !propSystemVerified.equals("0"); + } + boolean detected = false; + if (propState != null) { + detected = !propState.isEmpty() || fileDmVerityModule.exists(); + } + + if (verified) { + binding.dmverity.setText(R.string.verified_boot_active); + binding.dmverity.setTextColor(ContextCompat.getColor(requireActivity(), R.color.warning)); + } else if (detected) { + binding.dmverity.setText(R.string.verified_boot_deactivated); + binding.dmverityExplanation.setVisibility(View.GONE); + } else { + binding.dmverity.setText(R.string.verified_boot_none); + binding.dmverity.setTextColor(ContextCompat.getColor(requireActivity(), R.color.warning)); + binding.dmverityExplanation.setVisibility(View.GONE); + } + } catch (Exception e) { + Log.e(App.TAG, "Could not detect Verified Boot state", e); + } + } + + /* + @SuppressWarnings("SameParameterValue") + private boolean checkAppInstalled(Context context, String pkgName) { + if (pkgName == null || pkgName.isEmpty()) { + return false; + } + final PackageManager packageManager = context.getPackageManager(); + List info = packageManager.getInstalledPackages(0); + if (info == null || info.isEmpty()) { + return false; + } + for (int i = 0; i < info.size(); i++) { + if (pkgName.equals(info.get(i).packageName)) { + return true; + } + } + return false; + } + + @SuppressLint("StringFormatInvalid") + private void refreshKnownIssue() { + String issueName = null; + String issueLink = null; + final ApplicationInfo appInfo = Objects.requireNonNull(getActivity()).getApplicationInfo(); + final File baseDir = new File(App.BASE_DIR); + final File baseDirCanonical = getCanonicalFile(baseDir); + final File baseDirActual = new File(Build.VERSION.SDK_INT >= 24 ? appInfo.deviceProtectedDataDir : appInfo.dataDir); + final File baseDirActualCanonical = getCanonicalFile(baseDirActual); + + if (new File("/system/framework/core.jar.jex").exists()) { + issueName = "Aliyun OS"; + issueLink = "https://forum.xda-developers.com/showpost.php?p=52289793&postcount=5"; + // } else if (Build.VERSION.SDK_INT < 24 && (new File("/data/miui/DexspyInstaller.jar").exists() || checkClassExists("miui.dexspy.DexspyInstaller"))) { + // issueName = "MIUI/Dexspy"; + // issueLink = "https://forum.xda-developers.com/showpost.php?p=52291098&postcount=6"; + // } else if (Build.VERSION.SDK_INT < 24 && new File("/system/framework/twframework.jar").exists()) { + // issueName = "Samsung TouchWiz ROM"; + // issueLink = "https://forum.xda-developers.com/showthread.php?t=3034811"; + } else if (!baseDirCanonical.equals(baseDirActualCanonical)) { + Log.e(App.TAG, "Base directory: " + getPathWithCanonicalPath(baseDir, baseDirCanonical)); + Log.e(App.TAG, "Expected: " + getPathWithCanonicalPath(baseDirActual, baseDirActualCanonical)); + issueName = getString(R.string.known_issue_wrong_base_directory, getPathWithCanonicalPath(baseDirActual, baseDirActualCanonical)); + } else if (!baseDir.exists()) { + issueName = getString(R.string.known_issue_missing_base_directory); + issueLink = "https://github.com/rovo89/XposedInstaller/issues/393"; + } else if (checkAppInstalled(getContext(), "com.solohsu.android.edxp.manager")) { + issueName = getString(R.string.edxp_installer_installed); + issueLink = getString(R.string.about_support); + } + + } + */ + private String getAndroidVersion() { + switch (Build.VERSION.SDK_INT) { +// case 16: +// case 17: +// case 18: +// return "Jelly Bean"; +// case 19: +// return "KitKat"; + case 21: + case 22: + return "Lollipop"; + case 23: + return "Marshmallow"; + case 24: + case 25: + return "Nougat"; + case 26: + case 27: + return "Oreo"; + case 28: + return "Pie"; + case 29: + return "Ten"; + case 30: + return "R"; + } + return "Unknown"; + } + + private String getUIFramework() { + String manufacturer = Character.toUpperCase(Build.MANUFACTURER.charAt(0)) + Build.MANUFACTURER.substring(1); + if (!Build.BRAND.equals(Build.MANUFACTURER)) { + manufacturer += " " + Character.toUpperCase(Build.BRAND.charAt(0)) + Build.BRAND.substring(1); + } + manufacturer += " " + Build.MODEL + " "; + if (new File("/system/framework/twframework.jar").exists() || new File("/system/framework/samsung-services.jar").exists()) { + manufacturer += "(TouchWiz)"; + } else if (new File("/system/framework/framework-miui-res.apk").exists() || new File("/system/app/miui/miui.apk").exists() || new File("/system/app/miuisystem/miuisystem.apk").exists()) { + manufacturer += "(Mi UI)"; + } else if (new File("/system/priv-app/oneplus-framework-res/oneplus-framework-res.apk").exists()) { + manufacturer += "(Oxygen/Hydrogen OS)"; + } else if (new File("/system/framework/com.samsung.device.jar").exists() || new File("/system/framework/sec_platform_library.jar").exists()) { + manufacturer += "(One UI)"; + } + /*if (manufacturer.contains("Samsung")) { + manufacturer += new File("/system/framework/twframework.jar").exists() || + new File("/system/framework/samsung-services.jar").exists() + ? "(TouchWiz)" : "(AOSP-based ROM)"; + } else if (manufacturer.contains("Xiaomi")) { + manufacturer += new File("/system/framework/framework-miui-res.apk").exists() ? "(MIUI)" : "(AOSP-based ROM)"; + }*/ + return manufacturer; + } + + /* + private File getCanonicalFile(File file) { + try { + return file.getCanonicalFile(); + } catch (IOException e) { + Log.e(App.TAG, "Failed to get canonical file for " + file.getAbsolutePath(), e); + return file; + } + } + + private String getPathWithCanonicalPath(File file, File canonical) { + if (file.equals(canonical)) { + return file.getAbsolutePath(); + } else { + return file.getAbsolutePath() + " \u2192 " + canonical.getAbsolutePath(); + } + } + */ + private int extractIntPart(String str) { + int result = 0, length = str.length(); + for (int offset = 0; offset < length; offset++) { + char c = str.charAt(offset); + if ('0' <= c && c <= '9') + result = result * 10 + (c - '0'); + else + break; + } + return result; + } +} \ No newline at end of file diff --git a/app/src/main/java/org/meowcat/edxposed/manager/ui/widget/DownloadView.java b/app/src/main/java/org/meowcat/edxposed/manager/ui/widget/DownloadView.java new file mode 100644 index 00000000..67e404d4 --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/ui/widget/DownloadView.java @@ -0,0 +1,55 @@ +package org.meowcat.edxposed.manager.ui.widget; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.LinearLayout; + +import androidx.fragment.app.Fragment; + +import org.meowcat.edxposed.manager.R; +import org.meowcat.edxposed.manager.databinding.DownloadViewBinding; +import org.meowcat.edxposed.manager.ui.activity.BaseActivity; +import org.meowcat.edxposed.manager.util.NavUtil; + +public class DownloadView extends LinearLayout { + public Fragment fragment; + private String mUrl = null; + private String mTitle = null; + private final DownloadViewBinding binding; + + public DownloadView(Context context, final AttributeSet attrs) { + super(context, attrs); + setFocusable(false); + setOrientation(LinearLayout.VERTICAL); + + binding = DownloadViewBinding.inflate(LayoutInflater.from(context), this); + + binding.btnDownload.setOnClickListener(v -> NavUtil.startURL((BaseActivity) context, mUrl)); + } + + public String getUrl() { + return mUrl; + } + + public void setUrl(String url) { + mUrl = url; + if (mUrl != null) { + binding.btnDownload.setVisibility(View.VISIBLE); + binding.txtInfo.setVisibility(View.GONE); + } else { + binding.btnDownload.setVisibility(View.GONE); + binding.txtInfo.setVisibility(View.VISIBLE); + binding.txtInfo.setText(R.string.download_view_no_url); + } + } + + public String getTitle() { + return mTitle; + } + + public void setTitle(String title) { + this.mTitle = title; + } +} \ No newline at end of file diff --git a/app/src/main/java/org/meowcat/edxposed/manager/ui/widget/IntegerListPreference.java b/app/src/main/java/org/meowcat/edxposed/manager/ui/widget/IntegerListPreference.java new file mode 100644 index 00000000..2e859f42 --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/ui/widget/IntegerListPreference.java @@ -0,0 +1,63 @@ +package org.meowcat.edxposed.manager.ui.widget; + +import android.content.Context; +import android.content.SharedPreferences; +import android.util.AttributeSet; + +import com.takisoft.preferencex.SimpleMenuPreference; + +@SuppressWarnings("unused") +public class IntegerListPreference extends SimpleMenuPreference { + public IntegerListPreference(Context context) { + super(context); + } + + public IntegerListPreference(Context context, AttributeSet attrs) { + super(context, attrs); + } + + private static int getIntValue(String value) { + if (value == null) + return 0; + + return (int) ((value.startsWith("0x")) + ? Long.parseLong(value.substring(2), 16) + : Long.parseLong(value)); + } + + @Override + public void setValue(String value) { + super.setValue(value); + notifyChanged(); + } + + @Override + protected boolean persistString(String value) { + return value != null && persistInt(getIntValue(value)); + + } + + @Override + protected String getPersistedString(String defaultReturnValue) { + SharedPreferences pref = getPreferenceManager().getSharedPreferences(); + String key = getKey(); + if (!shouldPersist() || !pref.contains(key)) + return defaultReturnValue; + + return String.valueOf(pref.getInt(key, 0)); + } + + @Override + public int findIndexOfValue(String value) { + CharSequence[] entryValues = getEntryValues(); + int intValue = getIntValue(value); + if (value != null && entryValues != null) { + for (int i = entryValues.length - 1; i >= 0; i--) { + if (getIntValue(entryValues[i].toString()) == intValue) { + return i; + } + } + } + return -1; + } +} \ No newline at end of file diff --git a/app/src/main/java/org/meowcat/edxposed/manager/ui/widget/MasterSwitch.java b/app/src/main/java/org/meowcat/edxposed/manager/ui/widget/MasterSwitch.java new file mode 100644 index 00000000..f6a838a5 --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/ui/widget/MasterSwitch.java @@ -0,0 +1,112 @@ +package org.meowcat.edxposed.manager.ui.widget; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.StateListDrawable; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.Checkable; +import android.widget.FrameLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.SwitchCompat; + +import org.meowcat.edxposed.manager.R; + +public class MasterSwitch extends FrameLayout implements View.OnClickListener, Checkable { + + private TextView masterTitle; + private SwitchCompat switchCompat; + + private String title; + + private OnCheckedChangeListener listener; + + private boolean isChecked; + + public MasterSwitch(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + init(context, attrs); + } + + public MasterSwitch(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(context, attrs); + } + + public MasterSwitch(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + init(context, attrs); + } + + private void init(Context context, AttributeSet attrs) { + LayoutInflater inflater = LayoutInflater.from(getContext()); + inflater.inflate(R.layout.master_switch, this, true); + + TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.MasterSwitch); + int colorOn = a.getColor(R.styleable.MasterSwitch_masterSwitchBackgroundOn, 0); + int colorOff = a.getColor(R.styleable.MasterSwitch_masterSwitchBackgroundOff, 0); + a.recycle(); + + StateListDrawable drawable = new StateListDrawable(); + drawable.addState(new int[]{android.R.attr.state_selected}, new ColorDrawable(colorOn)); + drawable.addState(new int[]{}, new ColorDrawable(colorOff)); + setBackground(drawable); + + masterTitle = findViewById(android.R.id.title); + switchCompat = findViewById(R.id.switchWidget); + + setOnClickListener(this); + } + + public void setTitle(String title) { + this.title = title; + masterTitle.setText(title); + } + + private void updateViews() { + if (switchCompat != null) { + setSelected(isChecked); + switchCompat.setChecked(isChecked); + } + } + + @Override + public boolean isChecked() { + return isChecked; + } + + @Override + public void toggle() { + setChecked(!isChecked); + } + + @Override + public void setChecked(boolean checked) { + final boolean changed = isChecked != checked; + if (changed) { + isChecked = checked; + updateViews(); + if (listener != null) { + listener.onCheckedChanged(checked); + } + } + } + + public void setOnCheckedChangedListener(OnCheckedChangeListener listener) { + this.listener = listener; + } + + public static abstract class OnCheckedChangeListener { + public abstract void onCheckedChanged(boolean checked); + } + + @Override + public void onClick(View v) { + toggle(); + } +} diff --git a/app/src/main/java/org/meowcat/edxposed/manager/ui/widget/RecyclerViewBugFixed.java b/app/src/main/java/org/meowcat/edxposed/manager/ui/widget/RecyclerViewBugFixed.java new file mode 100644 index 00000000..0e9a0b3e --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/ui/widget/RecyclerViewBugFixed.java @@ -0,0 +1,83 @@ +package org.meowcat.edxposed.manager.ui.widget; + +import android.content.Context; +import android.graphics.Canvas; +import android.util.AttributeSet; +import android.widget.EdgeEffect; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; + +import me.zhanghai.android.fastscroll.FixItemDecorationRecyclerView; + +public class RecyclerViewBugFixed extends FixItemDecorationRecyclerView { + + + public RecyclerViewBugFixed(@NonNull Context context) { + super(context); + setEdgeEffectFactory(getClipToPadding() ? new EdgeEffectFactory() : new AlwaysClipToPaddingEdgeEffectFactory()); + } + + public RecyclerViewBugFixed(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + setEdgeEffectFactory(getClipToPadding() ? new EdgeEffectFactory() : new AlwaysClipToPaddingEdgeEffectFactory()); + } + + public RecyclerViewBugFixed(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + setEdgeEffectFactory(getClipToPadding() ? new EdgeEffectFactory() : new AlwaysClipToPaddingEdgeEffectFactory()); + } + + public static class AlwaysClipToPaddingEdgeEffectFactory extends RecyclerView.EdgeEffectFactory { + + @NonNull + @Override + protected EdgeEffect createEdgeEffect(@NonNull RecyclerView view, int direction) { + return new EdgeEffect(view.getContext()) { + private boolean ensureSize = false; + + private void ensureSize() { + if (ensureSize) return; + ensureSize = true; + switch (direction) { + case DIRECTION_LEFT: + case DIRECTION_RIGHT: + setSize(view.getMeasuredHeight() - view.getPaddingTop() - view.getPaddingBottom(), + view.getMeasuredWidth() - view.getPaddingLeft() - view.getPaddingRight()); + break; + case DIRECTION_TOP: + case DIRECTION_BOTTOM: + setSize(view.getMeasuredWidth() - view.getPaddingLeft() - view.getPaddingRight(), + view.getMeasuredHeight() - view.getPaddingTop() - view.getPaddingBottom()); + break; + } + } + + @Override + public boolean draw(Canvas c) { + ensureSize(); + + int restore = c.save(); + switch (direction) { + case DIRECTION_LEFT: + c.translate(view.getPaddingBottom(), 0f); + break; + case DIRECTION_TOP: + c.translate(view.getPaddingLeft(), view.getPaddingTop()); + break; + case DIRECTION_RIGHT: + c.translate(-view.getPaddingTop(), 0f); + break; + case DIRECTION_BOTTOM: + c.translate(view.getPaddingRight(), view.getPaddingBottom()); + break; + } + boolean res = super.draw(c); + c.restoreToCount(restore); + return res; + } + }; + } + } +} diff --git a/app/src/main/java/org/meowcat/edxposed/manager/ui/widget/ThemeColorPreference.java b/app/src/main/java/org/meowcat/edxposed/manager/ui/widget/ThemeColorPreference.java new file mode 100644 index 00000000..3bd5e2cf --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/ui/widget/ThemeColorPreference.java @@ -0,0 +1,62 @@ +package org.meowcat.edxposed.manager.ui.widget; + +import android.content.Context; +import android.util.AttributeSet; + +import androidx.core.content.ContextCompat; + +import com.takisoft.preferencex.ColorPickerPreference; +import com.takisoft.preferencex.ColorPickerPreferenceDialogFragmentCompat; +import com.takisoft.preferencex.PreferenceFragmentCompat; + +import org.meowcat.edxposed.manager.util.CustomThemeColor; +import org.meowcat.edxposed.manager.util.CustomThemeColors; + +import java.util.Objects; + +public class ThemeColorPreference extends ColorPickerPreference { + + static { + PreferenceFragmentCompat.registerPreferenceFragment(ThemeColorPreference.class, + ColorPickerPreferenceDialogFragmentCompat.class); + } + + public ThemeColorPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + init(); + } + + public ThemeColorPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + public ThemeColorPreference(Context context, AttributeSet attrs) { + super(context, attrs); + init(); + } + + public ThemeColorPreference(Context context) { + super(context); + init(); + } + + private void init() { + String key = getKey(); + Context context = getContext(); + CustomThemeColor[] colors; + if (Objects.equals(key, "primary_color")) { + colors = CustomThemeColors.Primary.values(); + } else if (Objects.equals(key, "accent_color")) { + colors = CustomThemeColors.Accent.values(); + } else { + throw new IllegalArgumentException("Unknown custom theme color preference key: " + key); + } + int[] mEntryValues = new int[colors.length]; + for (int i = 0; i < colors.length; ++i) { + CustomThemeColor color = colors[i]; + mEntryValues[i] = ContextCompat.getColor(context, color.getResourceId()); + } + setColors(mEntryValues); + } +} \ No newline at end of file diff --git a/app/src/main/java/org/meowcat/edxposed/manager/util/CompileUtil.java b/app/src/main/java/org/meowcat/edxposed/manager/util/CompileUtil.java new file mode 100644 index 00000000..16d82ce6 --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/util/CompileUtil.java @@ -0,0 +1,43 @@ +package org.meowcat.edxposed.manager.util; + +import android.content.Context; +import android.content.pm.ApplicationInfo; + +import androidx.fragment.app.FragmentManager; + +import org.meowcat.edxposed.manager.R; +import org.meowcat.edxposed.manager.ui.fragment.CompileDialogFragment; + +public class CompileUtil { + + private static final String COMPILE_COMMAND_PREFIX = "cmd package "; + private static final String COMPILE_RESET_COMMAND = COMPILE_COMMAND_PREFIX + "compile --reset "; + private static final String COMPILE_SPEED_COMMAND = COMPILE_COMMAND_PREFIX + "compile -f -m speed "; + private static final String COMPILE_DEXOPT_COMMAND = COMPILE_COMMAND_PREFIX + "force-dex-opt "; + private static final String TAG_COMPILE_DIALOG = "compile_dialog"; + + public static void reset(Context context, FragmentManager fragmentManager, + ApplicationInfo info) { + compilePackageInBg(fragmentManager, info, + context.getString(R.string.compile_reset_msg), COMPILE_RESET_COMMAND); + } + + public static void compileSpeed(Context context, FragmentManager fragmentManager, + ApplicationInfo info) { + compilePackageInBg(fragmentManager, info, + context.getString(R.string.compile_speed_msg), COMPILE_SPEED_COMMAND); + } + + public static void compileDexopt(Context context, FragmentManager fragmentManager, + ApplicationInfo info) { + compilePackageInBg(fragmentManager, info, + context.getString(R.string.compile_speed_msg), COMPILE_DEXOPT_COMMAND); + } + + private static void compilePackageInBg(FragmentManager fragmentManager, + ApplicationInfo info, String msg, String... commands) { + CompileDialogFragment fragment = CompileDialogFragment.newInstance(info, msg, commands); + fragment.show(fragmentManager, TAG_COMPILE_DIALOG); + } + +} diff --git a/app/src/main/java/org/meowcat/edxposed/manager/util/CustomThemeColor.java b/app/src/main/java/org/meowcat/edxposed/manager/util/CustomThemeColor.java new file mode 100644 index 00000000..88f59fb0 --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/util/CustomThemeColor.java @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2019 Hai Zhang + * All Rights Reserved. + */ + +package org.meowcat.edxposed.manager.util; + +import androidx.annotation.ColorRes; +import androidx.annotation.NonNull; + +public interface CustomThemeColor { + + @ColorRes + int getResourceId(); + + @NonNull + String getResourceEntryName(); +} diff --git a/app/src/main/java/org/meowcat/edxposed/manager/util/CustomThemeColors.java b/app/src/main/java/org/meowcat/edxposed/manager/util/CustomThemeColors.java new file mode 100644 index 00000000..ed2f2090 --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/util/CustomThemeColors.java @@ -0,0 +1,104 @@ +package org.meowcat.edxposed.manager.util; + +import androidx.annotation.ColorRes; +import androidx.annotation.NonNull; + +import org.meowcat.edxposed.manager.R; + +public class CustomThemeColors { + + private CustomThemeColors() { + } + + public enum Primary implements CustomThemeColor { + + COLORPRIMARY(R.color.colorPrimary, "colorPrimary"), + MATERIAL_RED_500(R.color.material_red_500, "material_red_500"), + MATERIAL_PINK_500(R.color.material_pink_500, "material_pink_500"), + MATERIAL_PURPLE_500(R.color.material_purple_500, "material_purple_500"), + MATERIAL_DEEP_PURPLE_500(R.color.material_deep_purple_500, "material_deep_purple_500"), + MATERIAL_INDIGO_500(R.color.material_indigo_500, "material_indigo_500"), + MATERIAL_BLUE_500(R.color.material_blue_500, "material_blue_500"), + MATERIAL_LIGHT_BLUE_500(R.color.material_light_blue_500, "material_light_blue_500"), + MATERIAL_CYAN_500(R.color.material_cyan_500, "material_cyan_500"), + MATERIAL_TEAL_500(R.color.material_teal_500, "material_teal_500"), + MATERIAL_GREEN_500(R.color.material_green_500, "material_green_500"), + MATERIAL_LIGHT_GREEN_500(R.color.material_light_green_500, "material_light_green_500"), + MATERIAL_LIME_500(R.color.material_lime_500, "material_lime_500"), + MATERIAL_YELLOW_500(R.color.material_yellow_500, "material_yellow_500"), + MATERIAL_AMBER_500(R.color.material_amber_500, "material_amber_500"), + MATERIAL_ORANGE_500(R.color.material_orange_500, "material_orange_500"), + MATERIAL_DEEP_ORANGE_500(R.color.material_deep_orange_500, "material_deep_orange_500"), + MATERIAL_BROWN_500(R.color.material_brown_500, "material_brown_500"), + MATERIAL_GREY_500(R.color.material_grey_500, "material_grey_500"), + MATERIAL_BLUE_GREY_500(R.color.material_blue_grey_500, "material_blue_grey_500"); + + @ColorRes + private final int mResourceId; + @NonNull + private final String mResourceEntryName; + + Primary(@ColorRes int resourceId, @NonNull String resourceEntryName) { + mResourceId = resourceId; + mResourceEntryName = resourceEntryName; + } + + @ColorRes + @Override + public int getResourceId() { + return mResourceId; + } + + @NonNull + @Override + public String getResourceEntryName() { + return mResourceEntryName; + } + } + + public enum Accent implements CustomThemeColor { + + COLORACCENT(R.color.colorAccent, "colorAccent"), + MATERIAL_RED_A200(R.color.material_red_a200, "material_red_a200"), + MATERIAL_PINK_A200(R.color.material_pink_a200, "material_pink_a200"), + MATERIAL_PURPLE_A200(R.color.material_purple_a200, "material_purple_a200"), + MATERIAL_DEEP_PURPLE_A200(R.color.material_deep_purple_a200, "material_deep_purple_a200"), + MATERIAL_INDIGO_A200(R.color.material_indigo_a200, "material_indigo_a200"), + MATERIAL_BLUE_A200(R.color.material_blue_a200, "material_blue_a200"), + MATERIAL_LIGHT_BLUE_500(R.color.material_light_blue_500, "material_light_blue_500"), + MATERIAL_CYAN_500(R.color.material_cyan_500, "material_cyan_500"), + MATERIAL_TEAL_500(R.color.material_teal_500, "material_teal_500"), + MATERIAL_GREEN_500(R.color.material_green_500, "material_green_500"), + MATERIAL_LIGHT_GREEN_500(R.color.material_light_green_500, "material_light_green_500"), + MATERIAL_LIME_500(R.color.material_lime_500, "material_lime_500"), + MATERIAL_YELLOW_500(R.color.material_yellow_500, "material_yellow_500"), + MATERIAL_AMBER_500(R.color.material_amber_500, "material_amber_500"), + MATERIAL_ORANGE_500(R.color.material_orange_500, "material_orange_500"), + MATERIAL_DEEP_ORANGE_500(R.color.material_deep_orange_500, "material_deep_orange_500"), + MATERIAL_BROWN_500(R.color.material_brown_500, "material_brown_500"), + MATERIAL_GREY_500(R.color.material_grey_500, "material_grey_500"), + MATERIAL_BLUE_GREY_500(R.color.material_blue_grey_500, "material_blue_grey_500"); + + @ColorRes + private final int mResourceId; + @NonNull + private final String mResourceEntryName; + + Accent(@ColorRes int resourceId, @NonNull String resourceEntryName) { + mResourceId = resourceId; + mResourceEntryName = resourceEntryName; + } + + @ColorRes + @Override + public int getResourceId() { + return mResourceId; + } + + @NonNull + @Override + public String getResourceEntryName() { + return mResourceEntryName; + } + } +} diff --git a/app/src/main/java/org/meowcat/edxposed/manager/util/DownloadsUtil.java b/app/src/main/java/org/meowcat/edxposed/manager/util/DownloadsUtil.java new file mode 100644 index 00000000..208e38e1 --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/util/DownloadsUtil.java @@ -0,0 +1,129 @@ +package org.meowcat.edxposed.manager.util; + +import android.content.Context; +import android.content.SharedPreferences; + +import org.meowcat.edxposed.manager.App; +import org.meowcat.edxposed.manager.R; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLConnection; + +public class DownloadsUtil { + private static final SharedPreferences pref = App.getInstance().getSharedPreferences("download_cache", Context.MODE_PRIVATE); + + static SyncDownloadInfo downloadSynchronously(String url, File target) { + final boolean useNotModifiedTags = target.exists(); + + URLConnection connection = null; + InputStream in = null; + FileOutputStream out = null; + try { + connection = new URL(url).openConnection(); + connection.setDoOutput(false); + connection.setConnectTimeout(30000); + connection.setReadTimeout(30000); + + if (connection instanceof HttpURLConnection) { + // Disable transparent gzip encoding for gzipped files + if (url.endsWith(".gz")) { + connection.addRequestProperty("Accept-Encoding", "identity"); + } + + if (useNotModifiedTags) { + String modified = pref.getString("download_" + url + "_modified", null); + String etag = pref.getString("download_" + url + "_etag", null); + + if (modified != null) { + connection.addRequestProperty("If-Modified-Since", modified); + } + if (etag != null) { + connection.addRequestProperty("If-None-Match", etag); + } + } + } + + connection.connect(); + + if (connection instanceof HttpURLConnection) { + HttpURLConnection httpConnection = (HttpURLConnection) connection; + int responseCode = httpConnection.getResponseCode(); + if (responseCode == HttpURLConnection.HTTP_NOT_MODIFIED) { + return new SyncDownloadInfo(SyncDownloadInfo.STATUS_NOT_MODIFIED, null); + } else if (responseCode < 200 || responseCode >= 300) { + return new SyncDownloadInfo(SyncDownloadInfo.STATUS_FAILED, + App.getInstance().getString(R.string.repo_download_failed_http, + url, responseCode, + httpConnection.getResponseMessage())); + } + } + + in = connection.getInputStream(); + out = new FileOutputStream(target); + byte[] buf = new byte[1024]; + int read; + while ((read = in.read(buf)) != -1) { + out.write(buf, 0, read); + } + + if (connection instanceof HttpURLConnection) { + HttpURLConnection httpConnection = (HttpURLConnection) connection; + String modified = httpConnection.getHeaderField("Last-Modified"); + String etag = httpConnection.getHeaderField("ETag"); + + pref.edit() + .putString("download_" + url + "_modified", modified) + .putString("download_" + url + "_etag", etag).apply(); + } + + return new SyncDownloadInfo(SyncDownloadInfo.STATUS_SUCCESS, null); + + } catch (Throwable t) { + return new SyncDownloadInfo(SyncDownloadInfo.STATUS_FAILED, + App.getInstance().getString(R.string.repo_download_failed, url, + t.getMessage())); + + } finally { + if (connection instanceof HttpURLConnection) + ((HttpURLConnection) connection).disconnect(); + if (in != null) + try { + in.close(); + } catch (IOException ignored) { + } + if (out != null) + try { + out.close(); + } catch (IOException ignored) { + } + } + } + + static void clearCache(String url) { + if (url != null) { + pref.edit().remove("download_" + url + "_modified") + .remove("download_" + url + "_etag").apply(); + } else { + pref.edit().clear().apply(); + } + } + + public static class SyncDownloadInfo { + static final int STATUS_SUCCESS = 0; + static final int STATUS_NOT_MODIFIED = 1; + static final int STATUS_FAILED = 2; + + public final int status; + final String errorMessage; + + private SyncDownloadInfo(int status, String errorMessage) { + this.status = status; + this.errorMessage = errorMessage; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/meowcat/edxposed/manager/util/GlideHelper.java b/app/src/main/java/org/meowcat/edxposed/manager/util/GlideHelper.java new file mode 100644 index 00000000..caf73c54 --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/util/GlideHelper.java @@ -0,0 +1,12 @@ +package org.meowcat.edxposed.manager.util; + +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageInfo; + +public class GlideHelper { + public static PackageInfo wrapApplicationInfoForIconLoader(ApplicationInfo applicationInfo) { + PackageInfo packageInfo = new PackageInfo(); + packageInfo.applicationInfo = applicationInfo; + return packageInfo; + } +} diff --git a/app/src/main/java/org/meowcat/edxposed/manager/util/HashUtil.java b/app/src/main/java/org/meowcat/edxposed/manager/util/HashUtil.java new file mode 100644 index 00000000..0322988b --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/util/HashUtil.java @@ -0,0 +1,64 @@ +package org.meowcat.edxposed.manager.util; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +public class HashUtil { + private static String hash(String input, @SuppressWarnings("SameParameterValue") String algorithm) { + try { + MessageDigest md = MessageDigest.getInstance(algorithm); + byte[] messageDigest = md.digest(input.getBytes()); + return toHexString(messageDigest); + } catch (NoSuchAlgorithmException e) { + throw new IllegalArgumentException(e); + } + } + + static String md5(String input) { + return hash(input, "MD5"); + } + +// public static String sha1(String input) { +// return hash(input, "SHA-1"); +// } + + private static String hash(File file, @SuppressWarnings("SameParameterValue") String algorithm) throws IOException { + try { + MessageDigest md = MessageDigest.getInstance(algorithm); + InputStream is = new FileInputStream(file); + byte[] buffer = new byte[8192]; + int read; + while ((read = is.read(buffer)) > 0) { + md.update(buffer, 0, read); + } + is.close(); + byte[] messageDigest = md.digest(); + return toHexString(messageDigest); + } catch (NoSuchAlgorithmException e) { + throw new IllegalArgumentException(e); + } + } + + public static String md5(File input) throws IOException { + return hash(input, "MD5"); + } + +// public static String sha1(File input) throws IOException { +// return hash(input, "SHA-1"); +// } + + private static String toHexString(byte[] bytes) { + StringBuilder sb = new StringBuilder(); + for (byte b : bytes) { + int unsignedB = b & 0xff; + if (unsignedB < 0x10) + sb.append("0"); + sb.append(Integer.toHexString(unsignedB)); + } + return sb.toString(); + } +} \ No newline at end of file diff --git a/app/src/main/java/org/meowcat/edxposed/manager/util/IconLoader.java b/app/src/main/java/org/meowcat/edxposed/manager/util/IconLoader.java new file mode 100644 index 00000000..878b4af1 --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/util/IconLoader.java @@ -0,0 +1,27 @@ +package org.meowcat.edxposed.manager.util; + +import android.content.Context; +import android.content.pm.PackageInfo; +import android.graphics.Bitmap; + +import androidx.annotation.NonNull; + +import com.bumptech.glide.Glide; +import com.bumptech.glide.Registry; +import com.bumptech.glide.annotation.GlideModule; +import com.bumptech.glide.module.AppGlideModule; + +import org.meowcat.edxposed.manager.R; + +import me.zhanghai.android.appiconloader.glide.AppIconModelLoader; + +@GlideModule +public class IconLoader extends AppGlideModule { + @Override + public void registerComponents(Context context, @NonNull Glide glide, Registry registry) { + int iconSize = context.getResources().getDimensionPixelSize(R.dimen.app_icon_size); + registry.prepend(PackageInfo.class, Bitmap.class, new AppIconModelLoader.Factory(iconSize, + false, context)); + } +} + diff --git a/app/src/main/java/org/meowcat/edxposed/manager/util/InstallApkUtil.java b/app/src/main/java/org/meowcat/edxposed/manager/util/InstallApkUtil.java new file mode 100644 index 00000000..8a05b6f2 --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/util/InstallApkUtil.java @@ -0,0 +1,19 @@ +package org.meowcat.edxposed.manager.util; + +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.content.res.Resources; + +public class InstallApkUtil { + + public static String getAppLabel(ApplicationInfo info, PackageManager pm) { + try { + if (info.labelRes > 0) { + Resources res = pm.getResourcesForApplication(info); + return res.getString(info.labelRes); + } + } catch (Exception ignored) { + } + return info.loadLabel(pm).toString(); + } +} diff --git a/app/src/main/java/org/meowcat/edxposed/manager/util/LinearLayoutManagerFix.java b/app/src/main/java/org/meowcat/edxposed/manager/util/LinearLayoutManagerFix.java new file mode 100644 index 00000000..23d78865 --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/util/LinearLayoutManagerFix.java @@ -0,0 +1,30 @@ +package org.meowcat.edxposed.manager.util; + +import android.content.Context; +import android.util.AttributeSet; + +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +public class LinearLayoutManagerFix extends LinearLayoutManager { + public LinearLayoutManagerFix(Context context) { + super(context); + } + + public LinearLayoutManagerFix(Context context, int orientation, boolean reverseLayout) { + super(context, orientation, reverseLayout); + } + + public LinearLayoutManagerFix(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + @Override + public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) { + try { + super.onLayoutChildren(recycler, state); + } catch (IndexOutOfBoundsException e) { + e.printStackTrace(); + } + } +} diff --git a/app/src/main/java/org/meowcat/edxposed/manager/util/ModuleUtil.java b/app/src/main/java/org/meowcat/edxposed/manager/util/ModuleUtil.java new file mode 100644 index 00000000..8884ee5b --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/util/ModuleUtil.java @@ -0,0 +1,390 @@ +package org.meowcat.edxposed.manager.util; + +import android.content.Context; +import android.content.SharedPreferences; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.content.pm.PackageManager.NameNotFoundException; +import android.os.Build; +import android.util.Log; +import android.widget.Toast; + +import androidx.annotation.NonNull; + +import com.google.android.material.snackbar.Snackbar; + +import org.meowcat.edxposed.manager.App; +import org.meowcat.edxposed.manager.Constants; +import org.meowcat.edxposed.manager.R; +import org.meowcat.edxposed.manager.databinding.ActivityModulesBinding; +import org.meowcat.edxposed.manager.repo.ModuleVersion; +import org.meowcat.edxposed.manager.repo.RepoDb; + +import java.io.IOException; +import java.io.PrintWriter; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; + +public final class ModuleUtil { + private static final String PLAY_STORE_PACKAGE = "com.android.vending"; + // xposedminversion below this + public static int MIN_MODULE_VERSION = 2; // reject modules with + private static ModuleUtil instance = null; + private final PackageManager pm; + private final String frameworkPackageName; + private final List listeners = new CopyOnWriteArrayList<>(); + private final SharedPreferences pref; + //private InstalledModule framework = null; + private Map installedModules; + private boolean isReloading = false; + private Toast toast; + + private ModuleUtil() { + pref = App.getInstance().getSharedPreferences("enabled_modules", Context.MODE_PRIVATE); + pm = App.getInstance().getPackageManager(); + frameworkPackageName = App.getInstance().getPackageName(); + } + + public static synchronized ModuleUtil getInstance() { + if (instance == null) { + instance = new ModuleUtil(); + instance.reloadInstalledModules(); + } + return instance; + } + + public static int extractIntPart(String str) { + int result = 0, length = str.length(); + for (int offset = 0; offset < length; offset++) { + char c = str.charAt(offset); + if ('0' <= c && c <= '9') + result = result * 10 + (c - '0'); + else + break; + } + return result; + } + + @SuppressWarnings("MismatchedQueryAndUpdateOfCollection") + public void reloadInstalledModules() { + synchronized (this) { + if (isReloading) + return; + isReloading = true; + } + + Map modules = new HashMap<>(); + RepoDb.beginTransation(); + try { + RepoDb.deleteAllInstalledModules(); + + for (PackageInfo pkg : pm.getInstalledPackages(PackageManager.GET_META_DATA)) { + ApplicationInfo app = pkg.applicationInfo; + if (!app.enabled) + continue; + + InstalledModule installed = null; + if (app.metaData != null && app.metaData.containsKey("xposedmodule")) { + installed = new InstalledModule(pkg, false); + modules.put(pkg.packageName, installed); + }/* else if (isFramework(pkg.packageName)) { + framework = installed = new InstalledModule(pkg, true); + }*/ + + if (installed != null) + RepoDb.insertInstalledModule(installed); + } + + RepoDb.setTransactionSuccessful(); + } finally { + RepoDb.endTransation(); + } + + installedModules = modules; + synchronized (this) { + isReloading = false; + } + for (ModuleListener listener : listeners) { + listener.onInstalledModulesReloaded(instance); + } + } + + public InstalledModule reloadSingleModule(String packageName) { + PackageInfo pkg; + try { + pkg = pm.getPackageInfo(packageName, PackageManager.GET_META_DATA); + } catch (NameNotFoundException e) { + RepoDb.deleteInstalledModule(packageName); + InstalledModule old = installedModules.remove(packageName); + if (old != null) { + for (ModuleListener listener : listeners) { + listener.onSingleInstalledModuleReloaded(instance, packageName, null); + } + } + return null; + } + + ApplicationInfo app = pkg.applicationInfo; + if (app.enabled && app.metaData != null && app.metaData.containsKey("xposedmodule")) { + InstalledModule module = new InstalledModule(pkg, false); + RepoDb.insertInstalledModule(module); + installedModules.put(packageName, module); + for (ModuleListener listener : listeners) { + listener.onSingleInstalledModuleReloaded(instance, packageName, + module); + } + return module; + } else { + RepoDb.deleteInstalledModule(packageName); + InstalledModule old = installedModules.remove(packageName); + if (old != null) { + for (ModuleListener listener : listeners) { + listener.onSingleInstalledModuleReloaded(instance, packageName, null); + } + } + return null; + } + } + + public synchronized boolean isLoading() { + return isReloading; + } + +/* public InstalledModule getFramework() { + return framework; + }*/ + + public String getFrameworkPackageName() { + return frameworkPackageName; + } + +/* private boolean isFramework(String packageName) { + return frameworkPackageName.equals(packageName); + }*/ + +// public boolean isInstalled(String packageName) { +// return installedModules.containsKey(packageName) || isFramework(packageName); +// } + + public InstalledModule getModule(String packageName) { + return installedModules.get(packageName); + } + + public Map getModules() { + return installedModules; + } + + public void setModuleEnabled(String packageName, boolean enabled) { + if (enabled) { + pref.edit().putInt(packageName, 1).apply(); + } else { + pref.edit().remove(packageName).apply(); + } + } + + public boolean isModuleEnabled(String packageName) { + return pref.contains(packageName); + } + + public List getEnabledModules() { + LinkedList result = new LinkedList<>(); + + for (String packageName : pref.getAll().keySet()) { + InstalledModule module = getModule(packageName); + if (module != null) + result.add(module); + else + setModuleEnabled(packageName, false); + } + + return result; + } + + public synchronized void updateModulesList(boolean showToast) { + updateModulesList(showToast, null); + } + + public synchronized void updateModulesList(boolean showToast, ActivityModulesBinding binding) { + try { + Log.i(App.TAG, "ModuleUtil -> updating modules.list"); + int installedXposedVersion = Constants.getXposedApiVersion(); + if (!App.getPreferences().getBoolean("skip_xposedminversion_check", false) && installedXposedVersion <= 0 && showToast) { + if (binding != null) { + Snackbar.make(binding.snackbar, R.string.notinstalled, Snackbar.LENGTH_SHORT).show(); + } else { + showToast(R.string.notinstalled); + } + return; + } + + PrintWriter modulesList = new PrintWriter(Constants.getModulesListFile()); + PrintWriter enabledModulesList = new PrintWriter(Constants.getEnabledModulesListFile()); + List enabledModules = getEnabledModules(); + for (InstalledModule module : enabledModules) { + + if (!App.getPreferences().getBoolean("skip_xposedminversion_check", false) && (module.minVersion > installedXposedVersion || module.minVersion < MIN_MODULE_VERSION) && showToast) { + if (binding != null) { + Snackbar.make(binding.snackbar, R.string.notinstalled, Snackbar.LENGTH_SHORT).show(); + } else { + showToast(R.string.notinstalled); + } + continue; + } + + modulesList.println(module.app.sourceDir); + + try { + String installer = pm.getInstallerPackageName(module.app.packageName); + if (!PLAY_STORE_PACKAGE.equals(installer)) + enabledModulesList.println(module.app.packageName); + } catch (Exception ignored) { + } + } + modulesList.close(); + enabledModulesList.close(); + + if (showToast) { + if (binding != null) { + Snackbar.make(binding.snackbar, R.string.xposed_module_list_updated, Snackbar.LENGTH_SHORT).show(); + } else { + showToast(R.string.xposed_module_list_updated); + } + } + } catch (IOException e) { + Log.e(App.TAG, "ModuleUtil -> cannot write " + Constants.getModulesListFile(), e); + if (binding != null) { + Snackbar.make(binding.snackbar, "cannot write " + Constants.getModulesListFile() + e, Snackbar.LENGTH_SHORT).show(); + } else { + Toast.makeText(App.getInstance(), "cannot write " + Constants.getModulesListFile() + e, Toast.LENGTH_SHORT).show(); + } + } + } + + @SuppressWarnings("SameParameterValue") + private void showToast(int message) { + if (toast != null) { + toast.cancel(); + toast = null; + } + toast = Toast.makeText(App.getInstance(), App.getInstance().getString(message), Toast.LENGTH_SHORT); + toast.show(); + } + + public void addListener(ModuleListener listener) { + if (!listeners.contains(listener)) + listeners.add(listener); + } + + public void removeListener(ModuleListener listener) { + listeners.remove(listener); + } + + public interface ModuleListener { + /** + * Called whenever one (previously or now) installed module has been + * reloaded + */ + void onSingleInstalledModuleReloaded(ModuleUtil moduleUtil, String packageName, InstalledModule module); + + /** + * Called whenever all installed modules have been reloaded + */ + void onInstalledModulesReloaded(ModuleUtil moduleUtil); + } + + public class InstalledModule { + //private static final int FLAG_FORWARD_LOCK = 1 << 29; + public final String packageName; + public final String versionName; + public final long versionCode; + public final int minVersion; + public final long installTime; + public final long updateTime; + final boolean isFramework; + public ApplicationInfo app; + public PackageInfo pkg; + private String appName; // loaded lazyily + private String description; // loaded lazyily + + private InstalledModule(PackageInfo pkg, boolean isFramework) { + this.app = pkg.applicationInfo; + this.pkg = pkg; + this.packageName = pkg.packageName; + this.isFramework = isFramework; + this.versionName = pkg.versionName; + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + this.versionCode = pkg.versionCode; + } else { + this.versionCode = pkg.getLongVersionCode(); + } + this.installTime = pkg.firstInstallTime; + this.updateTime = pkg.lastUpdateTime; + + if (isFramework) { + this.minVersion = 0; + this.description = ""; + } else { + int version = Constants.getXposedApiVersion(); + if (version > 0 && App.getPreferences().getBoolean("skip_xposedminversion_check", false)) { + this.minVersion = version; + } else { + Object minVersionRaw = app.metaData.get("xposedminversion"); + if (minVersionRaw instanceof Integer) { + this.minVersion = (Integer) minVersionRaw; + } else if (minVersionRaw instanceof String) { + this.minVersion = extractIntPart((String) minVersionRaw); + } else { + this.minVersion = 0; + } + } + } + } + + public boolean isInstalledOnExternalStorage() { + return (app.flags & ApplicationInfo.FLAG_EXTERNAL_STORAGE) != 0; + } + + public String getAppName() { + if (appName == null) + appName = app.loadLabel(pm).toString(); + return appName; + } + + public String getDescription() { + if (this.description == null) { + Object descriptionRaw = app.metaData.get("xposeddescription"); + String descriptionTmp = null; + if (descriptionRaw instanceof String) { + descriptionTmp = ((String) descriptionRaw).trim(); + } else if (descriptionRaw instanceof Integer) { + try { + int resId = (Integer) descriptionRaw; + if (resId != 0) + descriptionTmp = pm.getResourcesForApplication(app).getString(resId).trim(); + } catch (Exception ignored) { + } + } + this.description = (descriptionTmp != null) ? descriptionTmp : ""; + } + return this.description; + } + + public boolean isUpdate(ModuleVersion version) { + return (version != null) && version.code > versionCode; + } + + public PackageInfo getPackageInfo() { + return pkg; + } + + @NonNull + @Override + public String toString() { + return getAppName(); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/meowcat/edxposed/manager/util/NavUtil.java b/app/src/main/java/org/meowcat/edxposed/manager/util/NavUtil.java new file mode 100644 index 00000000..d3e64b4a --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/util/NavUtil.java @@ -0,0 +1,52 @@ +package org.meowcat.edxposed.manager.util; + +import android.content.Context; +import android.net.Uri; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.style.URLSpan; +import android.text.util.Linkify; + +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.browser.customtabs.CustomTabsIntent; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import org.meowcat.edxposed.manager.App; +import org.meowcat.edxposed.manager.R; +import org.meowcat.edxposed.manager.ui.activity.BaseActivity; + +public final class NavUtil { + + public static Uri parseURL(String str) { + if (str == null || str.isEmpty()) + return null; + + Spannable spannable = new SpannableString(str); + Linkify.addLinks(spannable, Linkify.WEB_URLS | Linkify.EMAIL_ADDRESSES); + + URLSpan[] spans = spannable.getSpans(0, spannable.length(), URLSpan.class); + return (spans.length > 0) ? Uri.parse(spans[0].getURL()) : null; + } + + public static void startURL(BaseActivity activity, Uri uri) { + CustomTabsIntent.Builder customTabsIntent = new CustomTabsIntent.Builder(); + customTabsIntent.setShowTitle(true); + customTabsIntent.setToolbarColor(activity.getThemedColor(R.attr.colorActionBar)); + customTabsIntent.build().launchUrl(activity, uri); + } + + public static void startURL(BaseActivity activity, String url) { + startURL(activity, parseURL(url)); + } + + @AnyThread + public static void showMessage(final @NonNull Context context, final CharSequence message) { + App.runOnUiThread(() -> new MaterialAlertDialogBuilder(context) + .setMessage(message) + .setPositiveButton(android.R.string.ok, null) + .show()); + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/meowcat/edxposed/manager/util/NotificationUtil.java b/app/src/main/java/org/meowcat/edxposed/manager/util/NotificationUtil.java new file mode 100644 index 00000000..b5874fdd --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/util/NotificationUtil.java @@ -0,0 +1,217 @@ +package org.meowcat.edxposed.manager.util; + +import android.annotation.SuppressLint; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.os.Build; +import android.util.Log; +import android.widget.Toast; + +import androidx.core.app.NotificationCompat; +import androidx.core.content.ContextCompat; + +import com.topjohnwu.superuser.Shell; + +import org.meowcat.edxposed.manager.App; +import org.meowcat.edxposed.manager.R; +import org.meowcat.edxposed.manager.ui.activity.MainActivity; + +public final class NotificationUtil { + + public static final int NOTIFICATION_MODULE_NOT_ACTIVATED_YET = 0; + private static final int NOTIFICATION_MODULES_UPDATED = 1; + private static final int NOTIFICATION_INSTALLER_UPDATE = 2; + private static final int PENDING_INTENT_OPEN_MODULES = 0; + private static final int PENDING_INTENT_OPEN_INSTALL = 1; + private static final int PENDING_INTENT_SOFT_REBOOT = 2; + private static final int PENDING_INTENT_REBOOT = 3; + private static final int PENDING_INTENT_ACTIVATE_MODULE_AND_REBOOT = 4; + private static final int PENDING_INTENT_ACTIVATE_MODULE = 5; + + private static final String HEADS_UP = "heads_up"; + private static final String FRAGMENT_ID = "fragment"; + + private static final String NOTIFICATION_UPDATE_CHANNEL = "app_update_channel"; + private static final String NOTIFICATION_MODULES_CHANNEL = "modules_channel"; + + @SuppressLint("StaticFieldLeak") + private static Context context = null; + private static NotificationManager notificationManager; + private static SharedPreferences prefs; + + public static void init() { + if (context != null) { + return; + } + + context = App.getInstance(); + prefs = App.getPreferences(); + notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationChannel channelUpdate = new NotificationChannel(NOTIFICATION_UPDATE_CHANNEL, context.getString(R.string.download_section_update_available), NotificationManager.IMPORTANCE_DEFAULT); + NotificationChannel channelModule = new NotificationChannel(NOTIFICATION_MODULES_CHANNEL, context.getString(R.string.nav_item_modules), NotificationManager.IMPORTANCE_DEFAULT); + notificationManager.createNotificationChannel(channelUpdate); + notificationManager.createNotificationChannel(channelModule); + } + } + + public static void cancel(String tag, int id) { + notificationManager.cancel(tag, id); + } + + public static void cancelAll() { + notificationManager.cancelAll(); + } + + public static void showNotActivatedNotification(String packageName, String appName) { + Intent intent = new Intent(context, MainActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK).putExtra(FRAGMENT_ID, 1); + PendingIntent pModulesTab = PendingIntent.getActivity(context, PENDING_INTENT_OPEN_MODULES, intent, PendingIntent.FLAG_UPDATE_CURRENT); + + String title = context.getString(R.string.module_is_not_activated_yet); + NotificationCompat.Builder builder = getNotificationBuilder(title, appName, NOTIFICATION_MODULES_CHANNEL) + .setContentIntent(pModulesTab); + if (prefs.getBoolean(HEADS_UP, true)) { + builder.setPriority(2); + } + Intent iActivateAndReboot = new Intent(context, RebootReceiver.class); + iActivateAndReboot.putExtra(RebootReceiver.EXTRA_ACTIVATE_MODULE, packageName); + PendingIntent pActivateAndReboot = PendingIntent.getBroadcast(context, PENDING_INTENT_ACTIVATE_MODULE_AND_REBOOT, + iActivateAndReboot, PendingIntent.FLAG_UPDATE_CURRENT); + Intent iActivate = new Intent(context, RebootReceiver.class); + iActivate.putExtra(RebootReceiver.EXTRA_ACTIVATE_MODULE, packageName); + iActivate.putExtra(RebootReceiver.EXTRA_ACTIVATE_MODULE_AND_RETURN, true); + PendingIntent pActivate = PendingIntent.getBroadcast(context, PENDING_INTENT_ACTIVATE_MODULE, + iActivate, PendingIntent.FLAG_UPDATE_CURRENT); + + NotificationCompat.BigTextStyle style = new NotificationCompat.BigTextStyle(); + style.setBigContentTitle(title); + style.bigText(context.getString(R.string.module_is_not_activated_yet_detailed, appName)); + builder.setStyle(style); + + // Only show the quick activation button if any module has been + // enabled before, + // to ensure that the user know the way to disable the module later. + if (!ModuleUtil.getInstance().getEnabledModules().isEmpty()) { + builder.addAction(new NotificationCompat.Action.Builder(R.drawable.ic_apps, context.getString(R.string.activate_and_reboot), pActivateAndReboot).build()); + builder.addAction(new NotificationCompat.Action.Builder(R.drawable.ic_apps, context.getString(R.string.activate_only), pActivate).build()); + } + + notificationManager.notify(packageName, NOTIFICATION_MODULE_NOT_ACTIVATED_YET, builder.build()); + } + + public static void showModulesUpdatedNotification() { + Intent intent = new Intent(context, MainActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.putExtra(FRAGMENT_ID, 0); + + PendingIntent pInstallTab = PendingIntent.getActivity(context, PENDING_INTENT_OPEN_INSTALL, + intent, PendingIntent.FLAG_UPDATE_CURRENT); + + String title = context + .getString(R.string.xposed_module_updated_notification_title); + String message = context + .getString(R.string.xposed_module_updated_notification); + NotificationCompat.Builder builder = getNotificationBuilder(title, message, NOTIFICATION_MODULES_CHANNEL) + .setContentIntent(pInstallTab); + if (prefs.getBoolean(HEADS_UP, true)) { + builder.setPriority(2); + } + Intent iSoftReboot = new Intent(context, RebootReceiver.class); + iSoftReboot.putExtra(RebootReceiver.EXTRA_SOFT_REBOOT, true); + PendingIntent pSoftReboot = PendingIntent.getBroadcast(context, PENDING_INTENT_SOFT_REBOOT, + iSoftReboot, PendingIntent.FLAG_UPDATE_CURRENT); + + Intent iReboot = new Intent(context, RebootReceiver.class); + PendingIntent pReboot = PendingIntent.getBroadcast(context, PENDING_INTENT_REBOOT, + iReboot, PendingIntent.FLAG_UPDATE_CURRENT); + + builder.addAction(new NotificationCompat.Action.Builder(0, context.getString(R.string.reboot), pReboot).build()); + builder.addAction(new NotificationCompat.Action.Builder(0, context.getString(R.string.soft_reboot), pSoftReboot).build()); + + notificationManager.notify(null, NOTIFICATION_MODULES_UPDATED, builder.build()); + } + + public static void showInstallerUpdateNotification() { + Intent intent = new Intent(context, MainActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.putExtra(FRAGMENT_ID, 0); + + PendingIntent pInstallTab = PendingIntent.getActivity(context, PENDING_INTENT_OPEN_INSTALL, + intent, PendingIntent.FLAG_UPDATE_CURRENT); + + String title = context.getString(R.string.app_name); + String message = context.getString(R.string.newVersion); + NotificationCompat.Builder builder = getNotificationBuilder(title, message, NOTIFICATION_UPDATE_CHANNEL) + .setContentIntent(pInstallTab); + + if (prefs.getBoolean(HEADS_UP, true)) { + builder.setPriority(2); + } + NotificationCompat.BigTextStyle notiStyle = new NotificationCompat.BigTextStyle(); + notiStyle.setBigContentTitle(title); + notiStyle.bigText(message); + builder.setStyle(notiStyle); + + notificationManager.notify(null, NOTIFICATION_INSTALLER_UPDATE, builder.build()); + } + + private static NotificationCompat.Builder getNotificationBuilder(String title, String message, String channel) { + return new NotificationCompat.Builder(context, channel) + .setContentTitle(title) + .setContentText(message) + .setVibrate(new long[]{0}) + .setAutoCancel(true) + .setSmallIcon(R.drawable.ic_notification) + .setColor(ContextCompat.getColor(context, R.color.colorPrimary)); + } + + public static class RebootReceiver extends BroadcastReceiver { + public static String EXTRA_SOFT_REBOOT = "soft"; + public static String EXTRA_ACTIVATE_MODULE = "activate_module"; + public static String EXTRA_ACTIVATE_MODULE_AND_RETURN = "activate_module_and_return"; + + @Override + public void onReceive(Context context, Intent intent) { + /* + * Close the notification bar in order to see the toast that module + * was enabled successfully. Furthermore, if SU permissions haven't + * been granted yet, the SU dialog will be prompted behind the + * expanded notification panel and is therefore not visible to the + * user. + */ + context.sendBroadcast(new Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS)); + cancelAll(); + + if (intent.hasExtra(EXTRA_ACTIVATE_MODULE)) { + String packageName = intent.getStringExtra(EXTRA_ACTIVATE_MODULE); + ModuleUtil moduleUtil = ModuleUtil.getInstance(); + moduleUtil.setModuleEnabled(packageName, true); + moduleUtil.updateModulesList(false); + Toast.makeText(context, R.string.module_activated, Toast.LENGTH_SHORT).show(); + + if (intent.hasExtra(EXTRA_ACTIVATE_MODULE_AND_RETURN)) return; + } + + if (!Shell.rootAccess()) { + Log.e(App.TAG, "NotificationUtil -> Could not start root shell"); + return; + } + + boolean isSoftReboot = intent.getBooleanExtra(EXTRA_SOFT_REBOOT, + false); + int returnCode = isSoftReboot + ? Shell.su("setprop ctl.restart surfaceflinger; setprop ctl.restart zygote").exec().getCode() + : Shell.su("reboot").exec().getCode(); + + if (returnCode != 0) { + Log.e(App.TAG, "NotificationUtil -> Could not reboot"); + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/meowcat/edxposed/manager/util/PrefixedSharedPreferences.java b/app/src/main/java/org/meowcat/edxposed/manager/util/PrefixedSharedPreferences.java new file mode 100644 index 00000000..abe4cb21 --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/util/PrefixedSharedPreferences.java @@ -0,0 +1,161 @@ +package org.meowcat.edxposed.manager.util; + +import android.annotation.SuppressLint; +import android.content.SharedPreferences; + +import androidx.preference.PreferenceManager; + +import java.lang.reflect.Field; +import java.util.HashMap; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; + +public class PrefixedSharedPreferences implements SharedPreferences { + private final SharedPreferences mBase; + private final String mPrefix; + + private PrefixedSharedPreferences(SharedPreferences base, String prefix) { + mBase = base; + mPrefix = prefix + "_"; + } + + public static void injectToPreferenceManager(PreferenceManager manager, String prefix) { + SharedPreferences prefixedPrefs = new PrefixedSharedPreferences(manager.getSharedPreferences(), prefix); + + try { + Field fieldSharedPref = PreferenceManager.class.getDeclaredField("mSharedPreferences"); + fieldSharedPref.setAccessible(true); + fieldSharedPref.set(manager, prefixedPrefs); + } catch (Throwable t) { + throw new RuntimeException(t); + } + } + + @Override + public Map getAll() { + Map baseResult = mBase.getAll(); + Map prefixedResult = new HashMap<>(baseResult); + for (Entry entry : baseResult.entrySet()) { + prefixedResult.put(mPrefix + entry.getKey(), entry.getValue()); + } + return prefixedResult; + } + + @Override + public String getString(String key, String defValue) { + return mBase.getString(mPrefix + key, defValue); + } + + @Override + public Set getStringSet(String key, Set defValues) { + return mBase.getStringSet(mPrefix + key, defValues); + } + + @Override + public int getInt(String key, int defValue) { + return mBase.getInt(mPrefix + key, defValue); + } + + @Override + public long getLong(String key, long defValue) { + return mBase.getLong(mPrefix + key, defValue); + } + + @Override + public float getFloat(String key, float defValue) { + return mBase.getFloat(mPrefix + key, defValue); + } + + @Override + public boolean getBoolean(String key, boolean defValue) { + return mBase.getBoolean(mPrefix + key, defValue); + } + + @Override + public boolean contains(String key) { + return mBase.contains(mPrefix + key); + } + + @SuppressLint("CommitPrefEdits") + @Override + public Editor edit() { + return new EditorImpl(mBase.edit()); + } + + @Override + public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) { + throw new UnsupportedOperationException("listeners are not supported in this implementation"); + } + + @Override + public void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) { + throw new UnsupportedOperationException("listeners are not supported in this implementation"); + } + + private class EditorImpl implements Editor { + private final Editor mEditorBase; + + public EditorImpl(Editor base) { + mEditorBase = base; + } + + @Override + public Editor putString(String key, String value) { + mEditorBase.putString(mPrefix + key, value); + return this; + } + + @Override + public Editor putStringSet(String key, Set values) { + mEditorBase.putStringSet(mPrefix + key, values); + return this; + } + + @Override + public Editor putInt(String key, int value) { + mEditorBase.putInt(mPrefix + key, value); + return this; + } + + @Override + public Editor putLong(String key, long value) { + mEditorBase.putLong(mPrefix + key, value); + return this; + } + + @Override + public Editor putFloat(String key, float value) { + mEditorBase.putFloat(mPrefix + key, value); + return this; + } + + @Override + public Editor putBoolean(String key, boolean value) { + mEditorBase.putBoolean(mPrefix + key, value); + return this; + } + + @Override + public Editor remove(String key) { + mEditorBase.remove(mPrefix + key); + return this; + } + + @Override + public Editor clear() { + mEditorBase.clear(); + return this; + } + + @Override + public boolean commit() { + return mEditorBase.commit(); + } + + @Override + public void apply() { + mEditorBase.apply(); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/meowcat/edxposed/manager/util/RepoLoader.java b/app/src/main/java/org/meowcat/edxposed/manager/util/RepoLoader.java new file mode 100644 index 00000000..ab53f1e9 --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/util/RepoLoader.java @@ -0,0 +1,434 @@ +package org.meowcat.edxposed.manager.util; + +import android.app.AlarmManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.database.sqlite.SQLiteException; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.text.TextUtils; +import android.util.Log; +import android.widget.Toast; + +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import org.meowcat.edxposed.manager.App; +import org.meowcat.edxposed.manager.R; +import org.meowcat.edxposed.manager.repo.Module; +import org.meowcat.edxposed.manager.repo.ModuleVersion; +import org.meowcat.edxposed.manager.repo.ReleaseType; +import org.meowcat.edxposed.manager.repo.RepoDb; +import org.meowcat.edxposed.manager.repo.RepoParser; +import org.meowcat.edxposed.manager.repo.RepoParser.RepoParserCallback; +import org.meowcat.edxposed.manager.repo.Repository; +import org.meowcat.edxposed.manager.ui.activity.DownloadActivity; +import org.meowcat.edxposed.manager.util.DownloadsUtil.SyncDownloadInfo; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.zip.GZIPInputStream; + +public class RepoLoader { + private static final int UPDATE_FREQUENCY = 24 * 60 * 60 * 1000; + private static String DEFAULT_REPOSITORIES; + private static RepoLoader instance = null; + private final List listeners = new CopyOnWriteArrayList<>(); + private final Map localReleaseTypesCache = new HashMap<>(); + private final App app; + private final SharedPreferences pref; + private final SharedPreferences modulePref; + private final ConnectivityManager conMgr; + private boolean isLoading = false; + private boolean reloadTriggeredOnce = false; + private Map repositories = null; + private ReleaseType globalReleaseType; + private SwipeRefreshLayout swipeRefreshLayout; + + private RepoLoader() { + instance = this; + app = App.getInstance(); + pref = app.getSharedPreferences("repo", Context.MODE_PRIVATE); + DEFAULT_REPOSITORIES = App.getPreferences().getBoolean("custom_list", false) ? "https://cdn.jsdelivr.net/gh/ElderDrivers/Repository-Website@gh-pages/assets/full.xml.gz" : "https://dl-xda.xposed.info/repo/full.xml.gz"; + modulePref = app.getSharedPreferences("module_settings", Context.MODE_PRIVATE); + conMgr = (ConnectivityManager) app.getSystemService(Context.CONNECTIVITY_SERVICE); + globalReleaseType = ReleaseType.fromString(App.getPreferences().getString("release_type_global", "stable")); + refreshRepositories(); + } + + public static synchronized RepoLoader getInstance() { + if (instance == null) + new RepoLoader(); + return instance; + } + + private boolean refreshRepositories() { + repositories = RepoDb.getRepositories(); + + // Unlikely case (usually only during initial load): DB state doesn't + // fit to configuration + boolean needReload = false; + String[] config = (pref.getString("repositories", DEFAULT_REPOSITORIES) + "").split("\\|"); + if (repositories.size() != config.length) { + needReload = true; + } else { + int i = 0; + for (Repository repo : repositories.values()) { + if (!repo.url.equals(config[i++])) { + needReload = true; + break; + } + } + } + + if (!needReload) + return false; + + clear(false); + for (String url : config) { + RepoDb.insertRepository(url); + } + repositories = RepoDb.getRepositories(); + return true; + } + + public void setReleaseTypeGlobal(String relTypeString) { + ReleaseType relType = ReleaseType.fromString(relTypeString); + if (globalReleaseType == relType) + return; + + globalReleaseType = relType; + + // Updating the latest version for all modules takes a moment + new Thread("DBUpdate") { + @Override + public void run() { + RepoDb.updateAllModulesLatestVersion(); + notifyListeners(); + } + }.start(); + } + + public void setReleaseTypeLocal(String packageName, String relTypeString) { + ReleaseType relType = (!TextUtils.isEmpty(relTypeString)) ? ReleaseType.fromString(relTypeString) : null; + + if (getReleaseTypeLocal(packageName) == relType) + return; + + synchronized (localReleaseTypesCache) { + if (relType != null) { + localReleaseTypesCache.put(packageName, relType); + } + } + + RepoDb.updateModuleLatestVersion(packageName); + notifyListeners(); + } + + private ReleaseType getReleaseTypeLocal(String packageName) { + synchronized (localReleaseTypesCache) { + if (localReleaseTypesCache.containsKey(packageName)) + return localReleaseTypesCache.get(packageName); + + String value = modulePref.getString(packageName + "_release_type", + null); + ReleaseType result = (!TextUtils.isEmpty(value)) ? ReleaseType.fromString(value) : null; + if (result != null) { + localReleaseTypesCache.put(packageName, result); + } + return result; + } + } + + public Repository getRepository(long repoId) { + return repositories.get(repoId); + } + + public Module getModule(String packageName) { + return RepoDb.getModuleByPackageName(packageName); + } + + public ModuleVersion getLatestVersion(Module module) { + if (module == null || module.versions.isEmpty()) + return null; + + for (ModuleVersion version : module.versions) { + if (version.downloadLink != null && isVersionShown(version)) + return version; + } + return null; + } + + public boolean isVersionShown(ModuleVersion version) { + return version.relType + .ordinal() <= getMaxShownReleaseType(version.module.packageName).ordinal(); + } + + public ReleaseType getMaxShownReleaseType(String packageName) { + ReleaseType localSetting = getReleaseTypeLocal(packageName); + if (localSetting != null) + return localSetting; + else + return globalReleaseType; + } + + public void triggerReload(final boolean force) { + reloadTriggeredOnce = true; + + if (force) { + resetLastUpdateCheck(); + } else { + long lastUpdateCheck = pref.getLong("last_update_check", 0); + if (System.currentTimeMillis() < lastUpdateCheck + UPDATE_FREQUENCY) + return; + } + + NetworkInfo netInfo = conMgr.getActiveNetworkInfo(); + if (netInfo == null || !netInfo.isConnected()) + return; + + synchronized (this) { + if (isLoading) + return; + isLoading = true; + } + app.updateProgressIndicator(swipeRefreshLayout); + + new Thread("RepositoryReload") { + public void run() { + final List messages = new LinkedList<>(); + boolean hasChanged = downloadAndParseFiles(messages); + + pref.edit().putLong("last_update_check", System.currentTimeMillis()).apply(); + + if (!messages.isEmpty()) { + App.runOnUiThread(() -> { + for (String message : messages) { + Toast.makeText(app, message, Toast.LENGTH_LONG).show(); + } + }); + } + + if (hasChanged) + notifyListeners(); + + synchronized (this) { + isLoading = false; + } + app.updateProgressIndicator(swipeRefreshLayout); + } + }.start(); + } + + public void setSwipeRefreshLayout(SwipeRefreshLayout swipeRefreshLayout) { + this.swipeRefreshLayout = swipeRefreshLayout; + } + + public void triggerFirstLoadIfNecessary() { + if (!reloadTriggeredOnce) + triggerReload(false); + } + + public void resetLastUpdateCheck() { + pref.edit().remove("last_update_check").apply(); + } + + public synchronized boolean isLoading() { + return isLoading; + } + + public void clear(boolean notify) { + synchronized (this) { + // TODO Stop reloading repository when it should be cleared + if (isLoading) + return; + + RepoDb.deleteRepositories(); + repositories = new LinkedHashMap<>(0); + DownloadsUtil.clearCache(null); + resetLastUpdateCheck(); + } + + if (notify) + notifyListeners(); + } + + public void setRepositories(String... repos) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < repos.length; i++) { + if (i > 0) + sb.append("|"); + sb.append(repos[i]); + } + pref.edit().putString("repositories", sb.toString()).apply(); + if (refreshRepositories()) + triggerReload(true); + } + + public boolean hasModuleUpdates() { + return RepoDb.hasModuleUpdates(); + } + + public String getFrameworkUpdateVersion() { + return RepoDb.getFrameworkUpdateVersion(); + } + + private File getRepoCacheFile(String repo) { + String filename = "repo_" + HashUtil.md5(repo) + ".xml"; + if (repo.endsWith(".gz")) + filename += ".gz"; + return new File(app.getCacheDir(), filename); + } + + private boolean downloadAndParseFiles(List messages) { + // These variables don't need to be atomic, just mutable + final AtomicBoolean hasChanged = new AtomicBoolean(false); + final AtomicInteger insertCounter = new AtomicInteger(); + final AtomicInteger deleteCounter = new AtomicInteger(); + + for (Entry repoEntry : repositories.entrySet()) { + final long repoId = repoEntry.getKey(); + final Repository repo = repoEntry.getValue(); + + String url = (repo.partialUrl != null && repo.version != null) ? String.format(repo.partialUrl, repo.version) : repo.url; + + File cacheFile = getRepoCacheFile(url); + SyncDownloadInfo info = DownloadsUtil.downloadSynchronously(url, + cacheFile); + + Log.i(App.TAG, String.format( + "RepoLoader -> Downloaded %s with status %d (error: %s), size %d bytes", + url, info.status, info.errorMessage, cacheFile.length())); + + if (info.status != SyncDownloadInfo.STATUS_SUCCESS) { + if (info.errorMessage != null) + messages.add(info.errorMessage); + continue; + } + + InputStream in = null; + RepoDb.beginTransation(); + try { + in = new FileInputStream(cacheFile); + if (url.endsWith(".gz")) + in = new GZIPInputStream(in); + + RepoParser.parse(in, new RepoParserCallback() { + @Override + public void onRepositoryMetadata(Repository repository) { + if (!repository.isPartial) { + RepoDb.deleteAllModules(repoId); + hasChanged.set(true); + } + } + + @Override + public void onNewModule(Module module) { + RepoDb.insertModule(repoId, module); + hasChanged.set(true); + insertCounter.incrementAndGet(); + } + + @Override + public void onRemoveModule(String packageName) { + RepoDb.deleteModule(repoId, packageName); + hasChanged.set(true); + deleteCounter.decrementAndGet(); + } + + @Override + public void onCompleted(Repository repository) { + if (!repository.isPartial) { + RepoDb.updateRepository(repoId, repository); + repo.name = repository.name; + repo.partialUrl = repository.partialUrl; + repo.version = repository.version; + } else { + RepoDb.updateRepositoryVersion(repoId, repository.version); + repo.version = repository.version; + } + + Log.i(App.TAG, String.format( + "RepoLoader -> Updated repository %s to version %s (%d new / %d removed modules)", + repo.url, repo.version, insertCounter.get(), + deleteCounter.get())); + } + }); + + RepoDb.setTransactionSuccessful(); + } catch (SQLiteException e) { + App.runOnUiThread(() -> new MaterialAlertDialogBuilder(app) + .setTitle(R.string.restart_needed) + .setMessage(R.string.cache_cleaned) + .setPositiveButton(android.R.string.ok, (dialog, which) -> { + Intent i = new Intent(app, DownloadActivity.class); + PendingIntent pi = PendingIntent.getActivity(app, 0, i, PendingIntent.FLAG_CANCEL_CURRENT); + AlarmManager mgr = (AlarmManager) app.getSystemService(Context.ALARM_SERVICE); + mgr.set(AlarmManager.RTC, System.currentTimeMillis() + 100, pi); + System.exit(0); + }) + .setCancelable(false) + .show()); + + DownloadsUtil.clearCache(url); + } catch (Throwable t) { + Log.e(App.TAG, "RepoLoader -> Cannot load repository from " + url, t); + messages.add(app.getString(R.string.repo_load_failed, url, t.getMessage())); + DownloadsUtil.clearCache(url); + } finally { + if (in != null) + try { + in.close(); + } catch (IOException ignored) { + } + //noinspection ResultOfMethodCallIgnored + cacheFile.delete(); + RepoDb.endTransation(); + } + } + + // TODO Set ModuleColumns.PREFERRED for modules which appear in multiple + // repositories + return hasChanged.get(); + } + + public void addListener(RepoListener listener, boolean triggerImmediately) { + if (!listeners.contains(listener)) + listeners.add(listener); + + if (triggerImmediately) + listener.onRepoReloaded(this); + } + + public void removeListener(RepoListener listener) { + listeners.remove(listener); + } + + private void notifyListeners() { + for (RepoListener listener : listeners) { + listener.onRepoReloaded(instance); + } + } + + public interface RepoListener { + /** + * Called whenever the list of modules from repositories has been + * successfully reloaded + */ + void onRepoReloaded(RepoLoader loader); + } +} diff --git a/app/src/main/java/org/meowcat/edxposed/manager/util/TaskRunner.java b/app/src/main/java/org/meowcat/edxposed/manager/util/TaskRunner.java new file mode 100644 index 00000000..fbdbe1ac --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/util/TaskRunner.java @@ -0,0 +1,19 @@ +package org.meowcat.edxposed.manager.util; + +import java.util.concurrent.Callable; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +public class TaskRunner { + private final Executor executor = Executors.newSingleThreadExecutor(); // change according to your requirements + + public void executeAsync(Callable callable) { + executor.execute(() -> { + try { + callable.call(); + } catch (Exception e) { + e.printStackTrace(); + } + }); + } +} \ No newline at end of file diff --git a/app/src/main/java/org/meowcat/edxposed/manager/util/ToastUtil.java b/app/src/main/java/org/meowcat/edxposed/manager/util/ToastUtil.java new file mode 100644 index 00000000..1060356f --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/util/ToastUtil.java @@ -0,0 +1,22 @@ +package org.meowcat.edxposed.manager.util; + +import android.content.Context; +import android.widget.Toast; + +import androidx.annotation.StringRes; + +public class ToastUtil { + + public static void showShortToast(Context context, @StringRes int resId) { + Toast.makeText(context, resId, Toast.LENGTH_SHORT).show(); + } + + public static void showLongToast(Context context, @StringRes int resId) { + Toast.makeText(context, resId, Toast.LENGTH_LONG).show(); + } + + public static void showLongToast(Context context, String msg) { + Toast.makeText(context, msg, Toast.LENGTH_LONG).show(); + } + +} diff --git a/app/src/main/java/org/meowcat/edxposed/manager/util/chrome/CustomTabsURLSpan.java b/app/src/main/java/org/meowcat/edxposed/manager/util/chrome/CustomTabsURLSpan.java new file mode 100644 index 00000000..a7707ca5 --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/util/chrome/CustomTabsURLSpan.java @@ -0,0 +1,26 @@ +package org.meowcat.edxposed.manager.util.chrome; + +import android.text.style.URLSpan; +import android.view.View; + +import org.meowcat.edxposed.manager.ui.activity.BaseActivity; +import org.meowcat.edxposed.manager.util.NavUtil; + +/** + * Created by Nikola D. on 12/23/2015. + */ +public class CustomTabsURLSpan extends URLSpan { + + private final BaseActivity activity; + + CustomTabsURLSpan(BaseActivity activity, String url) { + super(url); + this.activity = activity; + } + + @Override + public void onClick(View widget) { + String url = getURL(); + NavUtil.startURL(activity, url); + } +} \ No newline at end of file diff --git a/app/src/main/java/org/meowcat/edxposed/manager/util/chrome/LinkTransformationMethod.java b/app/src/main/java/org/meowcat/edxposed/manager/util/chrome/LinkTransformationMethod.java new file mode 100644 index 00000000..b1404c95 --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/util/chrome/LinkTransformationMethod.java @@ -0,0 +1,51 @@ +package org.meowcat.edxposed.manager.util.chrome; + +import android.graphics.Rect; +import android.text.Spannable; +import android.text.Spanned; +import android.text.method.TransformationMethod; +import android.text.style.URLSpan; +import android.text.util.Linkify; +import android.view.View; +import android.widget.TextView; + +import org.meowcat.edxposed.manager.ui.activity.BaseActivity; + +/** + * Created by Nikola D. on 12/23/2015. + */ +public class LinkTransformationMethod implements TransformationMethod { + + private final BaseActivity activity; + + public LinkTransformationMethod(BaseActivity activity) { + this.activity = activity; + } + + @Override + public CharSequence getTransformation(CharSequence source, View view) { + if (view instanceof TextView) { + TextView textView = (TextView) view; + Linkify.addLinks(textView, Linkify.WEB_URLS); + if (textView.getText() == null || !(textView.getText() instanceof Spannable)) { + return source; + } + Spannable text = (Spannable) textView.getText(); + URLSpan[] spans = text.getSpans(0, textView.length(), URLSpan.class); + for (int i = spans.length - 1; i >= 0; i--) { + URLSpan oldSpan = spans[i]; + int start = text.getSpanStart(oldSpan); + int end = text.getSpanEnd(oldSpan); + String url = oldSpan.getURL(); + text.removeSpan(oldSpan); + text.setSpan(new CustomTabsURLSpan(activity, url), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + return text; + } + return source; + } + + @Override + public void onFocusChanged(View view, CharSequence sourceText, boolean focused, int direction, Rect previouslyFocusedRect) { + } +} \ No newline at end of file diff --git a/app/src/main/java/org/meowcat/edxposed/manager/util/json/JSONUtils.java b/app/src/main/java/org/meowcat/edxposed/manager/util/json/JSONUtils.java new file mode 100644 index 00000000..54be3d22 --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/util/json/JSONUtils.java @@ -0,0 +1,43 @@ +package org.meowcat.edxposed.manager.util.json; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.List; + +public class JSONUtils { + + public static final String JSON_LINK = "https://edxp.meowcat.org/assets/version.json"; + + public static String getFileContent(String url) throws IOException { + HttpURLConnection c = (HttpURLConnection) new URL(url).openConnection(); + c.setRequestMethod("GET"); + c.setInstanceFollowRedirects(false); + c.setDoOutput(false); + c.connect(); + + BufferedReader br = new BufferedReader(new InputStreamReader(c.getInputStream())); + StringBuilder sb = new StringBuilder(); + String line; + while ((line = br.readLine()) != null) { + sb.append(line); + } + br.close(); + + return sb.toString(); + } + + public static class XposedJson { + public List tabs; + public ApkRelease apk; + } + + public static class ApkRelease { + public String version; + public String changelog; + public String link; + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/meowcat/edxposed/manager/util/json/XposedTab.java b/app/src/main/java/org/meowcat/edxposed/manager/util/json/XposedTab.java new file mode 100644 index 00000000..20acb95c --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/util/json/XposedTab.java @@ -0,0 +1,66 @@ +package org.meowcat.edxposed.manager.util.json; + + +import android.os.Parcel; +import android.os.Parcelable; + +import java.util.ArrayList; +import java.util.List; + +@SuppressWarnings("MismatchedQueryAndUpdateOfCollection") +public class XposedTab implements Parcelable { + + public static final Creator CREATOR = new Creator() { + @Override + public XposedTab createFromParcel(Parcel in) { + return new XposedTab(in); + } + + @Override + public XposedTab[] newArray(int size) { + return new XposedTab[size]; + } + }; + + public List sdks = new ArrayList<>(); + public String name; + public String author; + public String description; + public String support; + public String notice; + public boolean stable; + public boolean official; + public List installers = new ArrayList<>(); + public List uninstallers = new ArrayList<>(); +// private HashMap compatibility = new HashMap<>(); +// private HashMap incompatibility = new HashMap<>(); + +// public XposedTab() { +// } + + private XposedTab(Parcel in) { + name = in.readString(); + author = in.readString(); + description = in.readString(); + support = in.readString(); + notice = in.readString(); + stable = in.readByte() != 0; + official = in.readByte() != 0; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(name); + dest.writeString(author); + dest.writeString(description); + dest.writeString(support); + dest.writeString(notice); + dest.writeByte((byte) (stable ? 1 : 0)); + dest.writeByte((byte) (official ? 1 : 0)); + } +} \ No newline at end of file diff --git a/app/src/main/java/org/meowcat/edxposed/manager/util/json/XposedZip.java b/app/src/main/java/org/meowcat/edxposed/manager/util/json/XposedZip.java new file mode 100644 index 00000000..946e3ca2 --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/util/json/XposedZip.java @@ -0,0 +1,66 @@ +package org.meowcat.edxposed.manager.util.json; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; + +import java.util.List; + +public class XposedZip { + + public String name; + public String link; + public String version; + public String description; + + public static class MyAdapter extends ArrayAdapter { + + private final Context context; + List list; + + public MyAdapter(Context context, List objects) { + super(context, android.R.layout.simple_spinner_dropdown_item, objects); + this.context = context; + this.list = objects; + } + + @NonNull + @Override + public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) { + return getMyView(parent, position); + } + + @Override + public View getDropDownView(int position, @Nullable View convertView, @NonNull ViewGroup parent) { + return getMyView(parent, position); + } + + private View getMyView(ViewGroup parent, int position) { + View row; + ItemHolder holder = new ItemHolder(); + + LayoutInflater inflater = ((AppCompatActivity) context).getLayoutInflater(); + row = inflater.inflate(android.R.layout.simple_dropdown_item_1line, parent, false); + + holder.name = row.findViewById(android.R.id.text1); + + row.setTag(holder); + + holder.name.setText(list.get(position).name); + return row; + } + + private static class ItemHolder { + TextView name; + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/meowcat/edxposed/manager/util/light/Hack.java b/app/src/main/java/org/meowcat/edxposed/manager/util/light/Hack.java new file mode 100644 index 00000000..69198517 --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/util/light/Hack.java @@ -0,0 +1,1203 @@ +package org.meowcat.edxposed.manager.util.light; + +import android.util.Log; + +import androidx.annotation.CheckResult; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.IOException; +import java.lang.reflect.AccessibleObject; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.Arrays; +import java.util.Comparator; + +/** + * Java reflection helper optimized for hacking non-public APIs. + * The core design philosophy behind is compile-time consistency enforcement. + *

    + * It's suggested to declare all hacks in a centralized point, typically as static fields in a class. + * Then call it during application initialization, thus they are verified all together in an early stage. + * If any assertion failed, a fall-back strategy is suggested. + * + *

    https://gist.github.com/oasisfeng/75d3774ca5441372f049818de4d52605 + * + * @author Oasis + * @see Demo + */ +@SuppressWarnings({"Convert2Lambda", "WeakerAccess", "unused"}) +class Hack { + + public static Class ANY_TYPE = $.class; + private static final HackedClass FALLBACK = new HackedClass<>(ANY_TYPE); + private static AssertionFailureHandler sFailureHandler; + + private Hack() { + } + + public static HackedClass into(final @NonNull Class clazz) { + return new HackedClass<>(clazz); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + public static HackedClass into(final String class_name) { + try { + return new HackedClass(Class.forName(class_name)); + } catch (final ClassNotFoundException e) { + fail(new AssertionException(e)); + return new HackedClass(ANY_TYPE); // Use AnyType as a lazy trick to make fallback working and avoid null. + } + } + + @SuppressWarnings("unchecked") + public static HackedClass onlyIf(final boolean condition, final Hacking> hacking) { + if (condition) return hacking.hack(); + return (HackedClass) FALLBACK; + } + + public static ConditionalHack onlyIf(final boolean condition) { + return condition ? new ConditionalHack() { + @Override + public HackedClass into(@NonNull final Class clazz) { + return Hack.into(clazz); + } + + @Override + public HackedClass into(final String class_name) { + return Hack.into(class_name); + } + } : new ConditionalHack() { + @SuppressWarnings("unchecked") + @Override + public HackedClass into(@NonNull final Class clazz) { + return (HackedClass) FALLBACK; + } + + @SuppressWarnings("unchecked") + @Override + public HackedClass into(final String class_name) { + return (HackedClass) FALLBACK; + } + }; + } + + private static void fail(final AssertionException e) { + if (sFailureHandler != null) sFailureHandler.onAssertionFailure(e); + } + + /** + * Specify a handler to deal with assertion failure, and decide whether the failure should be thrown. + */ + public static AssertionFailureHandler setAssertionFailureHandler(final AssertionFailureHandler handler) { + final AssertionFailureHandler old = sFailureHandler; + sFailureHandler = handler; + return old; + } + + /** + * Use {@link Hack#setAssertionFailureHandler(AssertionFailureHandler) } to set the global handler + */ + public interface AssertionFailureHandler { + void onAssertionFailure(AssertionException failure); + } + + public interface HackedField { + T get(C instance); + + void set(C instance, T value); + + HackedTargetField on(C target); + + Class getType(); + + boolean isAbsent(); + } + + public interface HackedTargetField { + T get(); + + void set(T value); + + Class getType(); + + boolean isAbsent(); + } + + public interface HackedInvokable { + @CheckResult + HackedInvokable throwing(Class type); + + @CheckResult + HackedInvokable throwing(Class type1, Class type2); + + @CheckResult + HackedInvokable throwing(Class type1, Class type2, Class type3); + + @Nullable + HackedMethod0 withoutParams(); + + @Nullable + HackedMethod1 withParam(Class type); + + @Nullable + HackedMethod2 withParams(Class type1, Class type2); + + @Nullable + HackedMethod3 withParams(Class type1, Class type2, Class type3); + + @Nullable + HackedMethod4 withParams(Class type1, Class type2, Class type3, Class type4); + + @Nullable + HackedMethod5 withParams(Class type1, final Class type2, final Class type3, final Class type4, final Class type5); + + @Nullable + HackedMethodN withParams(Class... types); + } + + public interface NonNullHackedInvokable extends HackedInvokable { + @CheckResult + NonNullHackedInvokable throwing(Class type); + + @CheckResult + NonNullHackedInvokable throwing(Class type1, Class type2); + + @CheckResult + NonNullHackedInvokable throwing(Class type1, Class type2, Class type3); + + @NonNull + HackedMethod0 withoutParams(); + + @NonNull + HackedMethod1 withParam(Class type); + + @NonNull + HackedMethod2 withParams(Class type1, Class type2); + + @NonNull + HackedMethod3 withParams(Class type1, Class type2, Class type3); + + @NonNull + HackedMethod4 withParams(Class type1, Class type2, Class type3, Class type4); + + @NonNull + HackedMethod5 withParams(Class type1, final Class type2, final Class type3, final Class type4, final Class type5); + + @NonNull + HackedMethodN withParams(Class... types); + } + + public interface HackedMethod extends HackedInvokable { + /** + * Optional + */ + @CheckResult + HackedMethod returning(Class type); + + /** + * Fallback to the given value if this field is unavailable at runtime. (Optional) + */ + @CheckResult + NonNullHackedMethod fallbackReturning(R return_value); + + @CheckResult + HackedMethod throwing(Class type); + + @CheckResult + HackedMethod throwing(Class type1, Class type2); + + @CheckResult + HackedMethod throwing(Class type1, Class type2, Class type3); + + @CheckResult + HackedMethod throwing(Class... types); + } + + @SuppressWarnings("NullableProblems") // Force to NonNull + public interface NonNullHackedMethod extends HackedMethod, NonNullHackedInvokable { + /** + * Optional + */ + @CheckResult + HackedMethod returning(Class type); + + @CheckResult + NonNullHackedMethod throwing(Class type); + + @CheckResult + NonNullHackedMethod throwing(Class type1, Class type2); + + @CheckResult + NonNullHackedMethod throwing(Class type1, Class type2, Class type3); + } + + interface Invokable { + Object invoke(C target, Object[] args) throws InvocationTargetException, IllegalAccessException, InstantiationException; + + Class getReturnType(); + } + + public interface Hacking { + T hack(); + } + + public interface ConditionalHack { + /** + * WARNING: Never use this method if the target class may not exist when the condition is not met, use {@link #onlyIf(boolean, Hacking)} instead. + */ + HackedClass into(final @NonNull Class clazz); + + HackedClass into(final String class_name); + } + + private static class $ { + } + + public static class AssertionException extends Throwable { + + private static final long serialVersionUID = 1L; + private Class mClass; + private Field mHackedField; + private Method mHackedMethod; + private String mHackedFieldName; + private String mHackedMethodName; + private Class[] mParamTypes; + + AssertionException(final String e) { + super(e); + } + + AssertionException(final Exception e) { + super(e); + } + + @Override + public String toString() { + return getCause() != null ? getClass().getName() + ": " + getCause() : super.toString(); + } + + public String getDebugInfo() { + final StringBuilder info = new StringBuilder(getCause() != null ? getCause().toString() : super.toString()); + final Throwable cause = getCause(); + if (cause instanceof NoSuchMethodException) { + info.append(" Potential candidates:"); + final int initial_length = info.length(); + final String name = getHackedMethodName(); + if (name != null) { + for (final Method method : getHackedClass().getDeclaredMethods()) + if (method.getName().equals(name)) // Exact name match + info.append(' ').append(method); + if (info.length() == initial_length) + for (final Method method : getHackedClass().getDeclaredMethods()) + if (method.getName().startsWith(name)) // Name prefix match + info.append(' ').append(method); + if (info.length() == initial_length) + for (final Method method : getHackedClass().getDeclaredMethods()) + if (!method.getName().startsWith("-")) // Dump all but generated methods + info.append(' ').append(method); + } else + for (final Constructor constructor : getHackedClass().getDeclaredConstructors()) + info.append(' ').append(constructor); + } else if (cause instanceof NoSuchFieldException) { + info.append(" Potential candidates:"); + final int initial_length = info.length(); + final String name = getHackedFieldName(); + final Field[] fields = getHackedClass().getDeclaredFields(); + for (final Field field : fields) + if (field.getName().equals(name)) // Exact name match + info.append(' ').append(field); + if (info.length() == initial_length) for (final Field field : fields) + if (field.getName().startsWith(name)) // Name prefix match + info.append(' ').append(field); + if (info.length() == initial_length) for (final Field field : fields) + if (!field.getName().startsWith("$")) // Dump all but generated fields + info.append(' ').append(field); + } + return info.toString(); + } + + public Class getHackedClass() { + return mClass; + } + + AssertionException setHackedClass(final Class hacked_class) { + mClass = hacked_class; + return this; + } + + public Method getHackedMethod() { + return mHackedMethod; + } + + AssertionException setHackedMethod(final Method method) { + mHackedMethod = method; + return this; + } + + public String getHackedMethodName() { + return mHackedMethodName; + } + + AssertionException setHackedMethodName(final String method) { + mHackedMethodName = method; + return this; + } + + public Class[] getParamTypes() { + return mParamTypes; + } + + AssertionException setParamTypes(final Class[] param_types) { + mParamTypes = param_types; + return this; + } + + public Field getHackedField() { + return mHackedField; + } + + AssertionException setHackedField(final Field field) { + mHackedField = field; + return this; + } + + public String getHackedFieldName() { + return mHackedFieldName; + } + + AssertionException setHackedFieldName(final String field) { + mHackedFieldName = field; + return this; + } + } + + public static class FieldToHack { + + protected final Class mClass; + protected final String mName; + protected final int mModifiers; + + /** + * @param modifiers the modifiers this field must have + */ + protected FieldToHack(final Class clazz, final String name, final int modifiers) { + mClass = clazz; + mName = name; + mModifiers = modifiers; + } + + protected @Nullable + Field findField(final @Nullable Class type) { + if (mClass == ANY_TYPE) + return null; // AnyType as a internal indicator for class not found. + Field field = null; + try { + field = mClass.getDeclaredField(mName); + if (Modifier.isStatic(mModifiers) != Modifier.isStatic(field.getModifiers())) { + fail(new AssertionException(field + (Modifier.isStatic(mModifiers) ? " is not static" : " is static")).setHackedFieldName(mName)); + field = null; + } else if (mModifiers > 0 && (field.getModifiers() & mModifiers) != mModifiers) { + fail(new AssertionException(field + " does not match modifiers: " + mModifiers).setHackedFieldName(mName)); + field = null; + } else if (!field.isAccessible()) field.setAccessible(true); + } catch (final NoSuchFieldException e) { + final AssertionException hae = new AssertionException(e); + hae.setHackedClass(mClass); + hae.setHackedFieldName(mName); + fail(hae); + } + + if (type != null && field != null && !type.isAssignableFrom(field.getType())) + fail(new AssertionException(new ClassCastException(field + " is not of type " + type)).setHackedField(field)); + return field; + } + } + + public static class MemberFieldToHack extends FieldToHack { + + /** + * @param modifiers the modifiers this field must have + */ + private MemberFieldToHack(final Class clazz, final String name, final int modifiers) { + super(clazz, name, modifiers); + } + + /** + * Assert the field type. + */ + public @Nullable + HackedField ofType(final Class type) { + return ofType(type, false, null); + } + + public @Nullable + HackedField ofType(final String type_name) { + try { //noinspection unchecked + return ofType((Class) Class.forName(type_name, false, mClass.getClassLoader())); + } catch (final ClassNotFoundException e) { + fail(new AssertionException(e)); + return null; + } + } + + public @NonNull + HackedField fallbackTo(final byte value) { //noinspection ConstantConditions + return ofType(byte.class, true, value); + } + + public @NonNull + HackedField fallbackTo(final char value) { //noinspection ConstantConditions + return ofType(char.class, true, value); + } + + public @NonNull + HackedField fallbackTo(final short value) { //noinspection ConstantConditions + return ofType(short.class, true, value); + } + + public @NonNull + HackedField fallbackTo(final int value) { //noinspection ConstantConditions + return ofType(int.class, true, value); + } + + public @NonNull + HackedField fallbackTo(final long value) { //noinspection ConstantConditions + return ofType(long.class, true, value); + } + + public @NonNull + HackedField fallbackTo(final boolean value) { //noinspection ConstantConditions + return ofType(boolean.class, true, value); + } + + public @NonNull + HackedField fallbackTo(final float value) { //noinspection ConstantConditions + return ofType(float.class, true, value); + } + + public @NonNull + HackedField fallbackTo(final double value) { //noinspection ConstantConditions + return ofType(double.class, true, value); + } + + /** + * Fallback to the given value if this field is unavailable at runtime + */ + public @NonNull + HackedField fallbackTo(final T value) { + @SuppressWarnings("unchecked") final Class type = value == null ? null : (Class) value.getClass(); + //noinspection ConstantConditions + return ofType(type, true, value); + } + + private HackedField ofType(final Class type, final boolean fallback, final T fallback_value) { + final Field field = findField(type); + return field != null ? new HackedFieldImpl(field) : fallback ? new FallbackField(type, fallback_value) : null; + } + } + + public static class StaticFieldToHack extends FieldToHack { + + /** + * @param modifiers the modifiers this field must have + */ + private StaticFieldToHack(final Class clazz, final String name, final int modifiers) { + super(clazz, name, modifiers); + } + + /** + * Assert the field type. + */ + public @Nullable + HackedTargetField ofType(final Class type) { + return ofType(type, false, null); + } + + public @Nullable + HackedTargetField ofType(final String type_name) { + try { //noinspection unchecked + return ofType((Class) Class.forName(type_name, false, mClass.getClassLoader())); + } catch (final ClassNotFoundException e) { + fail(new AssertionException(e)); + return null; + } + } + + /** + * Fallback to the given value if this field is unavailable at runtime + */ + public @NonNull + HackedTargetField fallbackTo(final T value) { + @SuppressWarnings("unchecked") final Class type = value == null ? null : (Class) value.getClass(); + //noinspection ConstantConditions + return ofType(type, true, value); + } + + private HackedTargetField ofType(final Class type, final boolean fallback, final T fallback_value) { + final Field field = findField(type); + return field != null ? new HackedFieldImpl(field).onTarget(null) : fallback ? new FallbackField(type, fallback_value) : null; + } + } + + private static class HackedFieldImpl implements HackedField { + + private final @NonNull + Field mField; + + HackedFieldImpl(final @NonNull Field field) { + mField = field; + } + + @Override + public HackedTargetFieldImpl on(final C target) { + if (target == null) throw new IllegalArgumentException("target is null"); + return onTarget(target); + } + + private HackedTargetFieldImpl onTarget(final @Nullable C target) { + return new HackedTargetFieldImpl<>(mField, target); + } + + /** + * Get current value of this field + */ + @Override + public T get(final C instance) { + try { + @SuppressWarnings("unchecked") final T value = (T) mField.get(instance); + return value; + } catch (final IllegalAccessException e) { + return null; + } // Should never happen + } + + /** + * Set value of this field + * + *

    No type enforced here since most type mismatch can be easily tested and exposed early.

    + */ + @Override + public void set(final C instance, final T value) { + try { + mField.set(instance, value); + } catch (final IllegalAccessException ignored) { + } // Should never happen + } + + @Override + @SuppressWarnings("unchecked") + public @Nullable + Class getType() { + return (Class) mField.getType(); + } + + @Override + public boolean isAbsent() { + return false; + } + + public @Nullable + Field getField() { + return mField; + } + } + + private static class FallbackField implements HackedField, HackedTargetField { + + private final Class mType; + private final T mValue; + + private FallbackField(final Class type, final T value) { + mType = type; + mValue = value; + } + + @Override + public T get(final C instance) { + return mValue; + } + + @Override + public void set(final C instance, final T value) { + } + + @Override + public T get() { + return mValue; + } + + @Override + public void set(final T value) { + } + + @Override + public HackedTargetField on(final C target) { + return this; + } + + @Override + public Class getType() { + return mType; + } + + @Override + public boolean isAbsent() { + return true; + } + } + + public static class HackedTargetFieldImpl implements HackedTargetField { + + private final Field mField; + private final Object mInstance; // Instance type is already checked + private @Nullable + T mFallbackValue; + + HackedTargetFieldImpl(final Field field, final @Nullable Object instance) { + mField = field; + mInstance = instance; + } + + @Override + public T get() { + if (mField == null) return mFallbackValue; + try { + @SuppressWarnings("unchecked") final T value = (T) mField.get(mInstance); + return value; + } catch (final IllegalAccessException e) { + return null; + } // Should never happen + } + + @Override + public void set(final T value) { + if (mField != null) try { + mField.set(mInstance, value); + } catch (final IllegalAccessException ignored) { + } // Should never happen + } + + @Override + @SuppressWarnings("unchecked") + public @Nullable + Class getType() { + return (Class) mField.getType(); + } + + @Override + public boolean isAbsent() { + return mField == null; + } + } + + public static class CheckedHackedMethod { + + private final Invokable mInvokable; + + CheckedHackedMethod(final Invokable invokable) { + mInvokable = invokable; + } + + @SuppressWarnings("unchecked") + public Class getReturnType() { + return (Class) mInvokable.getReturnType(); + } + + protected HackInvocation invoke(final Object... args) { + return new HackInvocation<>(mInvokable, args); + } + + /** + * Whether this hack is absent, thus will be fallen-back when invoked + */ + public boolean isAbsent() { + return mInvokable instanceof FallbackInvokable; + } + } + + public static class HackedMethod0 extends CheckedHackedMethod { + HackedMethod0(final Invokable invokable) { + super(invokable); + } + + public @CheckResult + HackInvocation invoke() { + return super.invoke(); + } + } + + public static class HackedMethod1 extends CheckedHackedMethod { + HackedMethod1(final Invokable invokable) { + super(invokable); + } + + public @CheckResult + HackInvocation invoke(final A1 arg) { + return super.invoke(arg); + } + } + + public static class HackedMethod2 extends CheckedHackedMethod { + HackedMethod2(final Invokable invokable) { + super(invokable); + } + + public @CheckResult + HackInvocation invoke(final A1 arg1, final A2 arg2) { + return super.invoke(arg1, arg2); + } + } + + public static class HackedMethod3 extends CheckedHackedMethod { + HackedMethod3(final Invokable invokable) { + super(invokable); + } + + public @CheckResult + HackInvocation invoke(final A1 arg1, final A2 arg2, final A3 arg3) { + return super.invoke(arg1, arg2, arg3); + } + } + + public static class HackedMethod4 extends CheckedHackedMethod { + HackedMethod4(final Invokable invokable) { + super(invokable); + } + + public @CheckResult + HackInvocation invoke(final A1 arg1, final A2 arg2, final A3 arg3, final A4 arg4) { + return super.invoke(arg1, arg2, arg3, arg4); + } + } + + public static class HackedMethod5 extends CheckedHackedMethod { + HackedMethod5(final Invokable invokable) { + super(invokable); + } + + public @CheckResult + HackInvocation invoke(final A1 arg1, final A2 arg2, final A3 arg3, final A4 arg4, final A5 arg5) { + return super.invoke(arg1, arg2, arg3, arg4, arg5); + } + } + + public static class HackedMethodN extends CheckedHackedMethod { + HackedMethodN(final Invokable invokable) { + super(invokable); + } + + public @CheckResult + HackInvocation invoke(final Object... args) { + return super.invoke(args); + } + } + + public static class HackInvocation { + + private final Invokable invokable; + private final Object[] args; + + HackInvocation(final Invokable invokable, final Object... args) { + this.invokable = invokable; + this.args = args; + } + + public R on(final @NonNull C target) throws T1, T2, T3 { + return onTarget(target); + } + + public R statically() throws T1, T2, T3 { + return onTarget(null); + } + + private R onTarget(final C target) throws T1 { //noinspection TryWithIdenticalCatches + try { + @SuppressWarnings("unchecked") final R result = (R) invokable.invoke(target, args); + return result; + } catch (final IllegalAccessException e) { + throw new RuntimeException(e); // Should never happen + } catch (final InstantiationException e) { + throw new RuntimeException(e); + } catch (final InvocationTargetException e) { + final Throwable ex = e.getTargetException(); + //noinspection unchecked + throw (T1) ex; + } + } + } + + private static class HackedMethodImpl implements NonNullHackedMethod { + + private static final Comparator CLASS_COMPARATOR = new Comparator() { + @Override + public int compare(final Class lhs, final Class rhs) { + return lhs.toString().compareTo(rhs.toString()); + } + + @Override + public boolean equals(final Object object) { + return this == object; + } + }; + private final Class mClass; + private final @Nullable + String mName; // Null for constructor + private final int mModifiers; + private Class mReturnType; + private Class[] mThrowTypes; + private R mFallbackReturnValue; + private boolean mHasFallback; + + HackedMethodImpl(final Class clazz, @Nullable final String name, final int modifiers) { + //noinspection unchecked, to be compatible with HackedClass.staticMethod() + mClass = (Class) clazz; + mName = name; + mModifiers = modifiers; + } + + @Override + public HackedMethod returning(final Class type) { + mReturnType = type; + @SuppressWarnings("unchecked") final HackedMethod casted = (HackedMethod) this; + return casted; + } + + @Override + public NonNullHackedMethod fallbackReturning(final R value) { + mFallbackReturnValue = value; + mHasFallback = true; + return this; + } + + @Override + public NonNullHackedMethod throwing(final Class type) { + mThrowTypes = new Class[]{type}; + @SuppressWarnings("unchecked") final NonNullHackedMethod casted = (NonNullHackedMethod) this; + return casted; + } + + @Override + public NonNullHackedMethod + throwing(final Class type1, final Class type2) { + mThrowTypes = new Class[]{type1, type2}; + Arrays.sort(mThrowTypes, CLASS_COMPARATOR); + @SuppressWarnings("unchecked") final NonNullHackedMethod cast = (NonNullHackedMethod) this; + return cast; + } + + @Override + public NonNullHackedMethod + throwing(final Class type1, final Class type2, final Class type3) { + mThrowTypes = new Class[]{type1, type2, type3}; + Arrays.sort(mThrowTypes, CLASS_COMPARATOR); + @SuppressWarnings("unchecked") final NonNullHackedMethod cast = (NonNullHackedMethod) this; + return cast; + } + + @Override + public HackedMethod throwing(final Class... types) { + mThrowTypes = types; + Arrays.sort(mThrowTypes, CLASS_COMPARATOR); + @SuppressWarnings("unchecked") final HackedMethod cast = (HackedMethod) this; + return cast; + } + + @NonNull + @SuppressWarnings("ConstantConditions") + @Override + public HackedMethod0 withoutParams() { + final Invokable invokable = findInvokable(); + return invokable == null ? null : new HackedMethod0(invokable); + } + + @NonNull + @SuppressWarnings("ConstantConditions") + @Override + public HackedMethod1 withParam(final Class type) { + final Invokable invokable = findInvokable(type); + return invokable == null ? null : new HackedMethod1(invokable); + } + + @NonNull + @SuppressWarnings("ConstantConditions") + @Override + public HackedMethod2 withParams(final Class type1, final Class type2) { + final Invokable invokable = findInvokable(type1, type2); + return invokable == null ? null : new HackedMethod2(invokable); + } + + @NonNull + @SuppressWarnings("ConstantConditions") + @Override + public HackedMethod3 withParams(final Class type1, final Class type2, final Class type3) { + final Invokable invokable = findInvokable(type1, type2, type3); + return invokable == null ? null : new HackedMethod3(invokable); + } + + @NonNull + @SuppressWarnings("ConstantConditions") + @Override + public HackedMethod4 withParams(final Class type1, final Class type2, final Class type3, final Class type4) { + final Invokable invokable = findInvokable(type1, type2, type3, type4); + return invokable == null ? null : new HackedMethod4(invokable); + } + + @NonNull + @SuppressWarnings("ConstantConditions") + @Override + public HackedMethod5 withParams(final Class type1, final Class type2, final Class type3, final Class type4, final Class type5) { + final Invokable invokable = findInvokable(type1, type2, type3, type4, type5); + return invokable == null ? null : new HackedMethod5(invokable); + } + + @NonNull + @SuppressWarnings("ConstantConditions") + @Override + public HackedMethodN withParams(final Class... types) { + final Invokable invokable = findInvokable(types); + return invokable == null ? null : new HackedMethodN(invokable); + } + + private @Nullable + Invokable findInvokable(final Class... param_types) { + if (mClass == ANY_TYPE) // AnyType as a internal indicator for class not found. + return mHasFallback ? new FallbackInvokable(mFallbackReturnValue) : null; + + final int modifiers; + Invokable invokable; + final AccessibleObject accessible; + final Class[] ex_types; + try { + if (mName != null) { + final Method candidate = mClass.getDeclaredMethod(mName, param_types); + Method method = candidate; + ex_types = candidate.getExceptionTypes(); + modifiers = method.getModifiers(); + if (Modifier.isStatic(mModifiers) != Modifier.isStatic(candidate.getModifiers())) { + fail(new AssertionException(candidate + (Modifier.isStatic(mModifiers) ? " is not static" : "is static")).setHackedMethod(method)); + method = null; + } + if (mReturnType != null && mReturnType != ANY_TYPE && !candidate.getReturnType().equals(mReturnType)) { + fail(new AssertionException("Return type mismatch: " + candidate)); + method = null; + } + if (method != null) { + invokable = new InvokableMethod<>(method); + accessible = method; + } else { + invokable = null; + accessible = null; + } + } else { + final Constructor ctor = mClass.getDeclaredConstructor(param_types); + modifiers = ctor.getModifiers(); + invokable = new InvokableConstructor<>(ctor); + accessible = ctor; + ex_types = ctor.getExceptionTypes(); + } + } catch (final NoSuchMethodException e) { + fail(new AssertionException(e).setHackedClass(mClass).setHackedMethodName(mName).setParamTypes(param_types)); + return mHasFallback ? new FallbackInvokable(mFallbackReturnValue) : null; + } + + if (mModifiers > 0 && (modifiers & mModifiers) != mModifiers) + fail(new AssertionException(invokable + " does not match modifiers: " + mModifiers).setHackedMethodName(mName)); + + if (mThrowTypes == null && ex_types.length > 0 || mThrowTypes != null && ex_types.length == 0) { + fail(new AssertionException("Checked exception(s) not match: " + invokable)); + if (ex_types.length > 0) + invokable = null; // No need to fall-back if expected checked exceptions are absent. + } else if (mThrowTypes != null) { + Arrays.sort(ex_types, CLASS_COMPARATOR); + if (!Arrays.equals(ex_types, mThrowTypes)) { // TODO: Check derived relation of exceptions + fail(new AssertionException("Checked exception(s) not match: " + invokable)); + invokable = null; + } + } + + if (invokable == null) { + if (!mHasFallback) return null; + return new FallbackInvokable<>(mFallbackReturnValue); + } + + if (!accessible.isAccessible()) accessible.setAccessible(true); + return invokable; + } + } + + private static class InvokableMethod implements Invokable { + + private final Method method; + + InvokableMethod(final Method method) { + this.method = method; + } + + public Object invoke(final C target, final Object[] args) throws IllegalAccessException, + IllegalArgumentException, InvocationTargetException { + return method.invoke(target, args); + } + + @Override + public Class getReturnType() { + return method.getReturnType(); + } + + @Override + public String toString() { + return method.toString(); + } + } + + private static class InvokableConstructor implements Invokable { + + private final Constructor constructor; + + InvokableConstructor(final Constructor method) { + this.constructor = method; + } + + public Object invoke(final C target, final Object[] args) throws InstantiationException, + IllegalAccessException, IllegalArgumentException, InvocationTargetException { + return constructor.newInstance(args); + } + + @Override + public Class getReturnType() { + return constructor.getDeclaringClass(); + } + + @Override + public String toString() { + return constructor.toString(); + } + } + + private static class FallbackInvokable implements Invokable { + + private final @Nullable + Object mValue; + + FallbackInvokable(final @Nullable Object value) { + mValue = value; + } + + @Override + public Object invoke(final C target, final Object[] args) throws InvocationTargetException, IllegalAccessException, InstantiationException { + return mValue; + } + + @Override + public Class getReturnType() { + return mValue == null ? Object.class : mValue.getClass(); + } + } + + public static class HackedClass { + + private final Class mClass; + + HackedClass(final Class clazz) { + mClass = clazz; + } + + public @CheckResult + MemberFieldToHack field(final @NonNull String name) { + return new MemberFieldToHack<>(mClass, name, 0); + } + + public @CheckResult + StaticFieldToHack staticField(final @NonNull String name) { + return new StaticFieldToHack<>(mClass, name, Modifier.STATIC); + } + + public @CheckResult + NonNullHackedMethod method(final String name) { + return new HackedMethodImpl<>(mClass, name, 0); + } + + public @CheckResult + NonNullHackedMethod staticMethod(final String name) { + return new HackedMethodImpl<>(mClass, name, Modifier.STATIC); + } + + public @CheckResult + NonNullHackedInvokable constructor() { + final HackedMethodImpl constructor = new HackedMethodImpl<>(mClass, null, 0); + constructor.fallbackReturning(null); // Always fallback to null. + return constructor; + } + } + + /** + * This is a simple demo for the common usage of {@link Hack} + */ + @SuppressWarnings("unused") + private static class Demo { + + static String sField; + boolean mField; + + Demo(final int flags) { + } + + static void demo() { + final Demo demo = Hacks.Demo_ctor.invoke(0).statically(); + try { + Hacks.Demo_methodThrows.invoke().on(demo); + } catch (final InterruptedException | IOException e) { // The checked exceptions declared by throwing() in hack definition. + e.printStackTrace(); + } + Hacks.Demo_staticMethod.invoke(1, "xx").statically(); + } + + static boolean staticMethod(final int a, final String c) { + return false; + } + + private void methodThrows() throws InterruptedException, IOException { + } + + @SuppressWarnings({"FieldCanBeLocal", "UnnecessarilyQualifiedStaticUsage"}) + static class Hacks { + + static HackedMethod1 Demo_ctor; + static HackedMethod0 Demo_methodThrows; + static HackedMethod2 Demo_staticMethod; + static @Nullable + HackedField Demo_mField; // Optional hack may be null if assertion failed + static @Nullable + HackedTargetField Demo_sField; + + static { + Hack.setAssertionFailureHandler(new AssertionFailureHandler() { + @Override + public void onAssertionFailure(final AssertionException failure) { + Log.w("Demo", "Partially incompatible: " + failure.getDebugInfo()); + // Report the incompatibility silently. + //... + } + }); + Demo_ctor = Hack.into(Demo.class).constructor().withParam(int.class); + // Method without fallback (will be null if absent) + Demo_methodThrows = Hack.into(Demo.class).method("methodThrows").returning(Void.class).fallbackReturning(null) + .throwing(InterruptedException.class, IOException.class).withoutParams(); + // Method with fallback (will never be null) + Demo_staticMethod = Hack.into(Demo.class).staticMethod("methodWith2Params").returning(boolean.class) + .fallbackReturning(false).withParams(int.class, String.class); + Demo_mField = Hack.into(Demo.class).field("mField").fallbackTo(false); + Demo_sField = Hack.into(Demo.class).staticField("sField").ofType(String.class); + } + } + } + + /** + * Placeholder for unchecked exception + */ + public class Unchecked extends RuntimeException { + } +} diff --git a/app/src/main/java/org/meowcat/edxposed/manager/util/light/Light.java b/app/src/main/java/org/meowcat/edxposed/manager/util/light/Light.java new file mode 100644 index 00000000..40bb0f88 --- /dev/null +++ b/app/src/main/java/org/meowcat/edxposed/manager/util/light/Light.java @@ -0,0 +1,47 @@ +package org.meowcat.edxposed.manager.util.light; + +import android.annotation.SuppressLint; +import android.graphics.HardwareRenderer; +import android.os.Build; +import android.view.View; + + +@SuppressWarnings({"unchecked", "ConstantConditions"}) +@SuppressLint("PrivateApi") +public class Light { + + public static boolean setLightSourceAlpha(View view, float ambientShadowAlpha, float spotShadowAlpha) { + try { + @SuppressWarnings("rawtypes") Class threadedRendererClass = Class.forName("android.view.ThreadedRenderer"); + + Object threadedRenderer = Hack.into(View.class) + .method("getThreadedRenderer") + .returning(threadedRendererClass) + .withoutParams() + .invoke() + .on(view); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ((HardwareRenderer) threadedRenderer).setLightSourceAlpha(ambientShadowAlpha, spotShadowAlpha); + } else { + long mNativeProxy = (long) Hack.into(threadedRendererClass) + .field("mNativeProxy").ofType(long.class).get(threadedRenderer); + + float mLightRadius = (float) Hack.into(threadedRendererClass) + .field("mLightRadius") + .ofType(float.class) + .get(threadedRenderer); + + Hack.into(threadedRendererClass) + .staticMethod("nSetup") + .withParams(long.class, float.class, int.class, int.class) + .invoke(mNativeProxy, mLightRadius, + (int) (255 * ambientShadowAlpha + 0.5f), (int) (255 * spotShadowAlpha + 0.5f)) + .statically(); + } + return true; + } catch (Throwable tr) { + tr.printStackTrace(); + return false; + } + } +} diff --git a/app/src/main/res/drawable/ic_android.xml b/app/src/main/res/drawable/ic_android.xml new file mode 100644 index 00000000..575b4406 --- /dev/null +++ b/app/src/main/res/drawable/ic_android.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_apps.xml b/app/src/main/res/drawable/ic_apps.xml new file mode 100644 index 00000000..2d903a92 --- /dev/null +++ b/app/src/main/res/drawable/ic_apps.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_assignment.xml b/app/src/main/res/drawable/ic_assignment.xml new file mode 100644 index 00000000..6fd06e9d --- /dev/null +++ b/app/src/main/res/drawable/ic_assignment.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_bug.xml b/app/src/main/res/drawable/ic_bug.xml new file mode 100644 index 00000000..e68f558e --- /dev/null +++ b/app/src/main/res/drawable/ic_bug.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_check_circle.xml b/app/src/main/res/drawable/ic_check_circle.xml new file mode 100644 index 00000000..7d778e52 --- /dev/null +++ b/app/src/main/res/drawable/ic_check_circle.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_chip.xml b/app/src/main/res/drawable/ic_chip.xml new file mode 100644 index 00000000..67847c5f --- /dev/null +++ b/app/src/main/res/drawable/ic_chip.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_description.xml b/app/src/main/res/drawable/ic_description.xml new file mode 100644 index 00000000..1be0240e --- /dev/null +++ b/app/src/main/res/drawable/ic_description.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_donate.xml b/app/src/main/res/drawable/ic_donate.xml new file mode 100644 index 00000000..4cc67358 --- /dev/null +++ b/app/src/main/res/drawable/ic_donate.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_error.xml b/app/src/main/res/drawable/ic_error.xml new file mode 100644 index 00000000..cce259fa --- /dev/null +++ b/app/src/main/res/drawable/ic_error.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_framework.xml b/app/src/main/res/drawable/ic_framework.xml new file mode 100644 index 00000000..76deadaa --- /dev/null +++ b/app/src/main/res/drawable/ic_framework.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/ic_get_app.xml b/app/src/main/res/drawable/ic_get_app.xml new file mode 100644 index 00000000..09cd9664 --- /dev/null +++ b/app/src/main/res/drawable/ic_get_app.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_github.xml b/app/src/main/res/drawable/ic_github.xml new file mode 100644 index 00000000..47f21458 --- /dev/null +++ b/app/src/main/res/drawable/ic_github.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_help.xml b/app/src/main/res/drawable/ic_help.xml new file mode 100644 index 00000000..a3f80358 --- /dev/null +++ b/app/src/main/res/drawable/ic_help.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_history.xml b/app/src/main/res/drawable/ic_history.xml new file mode 100644 index 00000000..af1f7d15 --- /dev/null +++ b/app/src/main/res/drawable/ic_history.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_info.xml b/app/src/main/res/drawable/ic_info.xml new file mode 100644 index 00000000..1e1faf7e --- /dev/null +++ b/app/src/main/res/drawable/ic_info.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_language.xml b/app/src/main/res/drawable/ic_language.xml new file mode 100644 index 00000000..ea4b0692 --- /dev/null +++ b/app/src/main/res/drawable/ic_language.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 00000000..d9c7f636 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_manager.xml b/app/src/main/res/drawable/ic_manager.xml new file mode 100644 index 00000000..92356905 --- /dev/null +++ b/app/src/main/res/drawable/ic_manager.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_modules.xml b/app/src/main/res/drawable/ic_modules.xml new file mode 100644 index 00000000..5b9d32ac --- /dev/null +++ b/app/src/main/res/drawable/ic_modules.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_more.xml b/app/src/main/res/drawable/ic_more.xml new file mode 100644 index 00000000..14ff51ef --- /dev/null +++ b/app/src/main/res/drawable/ic_more.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_notification.xml b/app/src/main/res/drawable/ic_notification.xml new file mode 100644 index 00000000..bcd9d769 --- /dev/null +++ b/app/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_person.xml b/app/src/main/res/drawable/ic_person.xml new file mode 100644 index 00000000..4604d045 --- /dev/null +++ b/app/src/main/res/drawable/ic_person.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_phone.xml b/app/src/main/res/drawable/ic_phone.xml new file mode 100644 index 00000000..1cd53ff1 --- /dev/null +++ b/app/src/main/res/drawable/ic_phone.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_refresh.xml b/app/src/main/res/drawable/ic_refresh.xml new file mode 100644 index 00000000..0d768f68 --- /dev/null +++ b/app/src/main/res/drawable/ic_refresh.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_save.xml b/app/src/main/res/drawable/ic_save.xml new file mode 100644 index 00000000..6f9e3d35 --- /dev/null +++ b/app/src/main/res/drawable/ic_save.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_send.xml b/app/src/main/res/drawable/ic_send.xml new file mode 100644 index 00000000..0e8967ef --- /dev/null +++ b/app/src/main/res/drawable/ic_send.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml new file mode 100644 index 00000000..61d75531 --- /dev/null +++ b/app/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_share.xml b/app/src/main/res/drawable/ic_share.xml new file mode 100644 index 00000000..4bf68f28 --- /dev/null +++ b/app/src/main/res/drawable/ic_share.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_sort.xml b/app/src/main/res/drawable/ic_sort.xml new file mode 100644 index 00000000..707e960d --- /dev/null +++ b/app/src/main/res/drawable/ic_sort.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_update.xml b/app/src/main/res/drawable/ic_update.xml new file mode 100644 index 00000000..5abebd74 --- /dev/null +++ b/app/src/main/res/drawable/ic_update.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_verified.xml b/app/src/main/res/drawable/ic_verified.xml new file mode 100644 index 00000000..97b7632e --- /dev/null +++ b/app/src/main/res/drawable/ic_verified.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_warning.xml b/app/src/main/res/drawable/ic_warning.xml new file mode 100644 index 00000000..2eccab98 --- /dev/null +++ b/app/src/main/res/drawable/ic_warning.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/item_background_md2.xml b/app/src/main/res/drawable/item_background_md2.xml new file mode 100644 index 00000000..fc19ef5b --- /dev/null +++ b/app/src/main/res/drawable/item_background_md2.xml @@ -0,0 +1,17 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/item_background_round.xml b/app/src/main/res/drawable/item_background_round.xml new file mode 100644 index 00000000..70f4df8c --- /dev/null +++ b/app/src/main/res/drawable/item_background_round.xml @@ -0,0 +1,17 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/item_background_round_nopadding.xml b/app/src/main/res/drawable/item_background_round_nopadding.xml new file mode 100644 index 00000000..d52cfb1d --- /dev/null +++ b/app/src/main/res/drawable/item_background_round_nopadding.xml @@ -0,0 +1,12 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/outline_list_24.xml b/app/src/main/res/drawable/outline_list_24.xml new file mode 100644 index 00000000..a862eb68 --- /dev/null +++ b/app/src/main/res/drawable/outline_list_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/shortcut_ic_downloads.xml b/app/src/main/res/drawable/shortcut_ic_downloads.xml new file mode 100644 index 00000000..9d5bf9de --- /dev/null +++ b/app/src/main/res/drawable/shortcut_ic_downloads.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app/src/main/res/drawable/shortcut_ic_modules.xml b/app/src/main/res/drawable/shortcut_ic_modules.xml new file mode 100644 index 00000000..da6a8324 --- /dev/null +++ b/app/src/main/res/drawable/shortcut_ic_modules.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/font/exo.xml b/app/src/main/res/font/exo.xml new file mode 100644 index 00000000..e58a4ba9 --- /dev/null +++ b/app/src/main/res/font/exo.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/font/exo_bold.ttf b/app/src/main/res/font/exo_bold.ttf new file mode 100644 index 00000000..8ea37706 Binary files /dev/null and b/app/src/main/res/font/exo_bold.ttf differ diff --git a/app/src/main/res/font/exo_bold_italic.ttf b/app/src/main/res/font/exo_bold_italic.ttf new file mode 100644 index 00000000..7ec56c94 Binary files /dev/null and b/app/src/main/res/font/exo_bold_italic.ttf differ diff --git a/app/src/main/res/font/exo_regular.ttf b/app/src/main/res/font/exo_regular.ttf new file mode 100644 index 00000000..cf681a35 Binary files /dev/null and b/app/src/main/res/font/exo_regular.ttf differ diff --git a/app/src/main/res/font/exo_regular_italic.ttf b/app/src/main/res/font/exo_regular_italic.ttf new file mode 100644 index 00000000..5673caba Binary files /dev/null and b/app/src/main/res/font/exo_regular_italic.ttf differ diff --git a/app/src/main/res/layout/activity_about.xml b/app/src/main/res/layout/activity_about.xml new file mode 100644 index 00000000..d8aa315b --- /dev/null +++ b/app/src/main/res/layout/activity_about.xml @@ -0,0 +1,762 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_black_list.xml b/app/src/main/res/layout/activity_black_list.xml new file mode 100644 index 00000000..cc64fafe --- /dev/null +++ b/app/src/main/res/layout/activity_black_list.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_crash_report.xml b/app/src/main/res/layout/activity_crash_report.xml new file mode 100644 index 00000000..268a755c --- /dev/null +++ b/app/src/main/res/layout/activity_crash_report.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_download.xml b/app/src/main/res/layout/activity_download.xml new file mode 100644 index 00000000..a459edee --- /dev/null +++ b/app/src/main/res/layout/activity_download.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_download_details.xml b/app/src/main/res/layout/activity_download_details.xml new file mode 100644 index 00000000..dbda5103 --- /dev/null +++ b/app/src/main/res/layout/activity_download_details.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_download_details_not_found.xml b/app/src/main/res/layout/activity_download_details_not_found.xml new file mode 100644 index 00000000..7c24a8e5 --- /dev/null +++ b/app/src/main/res/layout/activity_download_details_not_found.xml @@ -0,0 +1,30 @@ + + + + + + + +