conversion or something
idk, im not religious Signed-off-by: androidacy-user <opensource@androidacy.com>pull/89/head
parent
19174c3b81
commit
7dc626328f
@ -1,482 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2023 to present Androidacy and contributors. Names, logos, icons, and the Androidacy name are all trademarks of Androidacy and may not be used without license. See LICENSE for more information.
|
||||
*/
|
||||
|
||||
package com.fox2code.mmm.androidacy;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.net.Uri;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.fingerprintjs.android.fingerprint.Fingerprinter;
|
||||
import com.fingerprintjs.android.fingerprint.FingerprinterFactory;
|
||||
import com.fox2code.mmm.BuildConfig;
|
||||
import com.fox2code.mmm.MainApplication;
|
||||
import com.fox2code.mmm.R;
|
||||
import com.fox2code.mmm.manager.ModuleInfo;
|
||||
import com.fox2code.mmm.repo.RepoData;
|
||||
import com.fox2code.mmm.repo.RepoManager;
|
||||
import com.fox2code.mmm.repo.RepoModule;
|
||||
import com.fox2code.mmm.utils.io.PropUtils;
|
||||
import com.fox2code.mmm.utils.io.net.Http;
|
||||
import com.fox2code.mmm.utils.io.net.HttpException;
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
import okhttp3.HttpUrl;
|
||||
import timber.log.Timber;
|
||||
|
||||
@SuppressWarnings("KotlinInternalInJava")
|
||||
public final class AndroidacyRepoData extends RepoData {
|
||||
public static String ANDROIDACY_DEVICE_ID = null;
|
||||
public static String token = Objects.requireNonNull(MainApplication.getSharedPreferences("androidacy")).getString("pref_androidacy_api_token", null);
|
||||
|
||||
static {
|
||||
HttpUrl.Builder OK_HTTP_URL_BUILDER = new HttpUrl.Builder().scheme("https");
|
||||
// Using HttpUrl.Builder.host(String) crash the app
|
||||
OK_HTTP_URL_BUILDER.setHost$okhttp(".androidacy.com");
|
||||
OK_HTTP_URL_BUILDER.build();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public final String ClientID = BuildConfig.ANDROIDACY_CLIENT_ID;
|
||||
private final boolean testMode;
|
||||
private final String host;
|
||||
public String[][] userInfo = new String[][]{{"role", null}, {"permissions", null}};
|
||||
public String memberLevel;
|
||||
// Avoid spamming requests to Androidacy
|
||||
private long androidacyBlockade = 0;
|
||||
|
||||
public AndroidacyRepoData(File cacheRoot, boolean testMode) {
|
||||
super(testMode ? RepoManager.ANDROIDACY_TEST_MAGISK_REPO_ENDPOINT : RepoManager.ANDROIDACY_MAGISK_REPO_ENDPOINT, cacheRoot);
|
||||
this.defaultName = "Androidacy Modules Repo";
|
||||
this.defaultWebsite = RepoManager.ANDROIDACY_MAGISK_REPO_HOMEPAGE;
|
||||
this.defaultSupport = "https://t.me/androidacy_discussions";
|
||||
this.defaultDonate = "https://www.androidacy.com/membership-account/membership-checkout/?level=2&discount_code=FOX2CODE&utm_souce=foxmmm&utm_medium=android-app&utm_campaign=fox-upgrade-promo";
|
||||
this.defaultSubmitModule = "https://www.androidacy.com/module-repository-applications/";
|
||||
this.host = testMode ? "staging-api.androidacy.com" : "production-api.androidacy.com";
|
||||
this.testMode = testMode;
|
||||
}
|
||||
|
||||
public static AndroidacyRepoData getInstance() {
|
||||
return RepoManager.getINSTANCE().getAndroidacyRepoData();
|
||||
}
|
||||
|
||||
private static String filterURL(String url) {
|
||||
if (url == null || url.isEmpty() || PropUtils.isInvalidURL(url)) {
|
||||
return null;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
// Generates a unique device ID. This is used to identify the device in the API for rate
|
||||
// limiting and fraud detection.
|
||||
public static String generateDeviceId() {
|
||||
// first, check if ANDROIDACY_DEVICE_ID is already set
|
||||
if (ANDROIDACY_DEVICE_ID != null) {
|
||||
return ANDROIDACY_DEVICE_ID;
|
||||
}
|
||||
// Try to get the device ID from the shared preferences
|
||||
SharedPreferences sharedPreferences = MainApplication.getSharedPreferences("androidacy");
|
||||
String deviceIdPref = Objects.requireNonNull(sharedPreferences).getString("device_id_v2", null);
|
||||
if (deviceIdPref != null) {
|
||||
ANDROIDACY_DEVICE_ID = deviceIdPref;
|
||||
return deviceIdPref;
|
||||
} else {
|
||||
Fingerprinter fp = FingerprinterFactory.create(Objects.requireNonNull(MainApplication.getINSTANCE()).getApplicationContext());
|
||||
fp.getFingerprint(Fingerprinter.Version.V_5, fingerprint -> {
|
||||
ANDROIDACY_DEVICE_ID = fingerprint;
|
||||
// use fingerprint
|
||||
// Save the device ID to the shared preferences
|
||||
SharedPreferences.Editor editor = sharedPreferences.edit();
|
||||
editor.putString("device_id_v2", ANDROIDACY_DEVICE_ID);
|
||||
editor.apply();
|
||||
return null;
|
||||
});
|
||||
// wait for up to 5 seconds for the fingerprint to be generated (ANDROIDACY_DEVICE_ID to be set)
|
||||
long startTime = System.currentTimeMillis();
|
||||
while (ANDROIDACY_DEVICE_ID == null && System.currentTimeMillis() - startTime < 5000) {
|
||||
try {
|
||||
//noinspection BusyWait
|
||||
Thread.sleep(100);
|
||||
} catch (InterruptedException ignored) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
if (ANDROIDACY_DEVICE_ID == null) {
|
||||
// fingerprint generation failed, use a random UUID
|
||||
ANDROIDACY_DEVICE_ID = UUID.randomUUID().toString();
|
||||
}
|
||||
return ANDROIDACY_DEVICE_ID;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isValidToken(String token) throws IOException {
|
||||
String deviceId = generateDeviceId();
|
||||
try {
|
||||
byte[] resp = Http.doHttpGet("https://" + this.host + "/auth/me?token=" + token + "&device_id=" + deviceId + "&client_id=" + BuildConfig.ANDROIDACY_CLIENT_ID, false);
|
||||
// response is JSON
|
||||
JSONObject jsonObject = new JSONObject(new String(resp));
|
||||
memberLevel = jsonObject.getString("role");
|
||||
Timber.d("Member level: %s", memberLevel);
|
||||
JSONArray memberPermissions = jsonObject.getJSONArray("permissions");
|
||||
// set role and permissions on userInfo property
|
||||
userInfo = new String[][]{{"role", memberLevel}, {"permissions", String.valueOf(memberPermissions)}};
|
||||
return true;
|
||||
} catch (HttpException e) {
|
||||
if (e.getErrorCode() == 401) {
|
||||
Timber.w("Invalid token, resetting...");
|
||||
// Remove saved preference
|
||||
SharedPreferences.Editor editor = Objects.requireNonNull(MainApplication.getSharedPreferences("androidacy")).edit();
|
||||
editor.remove("pref_androidacy_api_token");
|
||||
editor.apply();
|
||||
return false;
|
||||
}
|
||||
throw e;
|
||||
} catch (JSONException e) {
|
||||
// response is not JSON
|
||||
Timber.w("Invalid token, resetting...");
|
||||
Timber.w(e);
|
||||
// Remove saved preference
|
||||
SharedPreferences.Editor editor = Objects.requireNonNull(MainApplication.getSharedPreferences("androidacy")).edit();
|
||||
editor.remove("pref_androidacy_api_token");
|
||||
editor.apply();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a new token from the server and save it to the shared preferences
|
||||
* @return String token
|
||||
*/
|
||||
public String requestNewToken() throws IOException, JSONException {
|
||||
String deviceId = generateDeviceId();
|
||||
byte[] resp = Http.doHttpGet("https://" + this.host + "/auth/register?device_id=" + deviceId + "&client_id=" + BuildConfig.ANDROIDACY_CLIENT_ID, false);
|
||||
// response is JSON
|
||||
JSONObject jsonObject = new JSONObject(new String(resp));
|
||||
String token = jsonObject.getString("token");
|
||||
// Save the token to the shared preferences
|
||||
SharedPreferences.Editor editor = Objects.requireNonNull(MainApplication.getSharedPreferences("androidacy")).edit();
|
||||
editor.putString("pref_androidacy_api_token", token);
|
||||
editor.apply();
|
||||
return token;
|
||||
}
|
||||
|
||||
@SuppressLint({"RestrictedApi", "BinaryOperationInTimber"})
|
||||
@Override
|
||||
protected boolean prepare() {
|
||||
// If ANDROIDACY_CLIENT_ID is not set or is empty, disable this repo and return
|
||||
if (Objects.equals(BuildConfig.ANDROIDACY_CLIENT_ID, "")) {
|
||||
SharedPreferences.Editor editor = Objects.requireNonNull(MainApplication.getSharedPreferences("mmm")).edit();
|
||||
editor.putBoolean("pref_androidacy_repo_enabled", false);
|
||||
editor.apply();
|
||||
Timber.w("ANDROIDACY_CLIENT_ID is empty, disabling AndroidacyRepoData 2");
|
||||
return false;
|
||||
}
|
||||
if (Http.needCaptchaAndroidacy()) return false;
|
||||
// Implementation details discussed on telegram
|
||||
// First, ping the server to check if it's alive
|
||||
try {
|
||||
HttpURLConnection connection = (HttpURLConnection) new URL("https://" + this.host + "/ping").openConnection();
|
||||
connection.setRequestMethod("GET");
|
||||
connection.setReadTimeout(5000);
|
||||
connection.connect();
|
||||
if (connection.getResponseCode() != 200 && connection.getResponseCode() != 204) {
|
||||
// If it's a 400, the app is probably outdated. Show a snackbar suggesting user update app and webview
|
||||
if (connection.getResponseCode() == 400) {
|
||||
// Show a dialog using androidacy_update_needed string
|
||||
new MaterialAlertDialogBuilder(Objects.requireNonNull(MainApplication.getINSTANCE())).setTitle(R.string.androidacy_update_needed).setMessage(R.string.androidacy_update_needed_message).setPositiveButton(R.string.update, (dialog, which) -> {
|
||||
// Open the app's page on the Play Store
|
||||
Intent intent = new Intent(Intent.ACTION_VIEW);
|
||||
intent.setData(Uri.parse("https://www.androidacy.com/downloads/?view=FoxMMM&utm_source=foxmnm&utm_medium=app&utm_campaign=android-app"));
|
||||
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
MainApplication.getINSTANCE().startActivity(intent);
|
||||
}).setNegativeButton(R.string.cancel, null).show();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Timber.e(e, "Failed to ping server");
|
||||
return false;
|
||||
}
|
||||
long time = System.currentTimeMillis();
|
||||
if (this.androidacyBlockade > time) return true; // fake it till you make it. Basically,
|
||||
// don't fail just because we're rate limited. API and web rate limits are different.
|
||||
this.androidacyBlockade = time + 30_000L;
|
||||
try {
|
||||
if (token == null) {
|
||||
token = Objects.requireNonNull(MainApplication.getSharedPreferences("androidacy")).getString("pref_androidacy_api_token", null);
|
||||
if (token != null && !this.isValidToken(token)) {
|
||||
Timber.i("Token expired or invalid, requesting new one...");
|
||||
token = null;
|
||||
} else {
|
||||
Timber.i("Using cached token");
|
||||
}
|
||||
} else if (!this.isValidToken(token)) {
|
||||
Timber.i("Token expired, requesting new one...");
|
||||
token = null;
|
||||
} else {
|
||||
Timber.i("Using validated cached token");
|
||||
}
|
||||
} catch (IOException e) {
|
||||
if (HttpException.shouldTimeout(e)) {
|
||||
Timber.e(e, "We are being rate limited!");
|
||||
this.androidacyBlockade = time + 3_600_000L;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (token == null) {
|
||||
Timber.i("Token is null, requesting new one...");
|
||||
try {
|
||||
Timber.i("Requesting new token...");
|
||||
// POST json request to https://production-api.androidacy.com/auth/register
|
||||
token = requestNewToken();
|
||||
// Parse token
|
||||
try {
|
||||
JSONObject jsonObject = new JSONObject(token);
|
||||
// log last four of token, replacing the rest with asterisks
|
||||
token = jsonObject.getString("token");
|
||||
//noinspection SuspiciousRegexArgument
|
||||
Timber.d("Token: %s", token.substring(0, token.length() - 4).replaceAll(".", "*") + token.substring(token.length() - 4));
|
||||
memberLevel = jsonObject.getString("role");
|
||||
Timber.d("Member level: %s", memberLevel);
|
||||
} catch (JSONException e) {
|
||||
Timber.e(e, "Failed to parse token: %s", token);
|
||||
// Show a toast
|
||||
Looper mainLooper = Looper.getMainLooper();
|
||||
Handler handler = new Handler(mainLooper);
|
||||
handler.post(() -> Toast.makeText(MainApplication.getINSTANCE(), R.string.androidacy_failed_to_parse_token, Toast.LENGTH_LONG).show());
|
||||
return false;
|
||||
}
|
||||
// Ensure token is valid
|
||||
if (!isValidToken(token)) {
|
||||
Timber.e("Failed to validate token");
|
||||
// Show a toast
|
||||
Looper mainLooper = Looper.getMainLooper();
|
||||
Handler handler = new Handler(mainLooper);
|
||||
handler.post(() -> Toast.makeText(MainApplication.getINSTANCE(), R.string.androidacy_failed_to_validate_token, Toast.LENGTH_LONG).show());
|
||||
return false;
|
||||
} else {
|
||||
// Save token to shared preference
|
||||
SharedPreferences.Editor editor = Objects.requireNonNull(MainApplication.getSharedPreferences("androidacy")).edit();
|
||||
editor.putString("pref_androidacy_api_token", token);
|
||||
editor.apply();
|
||||
Timber.i("Token saved to shared preference");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
if (HttpException.shouldTimeout(e)) {
|
||||
Timber.e(e, "We are being rate limited!");
|
||||
this.androidacyBlockade = time + 3_600_000L;
|
||||
}
|
||||
Timber.e(e, "Failed to get a new token");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected List<RepoModule> populate(JSONObject jsonObject) throws JSONException {
|
||||
Timber.d("AndroidacyRepoData populate start");
|
||||
String name = jsonObject.optString("name", "Androidacy Modules Repo");
|
||||
String nameForModules = name.endsWith(" (Official)") ? name.substring(0, name.length() - 11) : name;
|
||||
JSONArray jsonArray;
|
||||
try {
|
||||
jsonArray = jsonObject.getJSONArray("data");
|
||||
} catch (JSONException e) {
|
||||
// probably using modules key since it's cached
|
||||
try {
|
||||
jsonArray = jsonObject.getJSONArray("modules");
|
||||
} catch (JSONException e2) {
|
||||
// we should never get here, bail out
|
||||
Timber.e(e2, "Failed to parse modules");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
for (RepoModule repoModule : this.moduleHashMap.values()) {
|
||||
repoModule.processed = false;
|
||||
}
|
||||
ArrayList<RepoModule> newModules = new ArrayList<>();
|
||||
int len = jsonArray.length();
|
||||
long lastLastUpdate = 0;
|
||||
for (int i = 0; i < len; i++) {
|
||||
jsonObject = jsonArray.getJSONObject(i);
|
||||
String moduleId;
|
||||
try {
|
||||
moduleId = jsonObject.getString("codename");
|
||||
} catch (JSONException e) {
|
||||
Timber.e("Module %s has no codename or json %s is invalid", jsonObject.optString("codename", "Unknown"), jsonObject.toString());
|
||||
continue;
|
||||
}
|
||||
// Normally, we'd validate the module id here, but we don't need to because the server does it for us
|
||||
long lastUpdate;
|
||||
try {
|
||||
lastUpdate = jsonObject.getLong("updated_at") * 1000;
|
||||
} catch (JSONException e) {
|
||||
lastUpdate = jsonObject.getLong("lastUpdate") * 1000;
|
||||
}
|
||||
lastLastUpdate = Math.max(lastLastUpdate, lastUpdate);
|
||||
RepoModule repoModule = this.moduleHashMap.get(moduleId);
|
||||
if (repoModule == null) {
|
||||
repoModule = new RepoModule(this, moduleId);
|
||||
repoModule.moduleInfo.flags = 0;
|
||||
this.moduleHashMap.put(moduleId, repoModule);
|
||||
newModules.add(repoModule);
|
||||
} else {
|
||||
if (repoModule.lastUpdated < lastUpdate) {
|
||||
newModules.add(repoModule);
|
||||
}
|
||||
}
|
||||
repoModule.processed = true;
|
||||
repoModule.lastUpdated = lastUpdate;
|
||||
repoModule.repoName = nameForModules;
|
||||
repoModule.zipUrl = filterURL(jsonObject.optString("zipUrl", ""));
|
||||
repoModule.notesUrl = filterURL(jsonObject.optString("notesUrl", ""));
|
||||
if (repoModule.zipUrl == null) {
|
||||
repoModule.zipUrl = // Fallback url in case the API doesn't have zipUrl
|
||||
"https://" + this.host + "/magisk/info/" + moduleId;
|
||||
}
|
||||
if (repoModule.notesUrl == null) {
|
||||
repoModule.notesUrl = // Fallback url in case the API doesn't have notesUrl
|
||||
"https://" + this.host + "/magisk/readme/" + moduleId;
|
||||
}
|
||||
repoModule.zipUrl = this.injectToken(repoModule.zipUrl);
|
||||
repoModule.notesUrl = this.injectToken(repoModule.notesUrl);
|
||||
repoModule.qualityText = R.string.module_downloads;
|
||||
repoModule.qualityValue = jsonObject.optInt("downloads", 0);
|
||||
if (repoModule.qualityValue == 0) {
|
||||
repoModule.qualityValue = jsonObject.optInt("stats", 0);
|
||||
}
|
||||
String checksum = jsonObject.optString("checksum", "");
|
||||
repoModule.checksum = checksum.isEmpty() ? null : checksum;
|
||||
ModuleInfo moduleInfo = repoModule.moduleInfo;
|
||||
moduleInfo.name = jsonObject.getString("name");
|
||||
moduleInfo.versionCode = jsonObject.getLong("versionCode");
|
||||
moduleInfo.version = jsonObject.optString("version", "v" + moduleInfo.versionCode);
|
||||
moduleInfo.author = jsonObject.optString("author", "Unknown");
|
||||
moduleInfo.description = jsonObject.optString("description", "");
|
||||
moduleInfo.minApi = jsonObject.getInt("minApi");
|
||||
moduleInfo.maxApi = jsonObject.getInt("maxApi");
|
||||
String minMagisk = jsonObject.getString("minMagisk");
|
||||
try {
|
||||
int c = minMagisk.indexOf('.');
|
||||
if (c == -1) {
|
||||
moduleInfo.minMagisk = Integer.parseInt(minMagisk);
|
||||
} else {
|
||||
moduleInfo.minMagisk = // Allow 24.1 to mean 24100
|
||||
(Integer.parseInt(minMagisk.substring(0, c)) * 1000) + (Integer.parseInt(minMagisk.substring(c + 1)) * 100);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
moduleInfo.minMagisk = 0;
|
||||
}
|
||||
moduleInfo.needRamdisk = jsonObject.optBoolean("needRamdisk", false);
|
||||
moduleInfo.changeBoot = jsonObject.optBoolean("changeBoot", false);
|
||||
moduleInfo.mmtReborn = jsonObject.optBoolean("mmtReborn", false);
|
||||
moduleInfo.support = filterURL(jsonObject.optString("support"));
|
||||
moduleInfo.donate = filterURL(jsonObject.optString("donate"));
|
||||
moduleInfo.safe = (jsonObject.has("vt_status") && jsonObject.getString("vt_status").equalsIgnoreCase("clean")) || jsonObject.optBoolean("safe", false);
|
||||
String config = jsonObject.optString("config", "");
|
||||
moduleInfo.config = config.isEmpty() ? null : config;
|
||||
PropUtils.applyFallbacks(moduleInfo); // Apply fallbacks
|
||||
}
|
||||
Iterator<RepoModule> moduleInfoIterator = this.moduleHashMap.values().iterator();
|
||||
while (moduleInfoIterator.hasNext()) {
|
||||
RepoModule repoModule = moduleInfoIterator.next();
|
||||
if (!repoModule.processed) {
|
||||
moduleInfoIterator.remove();
|
||||
} else {
|
||||
repoModule.moduleInfo.verify();
|
||||
}
|
||||
}
|
||||
this.lastUpdate = lastLastUpdate;
|
||||
this.name = name;
|
||||
this.website = jsonObject.optString("website");
|
||||
this.support = jsonObject.optString("support");
|
||||
this.donate = jsonObject.optString("donate");
|
||||
this.submitModule = jsonObject.optString("submitModule");
|
||||
return newModules;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void storeMetadata(RepoModule repoModule, byte[] data) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean tryLoadMetadata(RepoModule repoModule) {
|
||||
if (this.moduleHashMap.containsKey(repoModule.id)) {
|
||||
repoModule.moduleInfo.flags &= ~ModuleInfo.FLAG_METADATA_INVALID;
|
||||
return true;
|
||||
}
|
||||
repoModule.moduleInfo.flags |= ModuleInfo.FLAG_METADATA_INVALID;
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getUrl() {
|
||||
return token == null ? this.url : this.url + "?token=" + token + "&v=" + BuildConfig.VERSION_CODE + "&c=" + BuildConfig.VERSION_NAME + "&device_id=" + generateDeviceId() + "&client_id=" + BuildConfig.ANDROIDACY_CLIENT_ID;
|
||||
}
|
||||
|
||||
private String injectToken(String url) {
|
||||
// Do not inject token for non Androidacy urls
|
||||
if (!AndroidacyUtil.Companion.isAndroidacyLink(url)) return url;
|
||||
if (this.testMode) {
|
||||
if (url.startsWith("https://production-api.androidacy.com/")) {
|
||||
Timber.e("Got non test mode url: %s", AndroidacyUtil.hideToken(url));
|
||||
url = "https://staging-api.androidacy.com/" + url.substring(38);
|
||||
}
|
||||
} else {
|
||||
if (url.startsWith("https://staging-api.androidacy.com/")) {
|
||||
Timber.e("Got test mode url: %s", AndroidacyUtil.hideToken(url));
|
||||
url = "https://production-api.androidacy.com/" + url.substring(35);
|
||||
}
|
||||
}
|
||||
String token = "token=" + AndroidacyRepoData.token;
|
||||
String deviceId = "device_id=" + generateDeviceId();
|
||||
if (!url.contains(token)) {
|
||||
if (url.lastIndexOf('/') < url.lastIndexOf('?')) {
|
||||
return url + '&' + token;
|
||||
} else {
|
||||
return url + '?' + token;
|
||||
}
|
||||
}
|
||||
if (!url.contains(deviceId)) {
|
||||
if (url.lastIndexOf('/') < url.lastIndexOf('?')) {
|
||||
return url + '&' + deviceId;
|
||||
} else {
|
||||
return url + '?' + deviceId;
|
||||
}
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public String getName() {
|
||||
return this.testMode ? super.getName() + " (Test Mode)" : super.getName();
|
||||
}
|
||||
|
||||
public void setToken(String token) {
|
||||
if (Http.hasWebView()) {
|
||||
AndroidacyRepoData.token = token;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,522 @@
|
||||
/*
|
||||
* Copyright (c) 2023 to present Androidacy and contributors. Names, logos, icons, and the Androidacy name are all trademarks of Androidacy and may not be used without license. See LICENSE for more information.
|
||||
*/
|
||||
package com.fox2code.mmm.androidacy
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.DialogInterface
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.widget.Toast
|
||||
import com.fingerprintjs.android.fingerprint.Fingerprinter
|
||||
import com.fingerprintjs.android.fingerprint.FingerprinterFactory.create
|
||||
import com.fox2code.mmm.BuildConfig
|
||||
import com.fox2code.mmm.MainApplication.Companion.INSTANCE
|
||||
import com.fox2code.mmm.MainApplication.Companion.getSharedPreferences
|
||||
import com.fox2code.mmm.R
|
||||
import com.fox2code.mmm.androidacy.AndroidacyUtil.Companion.hideToken
|
||||
import com.fox2code.mmm.androidacy.AndroidacyUtil.Companion.isAndroidacyLink
|
||||
import com.fox2code.mmm.manager.ModuleInfo
|
||||
import com.fox2code.mmm.repo.RepoData
|
||||
import com.fox2code.mmm.repo.RepoManager
|
||||
import com.fox2code.mmm.repo.RepoModule
|
||||
import com.fox2code.mmm.utils.io.PropUtils.Companion.applyFallbacks
|
||||
import com.fox2code.mmm.utils.io.PropUtils.Companion.isInvalidURL
|
||||
import com.fox2code.mmm.utils.io.net.Http.Companion.doHttpGet
|
||||
import com.fox2code.mmm.utils.io.net.Http.Companion.hasWebView
|
||||
import com.fox2code.mmm.utils.io.net.Http.Companion.needCaptchaAndroidacy
|
||||
import com.fox2code.mmm.utils.io.net.HttpException
|
||||
import com.fox2code.mmm.utils.io.net.HttpException.Companion.shouldTimeout
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import okhttp3.HttpUrl.Builder
|
||||
import okhttp3.HttpUrl.Builder.*
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.UUID
|
||||
import kotlin.math.max
|
||||
|
||||
class AndroidacyRepoData(cacheRoot: File?, testMode: Boolean) : RepoData(
|
||||
if (testMode) RepoManager.ANDROIDACY_TEST_MAGISK_REPO_ENDPOINT else RepoManager.ANDROIDACY_MAGISK_REPO_ENDPOINT,
|
||||
cacheRoot!!
|
||||
) {
|
||||
private val clientID = BuildConfig.ANDROIDACY_CLIENT_ID
|
||||
private val testMode: Boolean
|
||||
private val host: String
|
||||
@JvmField
|
||||
var userInfo = arrayOf(arrayOf("role", null), arrayOf("permissions", null))
|
||||
@JvmField
|
||||
var memberLevel: String? = null
|
||||
|
||||
// Avoid spamming requests to Androidacy
|
||||
private var androidacyBlockade: Long = 0
|
||||
|
||||
init {
|
||||
defaultName = "Androidacy Modules Repo"
|
||||
defaultWebsite = RepoManager.ANDROIDACY_MAGISK_REPO_HOMEPAGE
|
||||
defaultSupport = "https://t.me/androidacy_discussions"
|
||||
defaultDonate =
|
||||
"https://www.androidacy.com/membership-account/membership-checkout/?level=2&discount_code=FOX2CODE&utm_souce=foxmmm&utm_medium=android-app&utm_campaign=fox-upgrade-promo"
|
||||
defaultSubmitModule = "https://www.androidacy.com/module-repository-applications/"
|
||||
host = if (testMode) "staging-api.androidacy.com" else "production-api.androidacy.com"
|
||||
this.testMode = testMode
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun isValidToken(token: String?): Boolean {
|
||||
val deviceId = generateDeviceId()
|
||||
return try {
|
||||
val resp = doHttpGet(
|
||||
"https://$host/auth/me?token=$token&device_id=$deviceId&client_id=$clientID",
|
||||
false
|
||||
)
|
||||
// response is JSON
|
||||
val jsonObject = JSONObject(String(resp))
|
||||
memberLevel = jsonObject.getString("role")
|
||||
Timber.d("Member level: %s", memberLevel)
|
||||
val memberPermissions = jsonObject.getJSONArray("permissions")
|
||||
// set role and permissions on userInfo property
|
||||
userInfo = arrayOf(
|
||||
arrayOf("role", memberLevel),
|
||||
arrayOf("permissions", memberPermissions.toString())
|
||||
)
|
||||
true
|
||||
} catch (e: HttpException) {
|
||||
if (e.errorCode == 401) {
|
||||
Timber.w("Invalid token, resetting...")
|
||||
// Remove saved preference
|
||||
val editor = getSharedPreferences("androidacy")!!.edit()
|
||||
editor.remove("pref_androidacy_api_token")
|
||||
editor.apply()
|
||||
return false
|
||||
}
|
||||
throw e
|
||||
} catch (e: JSONException) {
|
||||
// response is not JSON
|
||||
Timber.w("Invalid token, resetting...")
|
||||
Timber.w(e)
|
||||
// Remove saved preference
|
||||
val editor = getSharedPreferences("androidacy")!!.edit()
|
||||
editor.remove("pref_androidacy_api_token")
|
||||
editor.apply()
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a new token from the server and save it to the shared preferences
|
||||
* @return String token
|
||||
*/
|
||||
@Throws(IOException::class, JSONException::class)
|
||||
fun requestNewToken(): String {
|
||||
val deviceId = generateDeviceId()
|
||||
val resp = doHttpGet(
|
||||
"https://" + host + "/auth/register?device_id=" + deviceId + "&client_id=" + BuildConfig.ANDROIDACY_CLIENT_ID,
|
||||
false
|
||||
)
|
||||
// response is JSON
|
||||
val jsonObject = JSONObject(String(resp))
|
||||
val token = jsonObject.getString("token")
|
||||
// Save the token to the shared preferences
|
||||
val editor = getSharedPreferences("androidacy")!!.edit()
|
||||
editor.putString("pref_androidacy_api_token", token)
|
||||
editor.apply()
|
||||
return token
|
||||
}
|
||||
|
||||
@SuppressLint("RestrictedApi", "BinaryOperationInTimber")
|
||||
override fun prepare(): Boolean {
|
||||
// If ANDROIDACY_CLIENT_ID is not set or is empty, disable this repo and return
|
||||
@Suppress("KotlinConstantConditions")
|
||||
if (BuildConfig.ANDROIDACY_CLIENT_ID == "") {
|
||||
val editor = getSharedPreferences("mmm")!!.edit()
|
||||
editor.putBoolean("pref_androidacy_repo_enabled", false)
|
||||
editor.apply()
|
||||
Timber.w("ANDROIDACY_CLIENT_ID is empty, disabling AndroidacyRepoData 2")
|
||||
return false
|
||||
}
|
||||
if (needCaptchaAndroidacy()) return false
|
||||
// Implementation details discussed on telegram
|
||||
// First, ping the server to check if it's alive
|
||||
try {
|
||||
val connection = URL("https://$host/ping").openConnection() as HttpURLConnection
|
||||
connection.requestMethod = "GET"
|
||||
connection.readTimeout = 5000
|
||||
connection.connect()
|
||||
if (connection.responseCode != 200 && connection.responseCode != 204) {
|
||||
// If it's a 400, the app is probably outdated. Show a snackbar suggesting user update app and webview
|
||||
if (connection.responseCode == 400) {
|
||||
// Show a dialog using androidacy_update_needed string
|
||||
INSTANCE?.let { MaterialAlertDialogBuilder(it) }!!.setTitle(R.string.androidacy_update_needed)
|
||||
.setMessage(
|
||||
R.string.androidacy_update_needed_message
|
||||
)
|
||||
.setPositiveButton(R.string.update) { _: DialogInterface?, _: Int ->
|
||||
// Open the app's page on the Play Store
|
||||
val intent = Intent(Intent.ACTION_VIEW)
|
||||
intent.data =
|
||||
Uri.parse("https://www.androidacy.com/downloads/?view=FoxMMM&utm_source=foxmnm&utm_medium=app&utm_campaign=android-app")
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
INSTANCE!!.startActivity(intent)
|
||||
}.setNegativeButton(R.string.cancel, null).show()
|
||||
}
|
||||
return false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to ping server")
|
||||
return false
|
||||
}
|
||||
val time = System.currentTimeMillis()
|
||||
if (androidacyBlockade > time) return true // fake it till you make it. Basically,
|
||||
// don't fail just because we're rate limited. API and web rate limits are different.
|
||||
androidacyBlockade = time + 30000L
|
||||
try {
|
||||
if (token == null) {
|
||||
token = getSharedPreferences("androidacy")?.getString("pref_androidacy_api_token", null)
|
||||
if (token != null && !isValidToken(token)) {
|
||||
Timber.i("Token expired or invalid, requesting new one...")
|
||||
token = null
|
||||
} else {
|
||||
Timber.i("Using cached token")
|
||||
}
|
||||
} else if (!isValidToken(token)) {
|
||||
Timber.i("Token expired, requesting new one...")
|
||||
token = null
|
||||
} else {
|
||||
Timber.i("Using validated cached token")
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
if (shouldTimeout(e)) {
|
||||
Timber.e(e, "We are being rate limited!")
|
||||
androidacyBlockade = time + 3600000L
|
||||
}
|
||||
return false
|
||||
}
|
||||
if (token == null) {
|
||||
Timber.i("Token is null, requesting new one...")
|
||||
try {
|
||||
Timber.i("Requesting new token...")
|
||||
// POST json request to https://production-api.androidacy.com/auth/register
|
||||
token = requestNewToken()
|
||||
// Parse token
|
||||
try {
|
||||
val jsonObject = JSONObject(token!!)
|
||||
// log last four of token, replacing the rest with asterisks
|
||||
token = jsonObject.getString("token")
|
||||
val tempToken = token!!
|
||||
Timber.d(
|
||||
"Token: %s",
|
||||
tempToken.substring(0, tempToken.length - 4)
|
||||
.replace(".".toRegex(), "*") + tempToken.substring(
|
||||
tempToken.length - 4
|
||||
)
|
||||
)
|
||||
memberLevel = jsonObject.getString("role")
|
||||
Timber.d("Member level: %s", memberLevel)
|
||||
} catch (e: JSONException) {
|
||||
Timber.e(e, "Failed to parse token: %s", token)
|
||||
// Show a toast
|
||||
val mainLooper = Looper.getMainLooper()
|
||||
val handler = Handler(mainLooper)
|
||||
handler.post {
|
||||
Toast.makeText(
|
||||
INSTANCE,
|
||||
R.string.androidacy_failed_to_parse_token,
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
return false
|
||||
}
|
||||
// Ensure token is valid
|
||||
if (!isValidToken(token)) {
|
||||
Timber.e("Failed to validate token")
|
||||
// Show a toast
|
||||
val mainLooper = Looper.getMainLooper()
|
||||
val handler = Handler(mainLooper)
|
||||
handler.post {
|
||||
Toast.makeText(
|
||||
INSTANCE,
|
||||
R.string.androidacy_failed_to_validate_token,
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
return false
|
||||
} else {
|
||||
// Save token to shared preference
|
||||
val editor = getSharedPreferences("androidacy")!!.edit()
|
||||
editor.putString("pref_androidacy_api_token", token)
|
||||
editor.apply()
|
||||
Timber.i("Token saved to shared preference")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (shouldTimeout(e)) {
|
||||
Timber.e(e, "We are being rate limited!")
|
||||
androidacyBlockade = time + 3600000L
|
||||
}
|
||||
Timber.e(e, "Failed to get a new token")
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@Suppress("NAME_SHADOWING")
|
||||
@Throws(JSONException::class)
|
||||
override fun populate(jsonObject: JSONObject): List<RepoModule>? {
|
||||
var jsonObject = jsonObject
|
||||
Timber.d("AndroidacyRepoData populate start")
|
||||
val name = jsonObject.optString("name", "Androidacy Modules Repo")
|
||||
val nameForModules =
|
||||
if (name.endsWith(" (Official)")) name.substring(0, name.length - 11) else name
|
||||
val jsonArray: JSONArray = try {
|
||||
jsonObject.getJSONArray("data")
|
||||
} catch (e: JSONException) {
|
||||
// probably using modules key since it's cached
|
||||
try {
|
||||
jsonObject.getJSONArray("modules")
|
||||
} catch (e2: JSONException) {
|
||||
// we should never get here, bail out
|
||||
Timber.e(e2, "Failed to parse modules")
|
||||
return null
|
||||
}
|
||||
}
|
||||
for (repoModule in moduleHashMap.values) {
|
||||
repoModule.processed = false
|
||||
}
|
||||
val newModules = ArrayList<RepoModule>()
|
||||
val len = jsonArray.length()
|
||||
var lastLastUpdate: Long = 0
|
||||
for (i in 0 until len) {
|
||||
jsonObject = jsonArray.getJSONObject(i)
|
||||
val moduleId: String = try {
|
||||
jsonObject.getString("codename")
|
||||
} catch (e: JSONException) {
|
||||
Timber.e(
|
||||
"Module %s has no codename or json %s is invalid",
|
||||
jsonObject.optString("codename", "Unknown"),
|
||||
jsonObject.toString()
|
||||
)
|
||||
continue
|
||||
}
|
||||
// Normally, we'd validate the module id here, but we don't need to because the server does it for us
|
||||
val lastUpdate: Long = try {
|
||||
jsonObject.getLong("updated_at") * 1000
|
||||
} catch (e: JSONException) {
|
||||
jsonObject.getLong("lastUpdate") * 1000
|
||||
}
|
||||
lastLastUpdate = max(lastLastUpdate, lastUpdate)
|
||||
var repoModule = moduleHashMap[moduleId]
|
||||
if (repoModule == null) {
|
||||
repoModule = RepoModule(this, moduleId)
|
||||
repoModule.moduleInfo.flags = 0
|
||||
moduleHashMap[moduleId] = repoModule
|
||||
newModules.add(repoModule)
|
||||
} else {
|
||||
if (repoModule.lastUpdated < lastUpdate) {
|
||||
newModules.add(repoModule)
|
||||
}
|
||||
}
|
||||
repoModule.processed = true
|
||||
repoModule.lastUpdated = lastUpdate
|
||||
repoModule.repoName = nameForModules
|
||||
repoModule.zipUrl = filterURL(jsonObject.optString("zipUrl", ""))
|
||||
repoModule.notesUrl = filterURL(jsonObject.optString("notesUrl", ""))
|
||||
if (repoModule.zipUrl == null) {
|
||||
repoModule.zipUrl = // Fallback url in case the API doesn't have zipUrl
|
||||
"https://$host/magisk/info/$moduleId"
|
||||
}
|
||||
if (repoModule.notesUrl == null) {
|
||||
repoModule.notesUrl = // Fallback url in case the API doesn't have notesUrl
|
||||
"https://$host/magisk/readme/$moduleId"
|
||||
}
|
||||
repoModule.zipUrl = injectToken(repoModule.zipUrl)
|
||||
repoModule.notesUrl = injectToken(repoModule.notesUrl)
|
||||
repoModule.qualityText = R.string.module_downloads
|
||||
repoModule.qualityValue = jsonObject.optInt("downloads", 0)
|
||||
if (repoModule.qualityValue == 0) {
|
||||
repoModule.qualityValue = jsonObject.optInt("stats", 0)
|
||||
}
|
||||
val checksum = jsonObject.optString("checksum", "")
|
||||
repoModule.checksum = checksum.ifEmpty { null }
|
||||
val moduleInfo = repoModule.moduleInfo
|
||||
moduleInfo.name = jsonObject.getString("name")
|
||||
moduleInfo.versionCode = jsonObject.getLong("versionCode")
|
||||
moduleInfo.version = jsonObject.optString("version", "v" + moduleInfo.versionCode)
|
||||
moduleInfo.author = jsonObject.optString("author", "Unknown")
|
||||
moduleInfo.description = jsonObject.optString("description", "")
|
||||
moduleInfo.minApi = jsonObject.getInt("minApi")
|
||||
moduleInfo.maxApi = jsonObject.getInt("maxApi")
|
||||
val minMagisk = jsonObject.getString("minMagisk")
|
||||
try {
|
||||
val c = minMagisk.indexOf('.')
|
||||
if (c == -1) {
|
||||
moduleInfo.minMagisk = minMagisk.toInt()
|
||||
} else {
|
||||
moduleInfo.minMagisk = // Allow 24.1 to mean 24100
|
||||
minMagisk.substring(0, c).toInt() * 1000 + minMagisk.substring(c + 1)
|
||||
.toInt() * 100
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
moduleInfo.minMagisk = 0
|
||||
}
|
||||
moduleInfo.needRamdisk = jsonObject.optBoolean("needRamdisk", false)
|
||||
moduleInfo.changeBoot = jsonObject.optBoolean("changeBoot", false)
|
||||
moduleInfo.mmtReborn = jsonObject.optBoolean("mmtReborn", false)
|
||||
moduleInfo.support = filterURL(jsonObject.optString("support"))
|
||||
moduleInfo.donate = filterURL(jsonObject.optString("donate"))
|
||||
moduleInfo.safe = jsonObject.has("vt_status") && jsonObject.getString("vt_status")
|
||||
.equals("clean", ignoreCase = true) || jsonObject.optBoolean("safe", false)
|
||||
val config = jsonObject.optString("config", "")
|
||||
moduleInfo.config = config.ifEmpty { null }
|
||||
applyFallbacks(moduleInfo) // Apply fallbacks
|
||||
}
|
||||
val moduleInfoIterator = moduleHashMap.values.iterator()
|
||||
while (moduleInfoIterator.hasNext()) {
|
||||
val repoModule = moduleInfoIterator.next()
|
||||
if (!repoModule.processed) {
|
||||
moduleInfoIterator.remove()
|
||||
} else {
|
||||
repoModule.moduleInfo.verify()
|
||||
}
|
||||
}
|
||||
lastUpdate = lastLastUpdate
|
||||
this.name = name
|
||||
website = jsonObject.optString("website")
|
||||
support = jsonObject.optString("support")
|
||||
donate = jsonObject.optString("donate")
|
||||
submitModule = jsonObject.optString("submitModule")
|
||||
return newModules
|
||||
}
|
||||
|
||||
override fun storeMetadata(repoModule: RepoModule, data: ByteArray?) {}
|
||||
override fun tryLoadMetadata(repoModule: RepoModule): Boolean {
|
||||
if (moduleHashMap.containsKey(repoModule.id)) {
|
||||
repoModule.moduleInfo.flags =
|
||||
repoModule.moduleInfo.flags and ModuleInfo.FLAG_METADATA_INVALID.inv()
|
||||
return true
|
||||
}
|
||||
repoModule.moduleInfo.flags =
|
||||
repoModule.moduleInfo.flags or ModuleInfo.FLAG_METADATA_INVALID
|
||||
return false
|
||||
}
|
||||
|
||||
override fun getUrl(): String {
|
||||
return if (token == null) url else url + "?token=" + token + "&v=" + BuildConfig.VERSION_CODE + "&c=" + BuildConfig.VERSION_NAME + "&device_id=" + generateDeviceId() + "&client_id=" + BuildConfig.ANDROIDACY_CLIENT_ID
|
||||
}
|
||||
|
||||
@Suppress("NAME_SHADOWING")
|
||||
private fun injectToken(url: String?): String? {
|
||||
// Do not inject token for non Androidacy urls
|
||||
var url = url
|
||||
if (!isAndroidacyLink(url)) return url
|
||||
if (testMode) {
|
||||
if (url!!.startsWith("https://production-api.androidacy.com/")) {
|
||||
Timber.e("Got non test mode url: %s", hideToken(url))
|
||||
url = "https://staging-api.androidacy.com/" + url.substring(38)
|
||||
}
|
||||
} else {
|
||||
if (url!!.startsWith("https://staging-api.androidacy.com/")) {
|
||||
Timber.e("Got test mode url: %s", hideToken(url))
|
||||
url = "https://production-api.androidacy.com/" + url.substring(35)
|
||||
}
|
||||
}
|
||||
val token = "token=$token"
|
||||
val deviceId = "device_id=" + generateDeviceId()
|
||||
if (!url.contains(token)) {
|
||||
return if (url.lastIndexOf('/') < url.lastIndexOf('?')) {
|
||||
"$url&$token"
|
||||
} else {
|
||||
"$url?$token"
|
||||
}
|
||||
}
|
||||
return if (!url.contains(deviceId)) {
|
||||
if (url.lastIndexOf('/') < url.lastIndexOf('?')) {
|
||||
"$url&$deviceId"
|
||||
} else {
|
||||
"$url?$deviceId"
|
||||
}
|
||||
} else url
|
||||
}
|
||||
|
||||
override var name: String?
|
||||
get() = if (testMode) super.name + " (Test Mode)" else super.name!!
|
||||
set(name) {
|
||||
super.name = name
|
||||
}
|
||||
|
||||
fun setToken(token: String?) {
|
||||
if (hasWebView()) {
|
||||
Companion.token = token
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private var ANDROIDACY_DEVICE_ID: String? = null
|
||||
var token = getSharedPreferences("androidacy")!!.getString("pref_androidacy_api_token", null)
|
||||
|
||||
init {
|
||||
@Suppress("LocalVariableName") val OK_HTTP_URL_BUILDER: Builder = Builder().scheme("https")
|
||||
// Using HttpUrl.Builder.host(String) crash the app
|
||||
OK_HTTP_URL_BUILDER.host("production-api.androidacy.com")
|
||||
OK_HTTP_URL_BUILDER.build()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
val instance: AndroidacyRepoData
|
||||
get() = RepoManager.getINSTANCE()!!.androidacyRepoData!!
|
||||
|
||||
private fun filterURL(url: String?): String? {
|
||||
return if (url.isNullOrEmpty() || isInvalidURL(url)) {
|
||||
null
|
||||
} else url
|
||||
}
|
||||
|
||||
// Generates a unique device ID. This is used to identify the device in the API for rate
|
||||
// limiting and fraud detection.
|
||||
fun generateDeviceId(): String? {
|
||||
// first, check if ANDROIDACY_DEVICE_ID is already set
|
||||
if (ANDROIDACY_DEVICE_ID != null) {
|
||||
return ANDROIDACY_DEVICE_ID
|
||||
}
|
||||
// Try to get the device ID from the shared preferences
|
||||
val sharedPreferences = getSharedPreferences("androidacy")
|
||||
val deviceIdPref =
|
||||
sharedPreferences!!.getString("device_id_v2", null)
|
||||
return if (deviceIdPref != null) {
|
||||
ANDROIDACY_DEVICE_ID = deviceIdPref
|
||||
deviceIdPref
|
||||
} else {
|
||||
val fp = create(INSTANCE!!.applicationContext)
|
||||
fp.getFingerprint(Fingerprinter.Version.V_5) { fingerprint: String? ->
|
||||
ANDROIDACY_DEVICE_ID = fingerprint
|
||||
// use fingerprint
|
||||
// Save the device ID to the shared preferences
|
||||
val editor = sharedPreferences.edit()
|
||||
editor.putString("device_id_v2", ANDROIDACY_DEVICE_ID)
|
||||
editor.apply()
|
||||
}
|
||||
// wait for up to 5 seconds for the fingerprint to be generated (ANDROIDACY_DEVICE_ID to be set)
|
||||
val startTime = System.currentTimeMillis()
|
||||
while (ANDROIDACY_DEVICE_ID == null && System.currentTimeMillis() - startTime < 5000) {
|
||||
try {
|
||||
Thread.sleep(100)
|
||||
} catch (ignored: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
}
|
||||
}
|
||||
if (ANDROIDACY_DEVICE_ID == null) {
|
||||
// fingerprint generation failed, use a random UUID
|
||||
ANDROIDACY_DEVICE_ID = UUID.randomUUID().toString()
|
||||
}
|
||||
ANDROIDACY_DEVICE_ID
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,63 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2023 to present Androidacy and contributors. Names, logos, icons, and the Androidacy name are all trademarks of Androidacy and may not be used without license. See LICENSE for more information.
|
||||
*/
|
||||
|
||||
package com.fox2code.mmm.repo;
|
||||
|
||||
import com.fox2code.mmm.utils.io.net.Http;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
public final class CustomRepoData extends RepoData {
|
||||
boolean loadedExternal;
|
||||
String override;
|
||||
|
||||
CustomRepoData(String url, File cacheRoot) {
|
||||
super(url, cacheRoot);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEnabledByDefault() {
|
||||
return this.override != null || this.loadedExternal;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getPreferenceId() {
|
||||
return this.override == null ?
|
||||
this.id : this.override;
|
||||
}
|
||||
|
||||
public void quickPrePopulate() throws IOException, JSONException {
|
||||
JSONObject jsonObject = new JSONObject(
|
||||
new String(Http.doHttpGet(this.getUrl(),
|
||||
false), StandardCharsets.UTF_8));
|
||||
// make sure there's at least a name and a modules or data object
|
||||
if (!jsonObject.has("name") || (!jsonObject.has("modules") && !jsonObject.has("data"))) {
|
||||
throw new IllegalArgumentException("Invalid repo: " + this.getUrl());
|
||||
}
|
||||
this.name = jsonObject.getString("name").trim();
|
||||
this.website = jsonObject.optString("website");
|
||||
this.support = jsonObject.optString("support");
|
||||
this.donate = jsonObject.optString("donate");
|
||||
this.submitModule = jsonObject.optString("submitModule");
|
||||
}
|
||||
|
||||
public Object toJSON() {
|
||||
try {
|
||||
return new JSONObject()
|
||||
.put("id", this.id)
|
||||
.put("name", this.name)
|
||||
.put("website", this.website)
|
||||
.put("support", this.support)
|
||||
.put("donate", this.donate)
|
||||
.put("submitModule", this.submitModule);
|
||||
} catch (JSONException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright (c) 2023 to present Androidacy and contributors. Names, logos, icons, and the Androidacy name are all trademarks of Androidacy and may not be used without license. See LICENSE for more information.
|
||||
*/
|
||||
package com.fox2code.mmm.repo
|
||||
|
||||
import com.fox2code.mmm.utils.io.net.Http.Companion.doHttpGet
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
class CustomRepoData internal constructor(url: String?, cacheRoot: File?) : RepoData(
|
||||
url!!, cacheRoot!!
|
||||
) {
|
||||
@JvmField
|
||||
var loadedExternal = false
|
||||
@JvmField
|
||||
var override: String? = null
|
||||
override val isEnabledByDefault: Boolean
|
||||
get() = override != null || loadedExternal
|
||||
|
||||
@Throws(IOException::class, JSONException::class)
|
||||
fun quickPrePopulate() {
|
||||
val jsonObject = JSONObject(
|
||||
String(
|
||||
doHttpGet(
|
||||
getUrl()!!,
|
||||
false
|
||||
), StandardCharsets.UTF_8
|
||||
)
|
||||
)
|
||||
// make sure there's at least a name and a modules or data object
|
||||
require(!(!jsonObject.has("name") || !jsonObject.has("modules") && !jsonObject.has("data"))) { "Invalid repo: " + getUrl() }
|
||||
name = jsonObject.getString("name").trim { it <= ' ' }
|
||||
website = jsonObject.optString("website")
|
||||
support = jsonObject.optString("support")
|
||||
donate = jsonObject.optString("donate")
|
||||
submitModule = jsonObject.optString("submitModule")
|
||||
}
|
||||
|
||||
fun toJSON(): Any? {
|
||||
return try {
|
||||
JSONObject()
|
||||
.put("id", preferenceId)
|
||||
.put("name", name)
|
||||
.put("website", website)
|
||||
.put("support", support)
|
||||
.put("donate", donate)
|
||||
.put("submitModule", submitModule)
|
||||
} catch (ignored: JSONException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
@ -1,207 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2023 to present Androidacy and contributors. Names, logos, icons, and the Androidacy name are all trademarks of Androidacy and may not be used without license. See LICENSE for more information.
|
||||
*/
|
||||
|
||||
package com.fox2code.mmm.repo;
|
||||
|
||||
import com.fox2code.mmm.MainApplication;
|
||||
import com.fox2code.mmm.utils.io.Hashes;
|
||||
import com.fox2code.mmm.utils.io.PropUtils;
|
||||
import com.fox2code.mmm.utils.io.net.Http;
|
||||
import com.fox2code.mmm.utils.realm.ReposList;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
|
||||
import io.realm.Realm;
|
||||
import io.realm.RealmConfiguration;
|
||||
import timber.log.Timber;
|
||||
|
||||
public class CustomRepoManager {
|
||||
public static final int MAX_CUSTOM_REPOS = 5;
|
||||
private static final boolean AUTO_RECOMPILE = true;
|
||||
private final RepoManager repoManager;
|
||||
private final String[] customRepos;
|
||||
boolean dirty;
|
||||
private int customReposCount;
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
CustomRepoManager(MainApplication mainApplication, RepoManager repoManager) {
|
||||
this.repoManager = repoManager;
|
||||
this.customRepos = new String[MAX_CUSTOM_REPOS];
|
||||
this.customReposCount = 0;
|
||||
// refuse to load if setup is not complete
|
||||
if (MainApplication.getSharedPreferences("mmm").getString("last_shown_setup", "").equals("")) {
|
||||
return;
|
||||
}
|
||||
RealmConfiguration realmConfiguration = new RealmConfiguration.Builder().name("ReposList.realm").encryptionKey(MainApplication.getINSTANCE().getKey()).allowQueriesOnUiThread(true).allowWritesOnUiThread(true).directory(MainApplication.getINSTANCE().getDataDirWithPath("realms")).schemaVersion(1).build();
|
||||
Realm realm = Realm.getInstance(realmConfiguration);
|
||||
if (realm.isInTransaction()) {
|
||||
realm.commitTransaction();
|
||||
}
|
||||
int i = 0;
|
||||
@SuppressWarnings("MismatchedReadAndWriteOfArray") final int[] lastFilled = {0};
|
||||
realm.executeTransaction(realm1 -> {
|
||||
// find all repos that are not built-in
|
||||
for (ReposList reposList : realm1.where(ReposList.class).notEqualTo("id", "androidacy_repo").and().notEqualTo("id", "magisk_alt_repo").and().notEqualTo("id", "magisk_official_repo").findAll()) {
|
||||
String repo = reposList.getUrl();
|
||||
if (!PropUtils.isNullString(repo) && !RepoManager.isBuiltInRepo(repo)) {
|
||||
lastFilled[0] = i;
|
||||
int index = AUTO_RECOMPILE ? this.customReposCount : i;
|
||||
this.customRepos[index] = repo;
|
||||
this.customReposCount++;
|
||||
((CustomRepoData) this.repoManager.addOrGet(repo)).override = "custom_repo_" + index;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public CustomRepoData addRepo(String repo) {
|
||||
if (RepoManager.isBuiltInRepo(repo))
|
||||
throw new IllegalArgumentException("Can't add built-in repo to custom repos");
|
||||
for (String repoEntry : this.customRepos) {
|
||||
if (repo.equals(repoEntry)) return (CustomRepoData) this.repoManager.get(repoEntry);
|
||||
}
|
||||
int i = 0;
|
||||
while (customRepos[i] != null) i++;
|
||||
customRepos[i] = repo;
|
||||
// fetch that sweet sweet json
|
||||
byte[] json;
|
||||
try {
|
||||
json = Http.doHttpGet(repo, false);
|
||||
} catch (Exception e) {
|
||||
Timber.e(e, "Failed to fetch json from repo");
|
||||
return null;
|
||||
}
|
||||
// get website, support, donate, submitModule. all optional. name is required.
|
||||
// parse json
|
||||
JSONObject jsonObject;
|
||||
try {
|
||||
jsonObject = new JSONObject(new String(json));
|
||||
} catch (Exception e) {
|
||||
Timber.e(e, "Failed to parse json from repo");
|
||||
return null;
|
||||
}
|
||||
// get name
|
||||
String name;
|
||||
try {
|
||||
name = jsonObject.getString("name");
|
||||
} catch (Exception e) {
|
||||
Timber.e(e, "Failed to get name from json");
|
||||
return null;
|
||||
}
|
||||
// get website
|
||||
String website;
|
||||
try {
|
||||
website = jsonObject.getString("website");
|
||||
} catch (Exception e) {
|
||||
website = null;
|
||||
}
|
||||
// get support
|
||||
String support;
|
||||
try {
|
||||
support = jsonObject.getString("support");
|
||||
} catch (Exception e) {
|
||||
support = null;
|
||||
}
|
||||
// get donate
|
||||
String donate;
|
||||
try {
|
||||
donate = jsonObject.getString("donate");
|
||||
} catch (Exception e) {
|
||||
donate = null;
|
||||
}
|
||||
// get submitModule
|
||||
String submitModule;
|
||||
try {
|
||||
submitModule = jsonObject.getString("submitModule");
|
||||
} catch (Exception e) {
|
||||
submitModule = null;
|
||||
}
|
||||
String id = "repo_" + Hashes.hashSha256(repo.getBytes(StandardCharsets.UTF_8));
|
||||
RealmConfiguration realmConfiguration = new RealmConfiguration.Builder().name("ReposList.realm").encryptionKey(MainApplication.getINSTANCE().getKey()).allowQueriesOnUiThread(true).allowWritesOnUiThread(true).directory(MainApplication.getINSTANCE().getDataDirWithPath("realms")).schemaVersion(1).build();
|
||||
Realm realm = Realm.getInstance(realmConfiguration);
|
||||
String finalWebsite = website;
|
||||
String finalSupport = support;
|
||||
String finalDonate = donate;
|
||||
String finalSubmitModule = submitModule;
|
||||
realm.executeTransaction(realm1 -> {
|
||||
// find the matching entry for repo_0, repo_1, etc.
|
||||
ReposList reposList = realm1.where(ReposList.class).equalTo("id", id).findFirst();
|
||||
if (reposList == null) {
|
||||
reposList = realm1.createObject(ReposList.class, id);
|
||||
}
|
||||
reposList.setUrl(repo);
|
||||
reposList.setName(name);
|
||||
reposList.setWebsite(finalWebsite);
|
||||
reposList.setSupport(finalSupport);
|
||||
reposList.setDonate(finalDonate);
|
||||
reposList.setSubmitModule(finalSubmitModule);
|
||||
reposList.setEnabled(true);
|
||||
// save the object
|
||||
realm1.copyToRealmOrUpdate(reposList);
|
||||
});
|
||||
customReposCount++;
|
||||
this.dirty = true;
|
||||
CustomRepoData customRepoData = (CustomRepoData) this.repoManager.addOrGet(repo);
|
||||
customRepoData.override = "repo_" + id;
|
||||
customRepoData.id = id;
|
||||
customRepoData.website = website;
|
||||
customRepoData.support = support;
|
||||
customRepoData.donate = donate;
|
||||
customRepoData.submitModule = submitModule;
|
||||
customRepoData.name = name;
|
||||
// Set the enabled state to true
|
||||
customRepoData.setEnabled(true);
|
||||
customRepoData.updateEnabledState();
|
||||
realm.close();
|
||||
return customRepoData;
|
||||
}
|
||||
|
||||
public CustomRepoData getRepo(String id) {
|
||||
return (CustomRepoData) this.repoManager.get(id);
|
||||
}
|
||||
|
||||
public void removeRepo(int index) {
|
||||
String oldRepo = customRepos[index];
|
||||
if (oldRepo != null) {
|
||||
customRepos[index] = null;
|
||||
customReposCount--;
|
||||
CustomRepoData customRepoData = (CustomRepoData) this.repoManager.get(oldRepo);
|
||||
if (customRepoData != null) {
|
||||
customRepoData.setEnabled(false);
|
||||
customRepoData.override = null;
|
||||
}
|
||||
this.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean hasRepo(String repo) {
|
||||
for (String repoEntry : this.customRepos) {
|
||||
if (repo.equals(repoEntry)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean canAddRepo() {
|
||||
return this.customReposCount < MAX_CUSTOM_REPOS;
|
||||
}
|
||||
|
||||
public boolean canAddRepo(String repo) {
|
||||
if (RepoManager.isBuiltInRepo(repo) || this.hasRepo(repo) || !this.canAddRepo())
|
||||
return false;
|
||||
return repo.startsWith("https://") && repo.indexOf('/', 9) != -1;
|
||||
}
|
||||
|
||||
public int getRepoCount() {
|
||||
return this.customReposCount;
|
||||
}
|
||||
|
||||
public boolean needUpdate() {
|
||||
boolean needUpdate = this.dirty;
|
||||
if (needUpdate) this.dirty = false;
|
||||
return needUpdate;
|
||||
}
|
||||
}
|
@ -0,0 +1,208 @@
|
||||
/*
|
||||
* Copyright (c) 2023 to present Androidacy and contributors. Names, logos, icons, and the Androidacy name are all trademarks of Androidacy and may not be used without license. See LICENSE for more information.
|
||||
*/
|
||||
package com.fox2code.mmm.repo
|
||||
|
||||
import com.fox2code.mmm.MainApplication
|
||||
import com.fox2code.mmm.MainApplication.Companion.INSTANCE
|
||||
import com.fox2code.mmm.MainApplication.Companion.getSharedPreferences
|
||||
import com.fox2code.mmm.utils.io.Hashes.Companion.hashSha256
|
||||
import com.fox2code.mmm.utils.io.PropUtils.Companion.isNullString
|
||||
import com.fox2code.mmm.utils.io.net.Http.Companion.doHttpGet
|
||||
import com.fox2code.mmm.utils.realm.ReposList
|
||||
import io.realm.Realm
|
||||
import io.realm.RealmConfiguration
|
||||
import org.json.JSONObject
|
||||
import timber.log.Timber
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
@Suppress("UNUSED_PARAMETER", "MemberVisibilityCanBePrivate")
|
||||
class CustomRepoManager internal constructor(
|
||||
mainApplication: MainApplication?,
|
||||
private val repoManager: RepoManager
|
||||
) {
|
||||
private val customRepos: Array<String?> = arrayOfNulls(MAX_CUSTOM_REPOS)
|
||||
|
||||
@JvmField
|
||||
var dirty = false
|
||||
var repoCount: Int
|
||||
private set
|
||||
|
||||
init {
|
||||
repoCount = 0
|
||||
// refuse to load if setup is not complete
|
||||
if (getSharedPreferences("mmm")!!.getString("last_shown_setup", "") != "") {
|
||||
val realmConfiguration =
|
||||
RealmConfiguration.Builder().name("ReposList.realm").encryptionKey(
|
||||
INSTANCE!!.key
|
||||
).allowQueriesOnUiThread(true).allowWritesOnUiThread(true).directory(
|
||||
INSTANCE!!.getDataDirWithPath("realms")
|
||||
).schemaVersion(1).build()
|
||||
val realm = Realm.getInstance(realmConfiguration)
|
||||
if (realm.isInTransaction) {
|
||||
realm.commitTransaction()
|
||||
}
|
||||
val i = 0
|
||||
val lastFilled = intArrayOf(0)
|
||||
realm.executeTransaction { realm1: Realm ->
|
||||
// find all repos that are not built-in
|
||||
for (reposList in realm1.where(ReposList::class.java)
|
||||
.notEqualTo("id", "androidacy_repo").and().notEqualTo("id", "magisk_alt_repo")
|
||||
.and()
|
||||
.notEqualTo("id", "magisk_official_repo").findAll()) {
|
||||
val repo = reposList.url
|
||||
if (!isNullString(repo) && !RepoManager.isBuiltInRepo(repo)) {
|
||||
lastFilled[0] = i
|
||||
val index = if (AUTO_RECOMPILE) repoCount else i
|
||||
customRepos[index] = repo
|
||||
repoCount++
|
||||
(repoManager.addOrGet(repo) as CustomRepoData).override =
|
||||
"custom_repo_$index"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addRepo(repo: String): CustomRepoData? {
|
||||
require(!RepoManager.isBuiltInRepo(repo)) { "Can't add built-in repo to custom repos" }
|
||||
for (repoEntry in customRepos) {
|
||||
if (repo == repoEntry) return repoManager[repoEntry] as CustomRepoData
|
||||
}
|
||||
var i = 0
|
||||
while (customRepos[i] != null) i++
|
||||
customRepos[i] = repo
|
||||
// fetch that sweet sweet json
|
||||
val json: ByteArray = try {
|
||||
doHttpGet(repo, false)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to fetch json from repo")
|
||||
return null
|
||||
}
|
||||
// get website, support, donate, submitModule. all optional. name is required.
|
||||
// parse json
|
||||
val jsonObject: JSONObject = try {
|
||||
JSONObject(String(json))
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to parse json from repo")
|
||||
return null
|
||||
}
|
||||
// get name
|
||||
val name: String = try {
|
||||
jsonObject.getString("name")
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to get name from json")
|
||||
return null
|
||||
}
|
||||
// get website
|
||||
val website: String? = try {
|
||||
jsonObject.getString("website")
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
// get support
|
||||
val support: String? = try {
|
||||
jsonObject.getString("support")
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
// get donate
|
||||
val donate: String? = try {
|
||||
jsonObject.getString("donate")
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
// get submitModule
|
||||
val submitModule: String? = try {
|
||||
jsonObject.getString("submitModule")
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
val id = "repo_" + hashSha256(repo.toByteArray(StandardCharsets.UTF_8))
|
||||
val realmConfiguration = RealmConfiguration.Builder().name("ReposList.realm").encryptionKey(
|
||||
INSTANCE!!.key
|
||||
).allowQueriesOnUiThread(true).allowWritesOnUiThread(true).directory(
|
||||
INSTANCE!!.getDataDirWithPath("realms")
|
||||
).schemaVersion(1).build()
|
||||
val realm = Realm.getInstance(realmConfiguration)
|
||||
realm.executeTransaction { realm1: Realm ->
|
||||
// find the matching entry for repo_0, repo_1, etc.
|
||||
var reposList =
|
||||
realm1.where(ReposList::class.java).equalTo("id", id).findFirst()
|
||||
if (reposList == null) {
|
||||
reposList = realm1.createObject(ReposList::class.java, id)
|
||||
}
|
||||
reposList!!.url = repo
|
||||
reposList.name = name
|
||||
reposList.website = website
|
||||
reposList.support = support
|
||||
reposList.donate = donate
|
||||
reposList.submitModule = submitModule
|
||||
reposList.isEnabled = true
|
||||
// save the object
|
||||
realm1.copyToRealmOrUpdate(reposList)
|
||||
}
|
||||
repoCount++
|
||||
dirty = true
|
||||
val customRepoData = repoManager.addOrGet(repo) as CustomRepoData
|
||||
customRepoData.override = "repo_$id"
|
||||
customRepoData.preferenceId = id
|
||||
customRepoData.website = website
|
||||
customRepoData.support = support
|
||||
customRepoData.donate = donate
|
||||
customRepoData.submitModule = submitModule
|
||||
customRepoData.name = name
|
||||
// Set the enabled state to true
|
||||
customRepoData.isEnabled = true
|
||||
customRepoData.updateEnabledState()
|
||||
realm.close()
|
||||
return customRepoData
|
||||
}
|
||||
|
||||
fun getRepo(id: String?): CustomRepoData {
|
||||
return repoManager[id] as CustomRepoData
|
||||
}
|
||||
|
||||
@Suppress("SENSELESS_COMPARISON")
|
||||
fun removeRepo(index: Int) {
|
||||
val oldRepo = customRepos[index]
|
||||
if (oldRepo != null) {
|
||||
customRepos[index] = null
|
||||
repoCount--
|
||||
val customRepoData = repoManager[oldRepo] as CustomRepoData
|
||||
if (customRepoData != null) {
|
||||
customRepoData.isEnabled = false
|
||||
customRepoData.override = null
|
||||
}
|
||||
dirty = true
|
||||
}
|
||||
}
|
||||
|
||||
fun hasRepo(repo: String): Boolean {
|
||||
for (repoEntry in customRepos) {
|
||||
if (repo == repoEntry) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun canAddRepo(): Boolean {
|
||||
return repoCount < MAX_CUSTOM_REPOS
|
||||
}
|
||||
|
||||
fun canAddRepo(repo: String): Boolean {
|
||||
return if (RepoManager.isBuiltInRepo(repo) || hasRepo(repo) || !this.canAddRepo()) false else repo.startsWith(
|
||||
"https://"
|
||||
) && repo.indexOf('/', 9) != -1
|
||||
}
|
||||
|
||||
fun needUpdate(): Boolean {
|
||||
val needUpdate = dirty
|
||||
if (needUpdate) dirty = false
|
||||
return needUpdate
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val MAX_CUSTOM_REPOS = 5
|
||||
private const val AUTO_RECOMPILE = true
|
||||
}
|
||||
}
|
@ -1,429 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2023 to present Androidacy and contributors. Names, logos, icons, and the Androidacy name are all trademarks of Androidacy and may not be used without license. See LICENSE for more information.
|
||||
*/
|
||||
|
||||
package com.fox2code.mmm.repo;
|
||||
|
||||
import android.net.Uri;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.fox2code.mmm.AppUpdateManager;
|
||||
import com.fox2code.mmm.BuildConfig;
|
||||
import com.fox2code.mmm.MainActivity;
|
||||
import com.fox2code.mmm.MainApplication;
|
||||
import com.fox2code.mmm.R;
|
||||
import com.fox2code.mmm.XRepo;
|
||||
import com.fox2code.mmm.manager.ModuleInfo;
|
||||
import com.fox2code.mmm.utils.io.Files;
|
||||
import com.fox2code.mmm.utils.io.PropUtils;
|
||||
import com.fox2code.mmm.utils.realm.ModuleListCache;
|
||||
import com.fox2code.mmm.utils.realm.ReposList;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import io.realm.Realm;
|
||||
import io.realm.RealmConfiguration;
|
||||
import io.realm.RealmResults;
|
||||
import timber.log.Timber;
|
||||
|
||||
public class RepoData extends XRepo {
|
||||
public final JSONObject supportedProperties = new JSONObject();
|
||||
private final Object populateLock = new Object();
|
||||
|
||||
public String url;
|
||||
|
||||
public String id;
|
||||
public File cacheRoot;
|
||||
public HashMap<String, RepoModule> moduleHashMap;
|
||||
public JSONObject metaDataCache;
|
||||
public long lastUpdate;
|
||||
public String name, website, support, donate, submitModule;
|
||||
protected String defaultName, defaultWebsite, defaultSupport, defaultDonate, defaultSubmitModule;
|
||||
|
||||
// array with module info default values
|
||||
// supported properties for a module
|
||||
//id=<string>
|
||||
//name=<string>
|
||||
//version=<string>
|
||||
//versionCode=<int>
|
||||
//author=<string>
|
||||
//description=<string>
|
||||
//minApi=<int>
|
||||
//maxApi=<int>
|
||||
//minMagisk=<int>
|
||||
//needRamdisk=<boolean>
|
||||
//support=<url>
|
||||
//donate=<url>
|
||||
//config=<package>
|
||||
//changeBoot=<boolean>
|
||||
//mmtReborn=<boolean>
|
||||
// extra properties only useful for the database
|
||||
//repoId=<string>
|
||||
//installed=<boolean>
|
||||
//installedVersionCode=<int> (only if installed)
|
||||
private boolean forceHide, enabled; // Cache for speed
|
||||
|
||||
public RepoData(String url, File cacheRoot) {
|
||||
// setup supportedProperties
|
||||
try {
|
||||
supportedProperties.put("id", "");
|
||||
supportedProperties.put("name", "");
|
||||
supportedProperties.put("version", "");
|
||||
supportedProperties.put("versionCode", "");
|
||||
supportedProperties.put("author", "");
|
||||
supportedProperties.put("description", "");
|
||||
supportedProperties.put("minApi", "");
|
||||
supportedProperties.put("maxApi", "");
|
||||
supportedProperties.put("minMagisk", "");
|
||||
supportedProperties.put("needRamdisk", "");
|
||||
supportedProperties.put("support", "");
|
||||
supportedProperties.put("donate", "");
|
||||
supportedProperties.put("config", "");
|
||||
supportedProperties.put("changeBoot", "");
|
||||
supportedProperties.put("mmtReborn", "");
|
||||
supportedProperties.put("repoId", "");
|
||||
supportedProperties.put("installed", "");
|
||||
supportedProperties.put("installedVersionCode", "");
|
||||
supportedProperties.put("safe", "");
|
||||
} catch (JSONException e) {
|
||||
Timber.e(e, "Error while setting up supportedProperties");
|
||||
}
|
||||
this.url = url;
|
||||
this.id = RepoManager.internalIdOfUrl(url);
|
||||
this.cacheRoot = cacheRoot;
|
||||
// metadata cache is a realm database from ModuleListCache
|
||||
this.metaDataCache = null;
|
||||
this.moduleHashMap = new HashMap<>();
|
||||
this.defaultName = url; // Set url as default name
|
||||
this.forceHide = AppUpdateManager.Companion.shouldForceHide(getPreferenceId());
|
||||
// this.enable is set from the database
|
||||
RealmConfiguration realmConfiguration = new RealmConfiguration.Builder().name("ReposList.realm").encryptionKey(MainApplication.getINSTANCE().getKey()).allowQueriesOnUiThread(true).allowWritesOnUiThread(true).directory(MainApplication.getINSTANCE().getDataDirWithPath("realms")).schemaVersion(1).build();
|
||||
Realm realm = Realm.getInstance(realmConfiguration);
|
||||
ReposList reposList = realm.where(ReposList.class).equalTo("id", this.id).findFirst();
|
||||
if (reposList == null) {
|
||||
Timber.d("RepoData for %s not found in database", this.id);
|
||||
// log every repo in db
|
||||
Object[] fullList = realm.where(ReposList.class).findAll().toArray();
|
||||
Timber.d("RepoData: " + this.id + ". repos in database: " + fullList.length);
|
||||
for (Object repo : fullList) {
|
||||
ReposList r = (ReposList) repo;
|
||||
Timber.d("RepoData: " + this.id + ". repo: " + r.getId() + " " + r.getName() + " " + r.getWebsite() + " " + r.getSupport() + " " + r.getDonate() + " " + r.getSubmitModule() + " " + r.isEnabled());
|
||||
}
|
||||
} else {
|
||||
Timber.d("RepoData for %s found in database", this.id);
|
||||
}
|
||||
Timber.d("RepoData: " + this.id + ". record in database: " + (reposList != null ? reposList.toString() : "none"));
|
||||
this.enabled = (!this.forceHide && reposList != null && reposList.isEnabled());
|
||||
this.defaultWebsite = "https://" + Uri.parse(url).getHost() + "/";
|
||||
// open realm database
|
||||
// load metadata from realm database
|
||||
if (this.enabled) {
|
||||
try {
|
||||
this.metaDataCache = ModuleListCache.getRepoModulesAsJson(this.id);
|
||||
// log count of modules in the database
|
||||
Timber.d("RepoData: " + this.id + ". modules in database: " + this.metaDataCache.length());
|
||||
// load repo metadata from ReposList unless it's a built-in repo
|
||||
if (RepoManager.isBuiltInRepo(this.id)) {
|
||||
this.name = this.defaultName;
|
||||
this.website = this.defaultWebsite;
|
||||
this.support = this.defaultSupport;
|
||||
this.donate = this.defaultDonate;
|
||||
this.submitModule = this.defaultSubmitModule;
|
||||
} else {
|
||||
// get everything from ReposList realm database
|
||||
this.name = Objects.requireNonNull(realm.where(ReposList.class).equalTo("id", this.id).findFirst()).getName();
|
||||
this.website = Objects.requireNonNull(realm.where(ReposList.class).equalTo("id", this.id).findFirst()).getWebsite();
|
||||
this.support = Objects.requireNonNull(realm.where(ReposList.class).equalTo("id", this.id).findFirst()).getSupport();
|
||||
this.donate = Objects.requireNonNull(realm.where(ReposList.class).equalTo("id", this.id).findFirst()).getDonate();
|
||||
this.submitModule = Objects.requireNonNull(realm.where(ReposList.class).equalTo("id", this.id).findFirst()).getSubmitModule();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Timber.w("Failed to load repo metadata from database: " + e.getMessage() + ". If this is a first time run, this is normal.");
|
||||
}
|
||||
}
|
||||
realm.close();
|
||||
}
|
||||
|
||||
private static boolean isNonNull(String str) {
|
||||
return str != null && !str.isEmpty() && !"null".equals(str);
|
||||
}
|
||||
|
||||
protected boolean prepare() {
|
||||
return true;
|
||||
}
|
||||
|
||||
protected List<RepoModule> populate(JSONObject jsonObject) throws JSONException {
|
||||
List<RepoModule> newModules = new ArrayList<>();
|
||||
synchronized (this.populateLock) {
|
||||
String name = jsonObject.getString("name").trim();
|
||||
// if Official is present, remove it, or (Official), or [Official]. We don't want to show it in the UI
|
||||
String nameForModules = name.endsWith(" (Official)") ? name.substring(0, name.length() - 11) : name;
|
||||
nameForModules = nameForModules.endsWith(" [Official]") ? nameForModules.substring(0, nameForModules.length() - 11) : nameForModules;
|
||||
nameForModules = nameForModules.contains("Official") ? nameForModules.replace("Official", "").trim() : nameForModules;
|
||||
long lastUpdate = jsonObject.getLong("last_update");
|
||||
for (RepoModule repoModule : this.moduleHashMap.values()) {
|
||||
repoModule.processed = false;
|
||||
}
|
||||
JSONArray array = jsonObject.getJSONArray("modules");
|
||||
int len = array.length();
|
||||
for (int i = 0; i < len; i++) {
|
||||
JSONObject module = array.getJSONObject(i);
|
||||
String moduleId = module.getString("id");
|
||||
// module IDs must match the regex ^[a-zA-Z][a-zA-Z0-9._-]+$ and cannot be empty or null or equal ak3-helper
|
||||
if (moduleId.isEmpty() || moduleId.equals("ak3-helper") || !moduleId.matches("^[a-zA-Z][a-zA-Z0-9._-]+$")) {
|
||||
continue;
|
||||
}
|
||||
// If module id start with a dot, warn user
|
||||
if (moduleId.charAt(0) == '.') {
|
||||
Timber.w("This is not recommended and may indicate an attempt to hide the module");
|
||||
}
|
||||
long moduleLastUpdate = module.getLong("last_update");
|
||||
String moduleNotesUrl = module.getString("notes_url");
|
||||
String modulePropsUrl = module.getString("prop_url");
|
||||
String moduleZipUrl = module.getString("zip_url");
|
||||
String moduleChecksum = module.optString("checksum");
|
||||
String moduleStars = module.optString("stars");
|
||||
String moduleDownloads = module.optString("downloads");
|
||||
// if downloads is mull or empty, try to get it from the stats field
|
||||
if (moduleDownloads.isEmpty() && module.has("stats")) {
|
||||
moduleDownloads = module.optString("stats");
|
||||
}
|
||||
RepoModule repoModule = this.moduleHashMap.get(moduleId);
|
||||
if (repoModule == null) {
|
||||
repoModule = new RepoModule(this, moduleId);
|
||||
this.moduleHashMap.put(moduleId, repoModule);
|
||||
newModules.add(repoModule);
|
||||
} else {
|
||||
if (repoModule.lastUpdated < moduleLastUpdate || repoModule.moduleInfo.hasFlag(ModuleInfo.FLAG_METADATA_INVALID)) {
|
||||
newModules.add(repoModule);
|
||||
}
|
||||
}
|
||||
repoModule.processed = true;
|
||||
repoModule.repoName = nameForModules;
|
||||
repoModule.lastUpdated = moduleLastUpdate;
|
||||
repoModule.notesUrl = moduleNotesUrl;
|
||||
repoModule.propUrl = modulePropsUrl;
|
||||
repoModule.zipUrl = moduleZipUrl;
|
||||
repoModule.checksum = moduleChecksum;
|
||||
// safety check must be overridden per repo. only androidacy repo has this flag currently
|
||||
// repoModule.safe = module.optBoolean("safe", false);
|
||||
if (!moduleStars.isEmpty()) {
|
||||
try {
|
||||
repoModule.qualityValue = Integer.parseInt(moduleStars);
|
||||
repoModule.qualityText = R.string.module_stars;
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
} else if (!moduleDownloads.isEmpty()) {
|
||||
try {
|
||||
repoModule.qualityValue = Integer.parseInt(moduleDownloads);
|
||||
repoModule.qualityText = R.string.module_downloads;
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Remove no longer existing modules
|
||||
Iterator<RepoModule> moduleInfoIterator = this.moduleHashMap.values().iterator();
|
||||
while (moduleInfoIterator.hasNext()) {
|
||||
RepoModule repoModule = moduleInfoIterator.next();
|
||||
if (!repoModule.processed) {
|
||||
boolean delete = new File(this.cacheRoot, repoModule.id + ".prop").delete();
|
||||
if (!delete) {
|
||||
throw new RuntimeException("Failed to delete module metadata");
|
||||
}
|
||||
moduleInfoIterator.remove();
|
||||
} else {
|
||||
repoModule.moduleInfo.verify();
|
||||
}
|
||||
}
|
||||
// Update final metadata
|
||||
this.name = name;
|
||||
this.lastUpdate = lastUpdate;
|
||||
this.website = jsonObject.optString("website");
|
||||
this.support = jsonObject.optString("support");
|
||||
this.donate = jsonObject.optString("donate");
|
||||
this.submitModule = jsonObject.optString("submitModule");
|
||||
}
|
||||
return newModules;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEnabledByDefault() {
|
||||
return BuildConfig.ENABLED_REPOS.contains(this.id);
|
||||
}
|
||||
|
||||
public void storeMetadata(RepoModule repoModule, byte[] data) throws IOException {
|
||||
Files.write(new File(this.cacheRoot, repoModule.id + ".prop"), data);
|
||||
}
|
||||
|
||||
public boolean tryLoadMetadata(RepoModule repoModule) {
|
||||
File file = new File(this.cacheRoot, repoModule.id + ".prop");
|
||||
if (file.exists()) {
|
||||
try {
|
||||
ModuleInfo moduleInfo = repoModule.moduleInfo;
|
||||
PropUtils.Companion.readProperties(moduleInfo, file.getAbsolutePath(), repoModule.repoName + "/" + moduleInfo.name, false);
|
||||
moduleInfo.flags &= ~ModuleInfo.FLAG_METADATA_INVALID;
|
||||
if (moduleInfo.version == null) {
|
||||
moduleInfo.version = "v" + moduleInfo.versionCode;
|
||||
}
|
||||
return true;
|
||||
} catch (Exception ignored) {
|
||||
boolean delete = file.delete();
|
||||
if (!delete) {
|
||||
throw new RuntimeException("Failed to delete invalid metadata file");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Timber.d("Metadata file not found for %s", repoModule.id);
|
||||
}
|
||||
repoModule.moduleInfo.flags |= ModuleInfo.FLAG_METADATA_INVALID;
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEnabled() {
|
||||
RealmConfiguration realmConfiguration2 = new RealmConfiguration.Builder().name("ReposList.realm").encryptionKey(MainApplication.getINSTANCE().getKey()).allowQueriesOnUiThread(true).allowWritesOnUiThread(true).directory(MainApplication.getINSTANCE().getDataDirWithPath("realms")).schemaVersion(1).build();
|
||||
Realm realm2 = Realm.getInstance(realmConfiguration2);
|
||||
AtomicBoolean dbEnabled = new AtomicBoolean(false);
|
||||
realm2.executeTransaction(realm -> {
|
||||
ReposList reposList = realm.where(ReposList.class).equalTo("id", this.id).findFirst();
|
||||
if (reposList != null) {
|
||||
dbEnabled.set(reposList.isEnabled());
|
||||
} else {
|
||||
// should never happen but for safety
|
||||
dbEnabled.set(false);
|
||||
}
|
||||
});
|
||||
realm2.close();
|
||||
if (dbEnabled.get()) {
|
||||
return !this.forceHide;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled && !this.forceHide;
|
||||
// reposlist realm
|
||||
RealmConfiguration realmConfiguration2 = new RealmConfiguration.Builder().name("ReposList.realm").encryptionKey(MainApplication.getINSTANCE().getKey()).allowQueriesOnUiThread(true).allowWritesOnUiThread(true).directory(MainApplication.getINSTANCE().getDataDirWithPath("realms")).schemaVersion(1).build();
|
||||
Realm realm2 = Realm.getInstance(realmConfiguration2);
|
||||
realm2.executeTransaction(realm -> {
|
||||
ReposList reposList = realm.where(ReposList.class).equalTo("id", this.id).findFirst();
|
||||
if (reposList != null) {
|
||||
reposList.setEnabled(enabled);
|
||||
}
|
||||
});
|
||||
realm2.close();
|
||||
}
|
||||
|
||||
public void updateEnabledState() {
|
||||
// Make sure first_launch preference is set to false
|
||||
if (MainActivity.doSetupNowRunning) {
|
||||
return;
|
||||
}
|
||||
if (this.id == null) {
|
||||
Timber.e("Repo ID is null");
|
||||
return;
|
||||
}
|
||||
// if repo starts with repo_, it's always enabled bc custom repos can't be disabled without being deleted.
|
||||
this.forceHide = AppUpdateManager.Companion.shouldForceHide(getPreferenceId());
|
||||
// reposlist realm
|
||||
RealmConfiguration realmConfiguration2 = new RealmConfiguration.Builder().name("ReposList.realm").encryptionKey(MainApplication.getINSTANCE().getKey()).allowQueriesOnUiThread(true).allowWritesOnUiThread(true).directory(MainApplication.getINSTANCE().getDataDirWithPath("realms")).schemaVersion(1).build();
|
||||
Realm realm2 = Realm.getInstance(realmConfiguration2);
|
||||
boolean dbEnabled = false;
|
||||
try {
|
||||
dbEnabled = Objects.requireNonNull(realm2.where(ReposList.class).equalTo("id", this.id).findFirst()).isEnabled();
|
||||
} catch (Exception e) {
|
||||
Timber.e(e, "Error while updating enabled state for repo %s", this.id);
|
||||
}
|
||||
realm2.close();
|
||||
this.enabled = (!this.forceHide) && dbEnabled;
|
||||
}
|
||||
|
||||
public String getUrl() {
|
||||
return this.url;
|
||||
}
|
||||
|
||||
public String getPreferenceId() {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
// Repo data info getters
|
||||
@NonNull
|
||||
@Override
|
||||
public String getName() {
|
||||
if (isNonNull(this.name)) return this.name;
|
||||
if (this.defaultName != null) return this.defaultName;
|
||||
return this.url;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public String getWebsite() {
|
||||
if (isNonNull(this.website)) return this.website;
|
||||
if (this.defaultWebsite != null) return this.defaultWebsite;
|
||||
return this.url;
|
||||
}
|
||||
|
||||
public String getSupport() {
|
||||
if (isNonNull(this.support)) return this.support;
|
||||
return this.defaultSupport;
|
||||
}
|
||||
|
||||
public String getDonate() {
|
||||
if (isNonNull(this.donate)) return this.donate;
|
||||
return this.defaultDonate;
|
||||
}
|
||||
|
||||
public String getSubmitModule() {
|
||||
if (isNonNull(this.submitModule)) return this.submitModule;
|
||||
return this.defaultSubmitModule;
|
||||
}
|
||||
|
||||
public final boolean isForceHide() {
|
||||
return this.forceHide;
|
||||
}
|
||||
|
||||
// should update (lastUpdate > 15 minutes)
|
||||
public boolean shouldUpdate() {
|
||||
Timber.d("Repo " + this.id + " should update check called");
|
||||
RealmConfiguration realmConfiguration2 = new RealmConfiguration.Builder().name("ReposList.realm").encryptionKey(MainApplication.getINSTANCE().getKey()).allowQueriesOnUiThread(true).allowWritesOnUiThread(true).directory(MainApplication.getINSTANCE().getDataDirWithPath("realms")).schemaVersion(1).build();
|
||||
Realm realm2 = Realm.getInstance(realmConfiguration2);
|
||||
ReposList repo = realm2.where(ReposList.class).equalTo("id", this.id).findFirst();
|
||||
// Make sure ModuleListCache for repoId is not null
|
||||
File cacheRoot = MainApplication.getINSTANCE().getDataDirWithPath("realms/repos/" + this.id);
|
||||
RealmConfiguration realmConfiguration = new RealmConfiguration.Builder().name("ModuleListCache.realm").encryptionKey(MainApplication.getINSTANCE().getKey()).schemaVersion(1).deleteRealmIfMigrationNeeded().allowWritesOnUiThread(true).allowQueriesOnUiThread(true).directory(cacheRoot).build();
|
||||
Realm realm = Realm.getInstance(realmConfiguration);
|
||||
RealmResults<ModuleListCache> moduleListCache = realm.where(ModuleListCache.class).equalTo("repoId", this.id).findAll();
|
||||
if (repo != null) {
|
||||
if (repo.getLastUpdate() != 0 && moduleListCache.size() != 0) {
|
||||
long lastUpdate = repo.getLastUpdate();
|
||||
long currentTime = System.currentTimeMillis();
|
||||
long diff = currentTime - lastUpdate;
|
||||
long diffMinutes = diff / (60 * 1000) % 60;
|
||||
Timber.d("Repo " + this.id + " updated: " + diffMinutes + " minutes ago");
|
||||
realm.close();
|
||||
return diffMinutes > (BuildConfig.DEBUG ? 15 : 30);
|
||||
} else {
|
||||
Timber.d("Repo " + this.id + " should update could not find repo in database");
|
||||
Timber.d("This is probably an error, please report this to the developer");
|
||||
realm.close();
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
realm.close();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
@ -0,0 +1,501 @@
|
||||
/*
|
||||
* Copyright (c) 2023 to present Androidacy and contributors. Names, logos, icons, and the Androidacy name are all trademarks of Androidacy and may not be used without license. See LICENSE for more information.
|
||||
*/
|
||||
package com.fox2code.mmm.repo
|
||||
|
||||
import android.net.Uri
|
||||
import com.fox2code.mmm.AppUpdateManager.Companion.shouldForceHide
|
||||
import com.fox2code.mmm.BuildConfig
|
||||
import com.fox2code.mmm.MainActivity
|
||||
import com.fox2code.mmm.MainApplication.Companion.INSTANCE
|
||||
import com.fox2code.mmm.R
|
||||
import com.fox2code.mmm.XRepo
|
||||
import com.fox2code.mmm.manager.ModuleInfo
|
||||
import com.fox2code.mmm.utils.io.Files.Companion.write
|
||||
import com.fox2code.mmm.utils.io.PropUtils.Companion.readProperties
|
||||
import com.fox2code.mmm.utils.realm.ModuleListCache
|
||||
import com.fox2code.mmm.utils.realm.ReposList
|
||||
import io.realm.Realm
|
||||
import io.realm.RealmConfiguration
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
@Suppress("LeakingThis", "SENSELESS_COMPARISON", "RedundantSetter")
|
||||
open class RepoData(url: String, cacheRoot: File) : XRepo() {
|
||||
private val supportedProperties = JSONObject()
|
||||
private val populateLock = Any()
|
||||
@JvmField
|
||||
var url: String
|
||||
@JvmField
|
||||
var preferenceId: String? = null
|
||||
@JvmField
|
||||
var cacheRoot: File
|
||||
@JvmField
|
||||
var moduleHashMap: HashMap<String, RepoModule>
|
||||
private var metaDataCache: JSONObject?
|
||||
@JvmField
|
||||
var lastUpdate
|
||||
: Long = 0
|
||||
@JvmField
|
||||
var website: String? = null
|
||||
@JvmField
|
||||
var support: String? = null
|
||||
@JvmField
|
||||
var donate: String? = null
|
||||
@JvmField
|
||||
var submitModule: String? = null
|
||||
@JvmField
|
||||
var defaultName: String
|
||||
@JvmField
|
||||
var defaultWebsite: String
|
||||
@JvmField
|
||||
protected var defaultSupport: String? = null
|
||||
@JvmField
|
||||
protected var defaultDonate: String? = null
|
||||
@JvmField
|
||||
var defaultSubmitModule: String? = null
|
||||
|
||||
override var name: String? = null
|
||||
get() = {
|
||||
// if name is null return defaultName and if defaultName is null return url
|
||||
if (field == null) {
|
||||
if (defaultName == null) {
|
||||
url
|
||||
} else {
|
||||
defaultName
|
||||
}
|
||||
} else {
|
||||
field
|
||||
}
|
||||
}.toString()
|
||||
set(value) {
|
||||
field = value
|
||||
}
|
||||
|
||||
// array with module info default values
|
||||
// supported properties for a module
|
||||
//id=<string>
|
||||
//name=<string>
|
||||
//version=<string>
|
||||
//versionCode=<int>
|
||||
//author=<string>
|
||||
//description=<string>
|
||||
//minApi=<int>
|
||||
//maxApi=<int>
|
||||
//minMagisk=<int>
|
||||
//needRamdisk=<boolean>
|
||||
//support=<url>
|
||||
//donate=<url>
|
||||
//config=<package>
|
||||
//changeBoot=<boolean>
|
||||
//mmtReborn=<boolean>
|
||||
// extra properties only useful for the database
|
||||
//repoId=<string>
|
||||
//installed=<boolean>
|
||||
//installedVersionCode=<int> (only if installed)
|
||||
var isForceHide: Boolean
|
||||
private set
|
||||
private var enabled // Cache for speed
|
||||
: Boolean
|
||||
|
||||
init {
|
||||
// setup supportedProperties
|
||||
try {
|
||||
supportedProperties.put("id", "")
|
||||
supportedProperties.put("name", "")
|
||||
supportedProperties.put("version", "")
|
||||
supportedProperties.put("versionCode", "")
|
||||
supportedProperties.put("author", "")
|
||||
supportedProperties.put("description", "")
|
||||
supportedProperties.put("minApi", "")
|
||||
supportedProperties.put("maxApi", "")
|
||||
supportedProperties.put("minMagisk", "")
|
||||
supportedProperties.put("needRamdisk", "")
|
||||
supportedProperties.put("support", "")
|
||||
supportedProperties.put("donate", "")
|
||||
supportedProperties.put("config", "")
|
||||
supportedProperties.put("changeBoot", "")
|
||||
supportedProperties.put("mmtReborn", "")
|
||||
supportedProperties.put("repoId", "")
|
||||
supportedProperties.put("installed", "")
|
||||
supportedProperties.put("installedVersionCode", "")
|
||||
supportedProperties.put("safe", "")
|
||||
} catch (e: JSONException) {
|
||||
Timber.e(e, "Error while setting up supportedProperties")
|
||||
}
|
||||
this.url = url
|
||||
preferenceId = RepoManager.internalIdOfUrl(url)
|
||||
this.cacheRoot = cacheRoot
|
||||
// metadata cache is a realm database from ModuleListCache
|
||||
metaDataCache = null
|
||||
moduleHashMap = HashMap()
|
||||
defaultName = url // Set url as default name
|
||||
val tempVarForPreferenceId = preferenceId!!
|
||||
isForceHide = shouldForceHide(tempVarForPreferenceId)
|
||||
// this.enable is set from the database
|
||||
val realmConfiguration = RealmConfiguration.Builder().name("ReposList.realm").encryptionKey(
|
||||
INSTANCE!!.key
|
||||
).allowQueriesOnUiThread(true).allowWritesOnUiThread(true).directory(
|
||||
INSTANCE!!.getDataDirWithPath("realms")
|
||||
).schemaVersion(1).build()
|
||||
val realm = Realm.getInstance(realmConfiguration)
|
||||
val reposList = realm.where(ReposList::class.java).equalTo("id", preferenceId).findFirst()
|
||||
if (reposList == null) {
|
||||
Timber.d("RepoData for %s not found in database", preferenceId)
|
||||
// log every repo in db
|
||||
val fullList: Array<Any> = realm.where(ReposList::class.java).findAll().toTypedArray()
|
||||
Timber.d("RepoData: " + preferenceId + ". repos in database: " + fullList.size)
|
||||
for (repo in fullList) {
|
||||
val r = repo as ReposList
|
||||
Timber.d("RepoData: " + preferenceId + ". repo: " + r.id + " " + r.name + " " + r.website + " " + r.support + " " + r.donate + " " + r.submitModule + " " + r.isEnabled)
|
||||
}
|
||||
} else {
|
||||
Timber.d("RepoData for %s found in database", preferenceId)
|
||||
}
|
||||
Timber.d(
|
||||
"RepoData: $preferenceId. record in database: " + (reposList?.toString()
|
||||
?: "none")
|
||||
)
|
||||
enabled = !isForceHide && reposList != null && reposList.isEnabled
|
||||
defaultWebsite = "https://" + Uri.parse(url).host + "/"
|
||||
// open realm database
|
||||
// load metadata from realm database
|
||||
if (enabled) {
|
||||
try {
|
||||
metaDataCache = ModuleListCache.getRepoModulesAsJson(preferenceId)
|
||||
// log count of modules in the database
|
||||
val tempMetaDataCacheVar = metaDataCache!!
|
||||
Timber.d("RepoData: $preferenceId. modules in database: ${tempMetaDataCacheVar.length()}")
|
||||
// load repo metadata from ReposList unless it's a built-in repo
|
||||
if (RepoManager.isBuiltInRepo(preferenceId)) {
|
||||
name = defaultName
|
||||
website = defaultWebsite
|
||||
support = defaultSupport
|
||||
donate = defaultDonate
|
||||
submitModule = defaultSubmitModule
|
||||
} else {
|
||||
// get everything from ReposList realm database
|
||||
name = realm.where(
|
||||
ReposList::class.java
|
||||
).equalTo("id", preferenceId).findFirst()?.name
|
||||
website = realm.where(
|
||||
ReposList::class.java
|
||||
).equalTo("id", preferenceId).findFirst()?.website
|
||||
support = realm.where(
|
||||
ReposList::class.java
|
||||
).equalTo("id", preferenceId).findFirst()?.support
|
||||
donate = realm.where(
|
||||
ReposList::class.java
|
||||
).equalTo("id", preferenceId).findFirst()?.donate
|
||||
submitModule = realm.where(
|
||||
ReposList::class.java
|
||||
).equalTo("id", preferenceId).findFirst()?.submitModule
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.w("Failed to load repo metadata from database: " + e.message + ". If this is a first time run, this is normal.")
|
||||
}
|
||||
}
|
||||
realm.close()
|
||||
}
|
||||
|
||||
open fun prepare(): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
@Throws(JSONException::class)
|
||||
open fun populate(jsonObject: JSONObject): List<RepoModule>? {
|
||||
val newModules: MutableList<RepoModule> = ArrayList()
|
||||
synchronized(populateLock) {
|
||||
val name = jsonObject.getString("name").trim { it <= ' ' }
|
||||
// if Official is present, remove it, or (Official), or [Official]. We don't want to show it in the UI
|
||||
var nameForModules =
|
||||
if (name.endsWith(" (Official)")) name.substring(0, name.length - 11) else name
|
||||
nameForModules = if (nameForModules.endsWith(" [Official]")) nameForModules.substring(
|
||||
0,
|
||||
nameForModules.length - 11
|
||||
) else nameForModules
|
||||
nameForModules =
|
||||
if (nameForModules.contains("Official")) nameForModules.replace("Official", "")
|
||||
.trim { it <= ' ' } else nameForModules
|
||||
val lastUpdate = jsonObject.getLong("last_update")
|
||||
for (repoModule in moduleHashMap.values) {
|
||||
repoModule.processed = false
|
||||
}
|
||||
val array = jsonObject.getJSONArray("modules")
|
||||
val len = array.length()
|
||||
for (i in 0 until len) {
|
||||
val module = array.getJSONObject(i)
|
||||
val moduleId = module.getString("id")
|
||||
// module IDs must match the regex ^[a-zA-Z][a-zA-Z0-9._-]+$ and cannot be empty or null or equal ak3-helper
|
||||
if (moduleId.isEmpty() || moduleId == "ak3-helper" || !moduleId.matches(Regex("^[a-zA-Z][a-zA-Z0-9._-]+$"))) {
|
||||
continue
|
||||
}
|
||||
// If module id start with a dot, warn user
|
||||
if (moduleId[0] == '.') {
|
||||
Timber.w("This is not recommended and may indicate an attempt to hide the module")
|
||||
}
|
||||
val moduleLastUpdate = module.getLong("last_update")
|
||||
val moduleNotesUrl = module.getString("notes_url")
|
||||
val modulePropsUrl = module.getString("prop_url")
|
||||
val moduleZipUrl = module.getString("zip_url")
|
||||
val moduleChecksum = module.optString("checksum")
|
||||
val moduleStars = module.optString("stars")
|
||||
var moduleDownloads = module.optString("downloads")
|
||||
// if downloads is mull or empty, try to get it from the stats field
|
||||
if (moduleDownloads.isEmpty() && module.has("stats")) {
|
||||
moduleDownloads = module.optString("stats")
|
||||
}
|
||||
var repoModule = moduleHashMap[moduleId]
|
||||
if (repoModule == null) {
|
||||
repoModule = RepoModule(this, moduleId)
|
||||
moduleHashMap[moduleId] = repoModule
|
||||
newModules.add(repoModule)
|
||||
} else {
|
||||
if (repoModule.lastUpdated < moduleLastUpdate || repoModule.moduleInfo.hasFlag(
|
||||
ModuleInfo.FLAG_METADATA_INVALID
|
||||
)
|
||||
) {
|
||||
newModules.add(repoModule)
|
||||
}
|
||||
}
|
||||
repoModule.processed = true
|
||||
repoModule.repoName = nameForModules
|
||||
repoModule.lastUpdated = moduleLastUpdate
|
||||
repoModule.notesUrl = moduleNotesUrl
|
||||
repoModule.propUrl = modulePropsUrl
|
||||
repoModule.zipUrl = moduleZipUrl
|
||||
repoModule.checksum = moduleChecksum
|
||||
// safety check must be overridden per repo. only androidacy repo has this flag currently
|
||||
// repoModule.safe = module.optBoolean("safe", false);
|
||||
if (moduleStars.isNotEmpty()) {
|
||||
try {
|
||||
repoModule.qualityValue = moduleStars.toInt()
|
||||
repoModule.qualityText = R.string.module_stars
|
||||
} catch (ignored: NumberFormatException) {
|
||||
}
|
||||
} else if (moduleDownloads.isNotEmpty()) {
|
||||
try {
|
||||
repoModule.qualityValue = moduleDownloads.toInt()
|
||||
repoModule.qualityText = R.string.module_downloads
|
||||
} catch (ignored: NumberFormatException) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Remove no longer existing modules
|
||||
val moduleInfoIterator = moduleHashMap.values.iterator()
|
||||
while (moduleInfoIterator.hasNext()) {
|
||||
val repoModule = moduleInfoIterator.next()
|
||||
if (!repoModule.processed) {
|
||||
val delete = File(cacheRoot, repoModule.id + ".prop").delete()
|
||||
if (!delete) {
|
||||
throw RuntimeException("Failed to delete module metadata")
|
||||
}
|
||||
moduleInfoIterator.remove()
|
||||
} else {
|
||||
repoModule.moduleInfo.verify()
|
||||
}
|
||||
}
|
||||
// Update final metadata
|
||||
this.name = name
|
||||
this.lastUpdate = lastUpdate
|
||||
website = jsonObject.optString("website")
|
||||
support = jsonObject.optString("support")
|
||||
donate = jsonObject.optString("donate")
|
||||
submitModule = jsonObject.optString("submitModule")
|
||||
}
|
||||
return newModules
|
||||
}
|
||||
|
||||
override val isEnabledByDefault: Boolean
|
||||
get() = BuildConfig.ENABLED_REPOS.contains(preferenceId)
|
||||
override var isEnabled: Boolean = false
|
||||
get() = if (field) {
|
||||
field
|
||||
} else {
|
||||
val realmConfiguration2 =
|
||||
RealmConfiguration.Builder().name("ReposList.realm").encryptionKey(
|
||||
INSTANCE!!.key
|
||||
).allowQueriesOnUiThread(true).allowWritesOnUiThread(true).directory(
|
||||
INSTANCE!!.getDataDirWithPath("realms")
|
||||
).schemaVersion(1).build()
|
||||
val realm2 = Realm.getInstance(realmConfiguration2)
|
||||
val dbEnabled = AtomicBoolean(false)
|
||||
realm2.executeTransaction { realm: Realm ->
|
||||
val reposList =
|
||||
realm.where(ReposList::class.java).equalTo("id", preferenceId).findFirst()
|
||||
if (reposList != null) {
|
||||
dbEnabled.set(reposList.isEnabled)
|
||||
} else {
|
||||
// should never happen but for safety
|
||||
dbEnabled.set(false)
|
||||
}
|
||||
}
|
||||
realm2.close()
|
||||
// should never happen but for safety
|
||||
if (dbEnabled.get()) {
|
||||
!isForceHide
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
set(value) {
|
||||
field = value
|
||||
this.enabled = enabled && !isForceHide
|
||||
// reposlist realm
|
||||
val realmConfiguration2 =
|
||||
RealmConfiguration.Builder().name("ReposList.realm").encryptionKey(
|
||||
INSTANCE!!.key
|
||||
).allowQueriesOnUiThread(true).allowWritesOnUiThread(true).directory(
|
||||
INSTANCE!!.getDataDirWithPath("realms")
|
||||
).schemaVersion(1).build()
|
||||
val realm2 = Realm.getInstance(realmConfiguration2)
|
||||
realm2.executeTransaction { realm: Realm ->
|
||||
val reposList =
|
||||
realm.where(ReposList::class.java).equalTo("id", preferenceId).findFirst()
|
||||
if (reposList != null) {
|
||||
reposList.isEnabled = enabled
|
||||
}
|
||||
}
|
||||
realm2.close()
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
open fun storeMetadata(repoModule: RepoModule, data: ByteArray?) {
|
||||
write(File(cacheRoot, repoModule.id + ".prop"), data)
|
||||
}
|
||||
|
||||
open fun tryLoadMetadata(repoModule: RepoModule): Boolean {
|
||||
val file = File(cacheRoot, repoModule.id + ".prop")
|
||||
if (file.exists()) {
|
||||
try {
|
||||
val moduleInfo = repoModule.moduleInfo
|
||||
readProperties(
|
||||
moduleInfo,
|
||||
file.absolutePath,
|
||||
repoModule.repoName + "/" + moduleInfo.name,
|
||||
false
|
||||
)
|
||||
moduleInfo.flags = moduleInfo.flags and ModuleInfo.FLAG_METADATA_INVALID.inv()
|
||||
if (moduleInfo.version == null) {
|
||||
moduleInfo.version = "v" + moduleInfo.versionCode
|
||||
}
|
||||
return true
|
||||
} catch (ignored: Exception) {
|
||||
val delete = file.delete()
|
||||
if (!delete) {
|
||||
throw RuntimeException("Failed to delete invalid metadata file")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Timber.d("Metadata file not found for %s", repoModule.id)
|
||||
}
|
||||
repoModule.moduleInfo.flags =
|
||||
repoModule.moduleInfo.flags or ModuleInfo.FLAG_METADATA_INVALID
|
||||
return false
|
||||
}
|
||||
|
||||
fun updateEnabledState() {
|
||||
// Make sure first_launch preference is set to false
|
||||
if (MainActivity.doSetupNowRunning) {
|
||||
return
|
||||
}
|
||||
if (preferenceId == null) {
|
||||
Timber.e("Repo ID is null")
|
||||
return
|
||||
}
|
||||
// if repo starts with repo_, it's always enabled bc custom repos can't be disabled without being deleted.
|
||||
isForceHide = shouldForceHide(preferenceId!!)
|
||||
// reposlist realm
|
||||
val realmConfiguration2 =
|
||||
RealmConfiguration.Builder().name("ReposList.realm").encryptionKey(
|
||||
INSTANCE!!.key
|
||||
).allowQueriesOnUiThread(true).allowWritesOnUiThread(true).directory(
|
||||
INSTANCE!!.getDataDirWithPath("realms")
|
||||
).schemaVersion(1).build()
|
||||
val realm2 = Realm.getInstance(realmConfiguration2)
|
||||
var dbEnabled = false
|
||||
try {
|
||||
dbEnabled = realm2.where(
|
||||
ReposList::class.java
|
||||
).equalTo("id", preferenceId).findFirst()?.isEnabled == true
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Error while updating enabled state for repo %s", preferenceId)
|
||||
}
|
||||
realm2.close()
|
||||
enabled = !isForceHide && dbEnabled
|
||||
}
|
||||
|
||||
open fun getUrl(): String? {
|
||||
return url
|
||||
}
|
||||
|
||||
fun getWebsite(): String {
|
||||
if (isNonNull(website)) return website!!
|
||||
return if (defaultWebsite != null) defaultWebsite else url
|
||||
}
|
||||
|
||||
fun getSupport(): String? {
|
||||
return if (isNonNull(support)) support else defaultSupport
|
||||
}
|
||||
|
||||
fun getDonate(): String? {
|
||||
return if (isNonNull(donate)) donate else defaultDonate
|
||||
}
|
||||
|
||||
fun getSubmitModule(): String? {
|
||||
return if (isNonNull(submitModule)) submitModule else defaultSubmitModule
|
||||
}
|
||||
|
||||
// should update (lastUpdate > 15 minutes)
|
||||
fun shouldUpdate(): Boolean {
|
||||
Timber.d("Repo $preferenceId should update check called")
|
||||
val realmConfiguration2 =
|
||||
RealmConfiguration.Builder().name("ReposList.realm").encryptionKey(
|
||||
INSTANCE!!.key
|
||||
).allowQueriesOnUiThread(true).allowWritesOnUiThread(true).directory(
|
||||
INSTANCE!!.getDataDirWithPath("realms")
|
||||
).schemaVersion(1).build()
|
||||
val realm2 = Realm.getInstance(realmConfiguration2)
|
||||
val repo = realm2.where(ReposList::class.java).equalTo("id", preferenceId).findFirst()
|
||||
// Make sure ModuleListCache for repoId is not null
|
||||
val cacheRoot = INSTANCE!!.getDataDirWithPath("realms/repos/$preferenceId")
|
||||
val realmConfiguration =
|
||||
RealmConfiguration.Builder().name("ModuleListCache.realm").encryptionKey(
|
||||
INSTANCE!!.key
|
||||
).schemaVersion(1).deleteRealmIfMigrationNeeded().allowWritesOnUiThread(true)
|
||||
.allowQueriesOnUiThread(true).directory(cacheRoot).build()
|
||||
val realm = Realm.getInstance(realmConfiguration)
|
||||
val moduleListCache = realm.where(
|
||||
ModuleListCache::class.java
|
||||
).equalTo("repoId", preferenceId).findAll()
|
||||
if (repo != null) {
|
||||
return if (repo.lastUpdate != 0 && moduleListCache.size != 0) {
|
||||
val lastUpdate = repo.lastUpdate.toLong()
|
||||
val currentTime = System.currentTimeMillis()
|
||||
val diff = currentTime - lastUpdate
|
||||
val diffMinutes = diff / (60 * 1000) % 60
|
||||
Timber.d("Repo $preferenceId updated: $diffMinutes minutes ago")
|
||||
realm.close()
|
||||
diffMinutes > if (BuildConfig.DEBUG) 15 else 30
|
||||
} else {
|
||||
Timber.d("Repo $preferenceId should update could not find repo in database")
|
||||
Timber.d("This is probably an error, please report this to the developer")
|
||||
realm.close()
|
||||
true
|
||||
}
|
||||
} else {
|
||||
realm.close()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
companion object {
|
||||
private fun isNonNull(str: String?): Boolean {
|
||||
return !str.isNullOrEmpty() && "null" != str
|
||||
}
|
||||
}
|
||||
}
|
@ -1,401 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2023 to present Androidacy and contributors. Names, logos, icons, and the Androidacy name are all trademarks of Androidacy and may not be used without license. See LICENSE for more information.
|
||||
*/
|
||||
|
||||
package com.fox2code.mmm.repo;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.Activity;
|
||||
import android.content.SharedPreferences;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.fox2code.mmm.BuildConfig;
|
||||
import com.fox2code.mmm.MainActivity;
|
||||
import com.fox2code.mmm.MainApplication;
|
||||
import com.fox2code.mmm.R;
|
||||
import com.fox2code.mmm.XHooks;
|
||||
import com.fox2code.mmm.XRepo;
|
||||
import com.fox2code.mmm.androidacy.AndroidacyRepoData;
|
||||
import com.fox2code.mmm.manager.ModuleInfo;
|
||||
import com.fox2code.mmm.utils.SyncManager;
|
||||
import com.fox2code.mmm.utils.io.Files;
|
||||
import com.fox2code.mmm.utils.io.Hashes;
|
||||
import com.fox2code.mmm.utils.io.PropUtils;
|
||||
import com.fox2code.mmm.utils.io.net.Http;
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
|
||||
import timber.log.Timber;
|
||||
|
||||
public final class RepoManager extends SyncManager {
|
||||
public static final String MAGISK_REPO = "https://raw.githubusercontent.com/Magisk-Modules-Repo/submission/modules/modules.json";
|
||||
public static final String MAGISK_REPO_HOMEPAGE = "https://github.com/Magisk-Modules-Repo";
|
||||
public static final String MAGISK_ALT_REPO = "https://raw.githubusercontent.com/Magisk-Modules-Alt-Repo/json/main/modules.json";
|
||||
public static final String MAGISK_ALT_REPO_HOMEPAGE = "https://github.com/Magisk-Modules-Alt-Repo";
|
||||
public static final String MAGISK_ALT_REPO_JSDELIVR = "https://cdn.jsdelivr.net/gh/Magisk-Modules-Alt-Repo/json@main/modules.json";
|
||||
public static final String ANDROIDACY_MAGISK_REPO_ENDPOINT = "https://production-api.androidacy.com/magisk/repo";
|
||||
public static final String ANDROIDACY_TEST_MAGISK_REPO_ENDPOINT = "https://staging-api.androidacy.com/magisk/repo";
|
||||
public static final String ANDROIDACY_MAGISK_REPO_HOMEPAGE = "https://www.androidacy.com/modules-repo";
|
||||
private static final String MAGISK_REPO_MANAGER = "https://magisk-modules-repo.github.io/submission/modules.json";
|
||||
private static final Object lock = new Object();
|
||||
private static final double STEP1 = 0.1D;
|
||||
private static final double STEP2 = 0.8D;
|
||||
private static final double STEP3 = 0.1D;
|
||||
private static volatile RepoManager INSTANCE;
|
||||
private final MainApplication mainApplication;
|
||||
private final LinkedHashMap<String, RepoData> repoData;
|
||||
private final HashMap<String, RepoModule> modules;
|
||||
public String repoLastErrorName = null;
|
||||
private AndroidacyRepoData androidacyRepoData;
|
||||
private CustomRepoManager customRepoManager;
|
||||
private boolean initialized;
|
||||
private boolean repoLastSuccess;
|
||||
|
||||
private RepoManager(MainApplication mainApplication) {
|
||||
INSTANCE = this; // Set early fox XHooks
|
||||
this.initialized = false;
|
||||
this.mainApplication = mainApplication;
|
||||
this.repoData = new LinkedHashMap<>();
|
||||
this.modules = new HashMap<>();
|
||||
// refuse to load if setup is not complete
|
||||
if (MainApplication.getSharedPreferences("mmm").getString("last_shown_setup", "").equals("")) {
|
||||
return;
|
||||
}
|
||||
// We do not have repo list config yet.
|
||||
this.androidacyRepoData = this.addAndroidacyRepoData();
|
||||
RepoData altRepo = this.addRepoData(MAGISK_ALT_REPO, "Magisk Modules Alt Repo");
|
||||
altRepo.defaultWebsite = RepoManager.MAGISK_ALT_REPO_HOMEPAGE;
|
||||
altRepo.defaultSubmitModule = "https://github.com/Magisk-Modules-Alt-Repo/submission/issues";
|
||||
this.customRepoManager = new CustomRepoManager(mainApplication, this);
|
||||
XHooks.onRepoManagerInitialize();
|
||||
// Populate default cache
|
||||
boolean x = false;
|
||||
for (RepoData repoData : this.repoData.values()) {
|
||||
if (repoData == this.androidacyRepoData) {
|
||||
if (x) return;
|
||||
x = true;
|
||||
}
|
||||
this.populateDefaultCache(repoData);
|
||||
}
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
public static RepoManager getINSTANCE() {
|
||||
if (INSTANCE == null || !INSTANCE.initialized) {
|
||||
synchronized (lock) {
|
||||
if (INSTANCE == null) {
|
||||
MainApplication mainApplication = MainApplication.getINSTANCE();
|
||||
if (mainApplication != null) {
|
||||
INSTANCE = new RepoManager(mainApplication);
|
||||
XHooks.onRepoManagerInitialized();
|
||||
} else {
|
||||
throw new RuntimeException("Getting RepoManager too soon!");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
public static RepoManager getINSTANCE_UNSAFE() {
|
||||
if (INSTANCE == null) {
|
||||
synchronized (lock) {
|
||||
if (INSTANCE == null) {
|
||||
MainApplication mainApplication = MainApplication.getINSTANCE();
|
||||
if (mainApplication != null) {
|
||||
INSTANCE = new RepoManager(mainApplication);
|
||||
XHooks.onRepoManagerInitialized();
|
||||
} else {
|
||||
throw new RuntimeException("Getting RepoManager too soon!");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
public static String internalIdOfUrl(String url) {
|
||||
return switch (url) {
|
||||
case MAGISK_ALT_REPO, MAGISK_ALT_REPO_JSDELIVR -> "magisk_alt_repo";
|
||||
case ANDROIDACY_MAGISK_REPO_ENDPOINT, ANDROIDACY_TEST_MAGISK_REPO_ENDPOINT ->
|
||||
"androidacy_repo";
|
||||
default -> "repo_" + Hashes.hashSha256(url.getBytes(StandardCharsets.UTF_8));
|
||||
};
|
||||
}
|
||||
|
||||
static boolean isBuiltInRepo(String repo) {
|
||||
return switch (repo) {
|
||||
case RepoManager.ANDROIDACY_MAGISK_REPO_ENDPOINT, RepoManager.ANDROIDACY_TEST_MAGISK_REPO_ENDPOINT, RepoManager.MAGISK_ALT_REPO, RepoManager.MAGISK_ALT_REPO_JSDELIVR ->
|
||||
true;
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe way to do {@code RepoManager.getInstance().androidacyRepoData.isEnabled()}
|
||||
* without initializing RepoManager
|
||||
*/
|
||||
@SuppressWarnings("BooleanMethodIsAlwaysInverted")
|
||||
public static boolean isAndroidacyRepoEnabled() {
|
||||
return INSTANCE != null && INSTANCE.androidacyRepoData != null && INSTANCE.androidacyRepoData.isEnabled();
|
||||
}
|
||||
|
||||
@SuppressWarnings("StatementWithEmptyBody")
|
||||
private void populateDefaultCache(RepoData repoData) {
|
||||
// if last_shown_setup is not "v2", them=n refuse to continue
|
||||
if (!MainApplication.getSharedPreferences("mmm").getString("last_shown_setup", "").equals("v2")) {
|
||||
return;
|
||||
}
|
||||
// make sure repodata is not null
|
||||
if (repoData == null || repoData.moduleHashMap == null) {
|
||||
return;
|
||||
}
|
||||
for (RepoModule repoModule : repoData.moduleHashMap.values()) {
|
||||
if (!repoModule.moduleInfo.hasFlag(ModuleInfo.FLAG_METADATA_INVALID)) {
|
||||
RepoModule registeredRepoModule = this.modules.get(repoModule.id);
|
||||
if (registeredRepoModule == null) {
|
||||
this.modules.put(repoModule.id, repoModule);
|
||||
} else if (AndroidacyRepoData.getInstance().isEnabled() && registeredRepoModule.repoData == this.androidacyRepoData) {
|
||||
// empty
|
||||
} else if (AndroidacyRepoData.getInstance().isEnabled() && repoModule.repoData == this.androidacyRepoData) {
|
||||
this.modules.put(repoModule.id, repoModule);
|
||||
} else if (repoModule.moduleInfo.versionCode > registeredRepoModule.moduleInfo.versionCode) {
|
||||
this.modules.put(repoModule.id, repoModule);
|
||||
}
|
||||
} else {
|
||||
Timber.e("Detected module with invalid metadata: " + repoModule.repoName + "/" + repoModule.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public RepoData get(String url) {
|
||||
if (url == null) return null;
|
||||
if (MAGISK_ALT_REPO_JSDELIVR.equals(url)) {
|
||||
url = MAGISK_ALT_REPO;
|
||||
}
|
||||
return this.repoData.get(url);
|
||||
}
|
||||
|
||||
public RepoData addOrGet(String url) {
|
||||
return this.addOrGet(url, null);
|
||||
}
|
||||
|
||||
public RepoData addOrGet(String url, String fallBackName) {
|
||||
if (MAGISK_ALT_REPO_JSDELIVR.equals(url)) url = MAGISK_ALT_REPO;
|
||||
RepoData repoData;
|
||||
synchronized (this.syncLock) {
|
||||
repoData = this.repoData.get(url);
|
||||
if (repoData == null) {
|
||||
if (ANDROIDACY_TEST_MAGISK_REPO_ENDPOINT.equals(url) || ANDROIDACY_MAGISK_REPO_ENDPOINT.equals(url)) {
|
||||
//noinspection ReplaceNullCheck
|
||||
if (this.androidacyRepoData != null) {
|
||||
return this.androidacyRepoData;
|
||||
} else {
|
||||
return this.addAndroidacyRepoData();
|
||||
}
|
||||
} else {
|
||||
return this.addRepoData(url, fallBackName);
|
||||
}
|
||||
}
|
||||
}
|
||||
return repoData;
|
||||
}
|
||||
|
||||
@SuppressWarnings("StatementWithEmptyBody")
|
||||
@SuppressLint("StringFormatInvalid")
|
||||
protected void scanInternal(@NonNull UpdateListener updateListener) {
|
||||
// Refuse to start if first_launch is not false in shared preferences
|
||||
if (MainActivity.doSetupNowRunning) {
|
||||
return;
|
||||
}
|
||||
this.modules.clear();
|
||||
updateListener.update(0D);
|
||||
// Using LinkedHashSet to deduplicate Androidacy entry.
|
||||
RepoData[] repoDatas = new LinkedHashSet<>(this.repoData.values()).toArray(new RepoData[0]);
|
||||
RepoUpdater[] repoUpdaters = new RepoUpdater[repoDatas.length];
|
||||
int moduleToUpdate = 0;
|
||||
if (!this.hasConnectivity()) {
|
||||
updateListener.update(STEP3);
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < repoDatas.length; i++) {
|
||||
if (BuildConfig.DEBUG) Timber.d("Preparing to fetch: %s", repoDatas[i].getName());
|
||||
moduleToUpdate += (repoUpdaters[i] = new RepoUpdater(repoDatas[i])).fetchIndex();
|
||||
updateListener.update(STEP1 / repoDatas.length * (i + 1));
|
||||
}
|
||||
if (BuildConfig.DEBUG) Timber.d("Updating meta-data");
|
||||
int updatedModules = 0;
|
||||
boolean allowLowQualityModules = MainApplication.Companion.isDisableLowQualityModuleFilter();
|
||||
for (int i = 0; i < repoUpdaters.length; i++) {
|
||||
// Check if the repo is enabled
|
||||
if (!repoUpdaters[i].repoData.isEnabled()) {
|
||||
if (BuildConfig.DEBUG)
|
||||
Timber.d("Skipping disabled repo: %s", repoUpdaters[i].repoData.getName());
|
||||
continue;
|
||||
}
|
||||
List<RepoModule> repoModules = repoUpdaters[i].toUpdate();
|
||||
RepoData repoData = repoDatas[i];
|
||||
if (BuildConfig.DEBUG) Timber.d("Registering %s", repoData.getName());
|
||||
for (RepoModule repoModule : repoModules) {
|
||||
try {
|
||||
if (repoModule.propUrl != null && !repoModule.propUrl.isEmpty()) {
|
||||
repoData.storeMetadata(repoModule, Http.doHttpGet(repoModule.propUrl, false));
|
||||
Files.write(new File(repoData.cacheRoot, repoModule.id + ".prop"), Http.doHttpGet(repoModule.propUrl, false));
|
||||
}
|
||||
if (repoData.tryLoadMetadata(repoModule) && (allowLowQualityModules || !PropUtils.isLowQualityModule(repoModule.moduleInfo))) {
|
||||
// Note: registeredRepoModule may not be null if registered by multiple repos
|
||||
RepoModule registeredRepoModule = this.modules.get(repoModule.id);
|
||||
if (registeredRepoModule == null) {
|
||||
this.modules.put(repoModule.id, repoModule);
|
||||
} else if (AndroidacyRepoData.getInstance().isEnabled() && registeredRepoModule.repoData == this.androidacyRepoData) {
|
||||
// empty
|
||||
} else if (AndroidacyRepoData.getInstance().isEnabled() && repoModule.repoData == this.androidacyRepoData) {
|
||||
this.modules.put(repoModule.id, repoModule);
|
||||
} else if (repoModule.moduleInfo.versionCode > registeredRepoModule.moduleInfo.versionCode) {
|
||||
this.modules.put(repoModule.id, repoModule);
|
||||
}
|
||||
} else {
|
||||
repoModule.moduleInfo.flags |= ModuleInfo.FLAG_METADATA_INVALID;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Timber.e(e);
|
||||
}
|
||||
updatedModules++;
|
||||
updateListener.update(STEP1 + (STEP2 / (moduleToUpdate != 0 ? moduleToUpdate : 1) * updatedModules));
|
||||
}
|
||||
for (RepoModule repoModule : repoUpdaters[i].toApply()) {
|
||||
if ((repoModule.moduleInfo.flags & ModuleInfo.FLAG_METADATA_INVALID) == 0) {
|
||||
RepoModule registeredRepoModule = this.modules.get(repoModule.id);
|
||||
if (registeredRepoModule == null) {
|
||||
this.modules.put(repoModule.id, repoModule);
|
||||
} else if (AndroidacyRepoData.getInstance().isEnabled() && registeredRepoModule.repoData == this.androidacyRepoData) {
|
||||
// empty
|
||||
} else if (AndroidacyRepoData.getInstance().isEnabled() && repoModule.repoData == this.androidacyRepoData) {
|
||||
this.modules.put(repoModule.id, repoModule);
|
||||
} else if (repoModule.moduleInfo.versionCode > registeredRepoModule.moduleInfo.versionCode) {
|
||||
this.modules.put(repoModule.id, repoModule);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (BuildConfig.DEBUG) Timber.d("Finishing update");
|
||||
if (hasConnectivity()) {
|
||||
for (int i = 0; i < repoDatas.length; i++) {
|
||||
// If repo is not enabled, skip
|
||||
if (!repoDatas[i].isEnabled()) {
|
||||
if (BuildConfig.DEBUG)
|
||||
Timber.d("Skipping " + repoDatas[i].getName() + " because it's disabled");
|
||||
continue;
|
||||
}
|
||||
if (BuildConfig.DEBUG)
|
||||
Timber.d("Finishing: %s", repoUpdaters[i].repoData.getName());
|
||||
this.repoLastSuccess = repoUpdaters[i].finish();
|
||||
if (!this.repoLastSuccess) {
|
||||
Timber.e("Failed to update %s", repoUpdaters[i].repoData.getName());
|
||||
// Show snackbar on main looper and add some bottom padding
|
||||
int finalI = i;
|
||||
Activity context = MainApplication.getINSTANCE().getLastCompatActivity();
|
||||
new Handler(Looper.getMainLooper()).post(() -> {
|
||||
if (context != null) {
|
||||
// Show material dialogue with the repo name. for androidacy repo, show an option to reset the api key. show a message then a list of errors
|
||||
MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(context);
|
||||
builder.setTitle(R.string.repo_update_failed);
|
||||
builder.setMessage(context.getString(R.string.repo_update_failed_message, "- " + repoUpdaters[finalI].repoData.getName()));
|
||||
builder.setPositiveButton(android.R.string.ok, null);
|
||||
if (repoUpdaters[finalI].repoData instanceof AndroidacyRepoData) {
|
||||
builder.setNeutralButton(R.string.reset_api_key, (dialog, which) -> {
|
||||
SharedPreferences.Editor editor = MainApplication.getSharedPreferences("androidacy").edit();
|
||||
editor.putString("androidacy_api_key", "");
|
||||
editor.apply();
|
||||
Toast.makeText(context, R.string.api_key_removed, Toast.LENGTH_SHORT).show();
|
||||
});
|
||||
}
|
||||
builder.show();
|
||||
}
|
||||
});
|
||||
this.repoLastErrorName = repoUpdaters[i].repoData.getName();
|
||||
}
|
||||
updateListener.update(STEP1 + STEP2 + (STEP3 / repoDatas.length * (i + 1)));
|
||||
}
|
||||
}
|
||||
Timber.i("Got " + this.modules.size() + " modules!");
|
||||
updateListener.update(1D);
|
||||
}
|
||||
|
||||
public void updateEnabledStates() {
|
||||
for (RepoData repoData : this.repoData.values()) {
|
||||
boolean wasEnabled = repoData.isEnabled();
|
||||
repoData.updateEnabledState();
|
||||
if (!wasEnabled && repoData.isEnabled()) {
|
||||
this.customRepoManager.dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public HashMap<String, RepoModule> getModules() {
|
||||
this.afterUpdate();
|
||||
return this.modules;
|
||||
}
|
||||
|
||||
public boolean hasConnectivity() {
|
||||
return Http.hasConnectivity(MainApplication.getINSTANCE().getApplicationContext());
|
||||
}
|
||||
|
||||
private RepoData addRepoData(String url, String fallBackName) {
|
||||
String id = internalIdOfUrl(url);
|
||||
File cacheRoot = new File(this.mainApplication.getDataDir(), "repos/" + id);
|
||||
RepoData repoData = id.startsWith("repo_") ? new CustomRepoData(url, cacheRoot) : new RepoData(url, cacheRoot);
|
||||
if (fallBackName != null && !fallBackName.isEmpty()) {
|
||||
repoData.defaultName = fallBackName;
|
||||
if (repoData instanceof CustomRepoData) {
|
||||
((CustomRepoData) repoData).loadedExternal = true;
|
||||
this.customRepoManager.dirty = true;
|
||||
repoData.updateEnabledState();
|
||||
}
|
||||
}
|
||||
switch (url) {
|
||||
case MAGISK_REPO, MAGISK_REPO_MANAGER -> repoData.defaultWebsite = MAGISK_REPO_HOMEPAGE;
|
||||
}
|
||||
this.repoData.put(url, repoData);
|
||||
if (this.initialized) {
|
||||
this.populateDefaultCache(repoData);
|
||||
}
|
||||
return repoData;
|
||||
}
|
||||
|
||||
private AndroidacyRepoData addAndroidacyRepoData() {
|
||||
// cache dir is actually under app data
|
||||
File cacheRoot = this.mainApplication.getDataDirWithPath("realms/repos/androidacy_repo");
|
||||
AndroidacyRepoData repoData = new AndroidacyRepoData(cacheRoot, MainApplication.Companion.isAndroidacyTestMode());
|
||||
this.repoData.put(ANDROIDACY_MAGISK_REPO_ENDPOINT, repoData);
|
||||
this.repoData.put(ANDROIDACY_TEST_MAGISK_REPO_ENDPOINT, repoData);
|
||||
return repoData;
|
||||
}
|
||||
|
||||
public AndroidacyRepoData getAndroidacyRepoData() {
|
||||
return this.androidacyRepoData;
|
||||
}
|
||||
|
||||
public CustomRepoManager getCustomRepoManager() {
|
||||
return customRepoManager;
|
||||
}
|
||||
|
||||
public Collection<XRepo> getXRepos() {
|
||||
return new LinkedHashSet<>(this.repoData.values());
|
||||
}
|
||||
|
||||
public boolean isLastUpdateSuccess() {
|
||||
return this.repoLastSuccess;
|
||||
}
|
||||
}
|
@ -0,0 +1,411 @@
|
||||
/*
|
||||
* Copyright (c) 2023 to present Androidacy and contributors. Names, logos, icons, and the Androidacy name are all trademarks of Androidacy and may not be used without license. See LICENSE for more information.
|
||||
*/
|
||||
package com.fox2code.mmm.repo
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.DialogInterface
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.widget.Toast
|
||||
import com.fox2code.mmm.BuildConfig
|
||||
import com.fox2code.mmm.MainActivity
|
||||
import com.fox2code.mmm.MainApplication
|
||||
import com.fox2code.mmm.MainApplication.Companion.getSharedPreferences
|
||||
import com.fox2code.mmm.MainApplication.Companion.isAndroidacyTestMode
|
||||
import com.fox2code.mmm.MainApplication.Companion.isDisableLowQualityModuleFilter
|
||||
import com.fox2code.mmm.R
|
||||
import com.fox2code.mmm.XHooks.Companion.onRepoManagerInitialize
|
||||
import com.fox2code.mmm.XHooks.Companion.onRepoManagerInitialized
|
||||
import com.fox2code.mmm.XRepo
|
||||
import com.fox2code.mmm.androidacy.AndroidacyRepoData
|
||||
import com.fox2code.mmm.androidacy.AndroidacyRepoData.Companion.instance
|
||||
import com.fox2code.mmm.manager.ModuleInfo
|
||||
import com.fox2code.mmm.utils.SyncManager
|
||||
import com.fox2code.mmm.utils.io.Files.Companion.write
|
||||
import com.fox2code.mmm.utils.io.Hashes.Companion.hashSha256
|
||||
import com.fox2code.mmm.utils.io.PropUtils.Companion.isLowQualityModule
|
||||
import com.fox2code.mmm.utils.io.net.Http.Companion.doHttpGet
|
||||
import com.fox2code.mmm.utils.io.net.Http.Companion.hasConnectivity
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
@Suppress("NAME_SHADOWING", "unused")
|
||||
class RepoManager private constructor(mainApplication: MainApplication) : SyncManager() {
|
||||
private val mainApplication: MainApplication
|
||||
private val repoData: LinkedHashMap<String?, RepoData>
|
||||
val modules: HashMap<String, RepoModule>
|
||||
get() {
|
||||
afterUpdate()
|
||||
return field
|
||||
}
|
||||
private var repoLastErrorName: String? = null
|
||||
var androidacyRepoData: AndroidacyRepoData? = null
|
||||
var customRepoManager: CustomRepoManager? = null
|
||||
private var initialized: Boolean
|
||||
var isLastUpdateSuccess = false
|
||||
private set
|
||||
|
||||
init {
|
||||
INSTANCE = this // Set early fox XHooks
|
||||
initialized = false
|
||||
this.mainApplication = mainApplication
|
||||
repoData = LinkedHashMap()
|
||||
modules = HashMap()
|
||||
// refuse to load if setup is not complete
|
||||
if (getSharedPreferences("mmm")!!.getString("last_shown_setup", "") != "") {
|
||||
// We do not have repo list config yet.
|
||||
androidacyRepoData = addAndroidacyRepoData()
|
||||
val altRepo = addRepoData(MAGISK_ALT_REPO, "Magisk Modules Alt Repo")
|
||||
altRepo.defaultWebsite = MAGISK_ALT_REPO_HOMEPAGE
|
||||
altRepo.defaultSubmitModule =
|
||||
"https://github.com/Magisk-Modules-Alt-Repo/submission/issues"
|
||||
customRepoManager = CustomRepoManager(mainApplication, this)
|
||||
onRepoManagerInitialize()
|
||||
// Populate default cache
|
||||
var x = false
|
||||
for (repoData in repoData.values) {
|
||||
if (repoData === androidacyRepoData) {
|
||||
if (x) {
|
||||
Timber.e("Multiple Androidacy repo detected")
|
||||
} else {
|
||||
x = true
|
||||
}
|
||||
}
|
||||
populateDefaultCache(repoData)
|
||||
}
|
||||
initialized = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun populateDefaultCache(repoData: RepoData?) {
|
||||
// if last_shown_setup is not "v2", them=n refuse to continue
|
||||
if (getSharedPreferences("mmm")!!.getString("last_shown_setup", "") != "v2") {
|
||||
return
|
||||
}
|
||||
// make sure repodata is not null
|
||||
if (repoData?.moduleHashMap == null) {
|
||||
return
|
||||
}
|
||||
for (repoModule in repoData.moduleHashMap.values) {
|
||||
if (!repoModule.moduleInfo.hasFlag(ModuleInfo.FLAG_METADATA_INVALID)) {
|
||||
val registeredRepoModule = modules[repoModule.id]
|
||||
if (registeredRepoModule == null) {
|
||||
modules[repoModule.id] = repoModule
|
||||
} else if (instance.isEnabled && registeredRepoModule.repoData === androidacyRepoData) {
|
||||
// empty
|
||||
} else if (instance.isEnabled && repoModule.repoData === androidacyRepoData) {
|
||||
modules[repoModule.id] = repoModule
|
||||
} else if (repoModule.moduleInfo.versionCode > registeredRepoModule.moduleInfo.versionCode) {
|
||||
modules[repoModule.id] = repoModule
|
||||
}
|
||||
} else {
|
||||
Timber.e("Detected module with invalid metadata: " + repoModule.repoName + "/" + repoModule.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
operator fun get(url: String?): RepoData? {
|
||||
var url = url ?: return null
|
||||
if (MAGISK_ALT_REPO_JSDELIVR == url) {
|
||||
url = MAGISK_ALT_REPO
|
||||
}
|
||||
return repoData[url]
|
||||
}
|
||||
|
||||
@JvmOverloads
|
||||
fun addOrGet(url: String, fallBackName: String? = null): RepoData? {
|
||||
var url = url
|
||||
if (MAGISK_ALT_REPO_JSDELIVR == url) url = MAGISK_ALT_REPO
|
||||
var repoData: RepoData?
|
||||
synchronized(syncLock) {
|
||||
repoData = this.repoData[url]
|
||||
if (repoData == null) {
|
||||
return if (ANDROIDACY_TEST_MAGISK_REPO_ENDPOINT == url || ANDROIDACY_MAGISK_REPO_ENDPOINT == url) {
|
||||
androidacyRepoData ?: addAndroidacyRepoData()
|
||||
} else {
|
||||
addRepoData(url, fallBackName)
|
||||
}
|
||||
}
|
||||
}
|
||||
return repoData
|
||||
}
|
||||
|
||||
@SuppressLint("StringFormatInvalid")
|
||||
override fun scanInternal(updateListener: UpdateListener) {
|
||||
// Refuse to start if first_launch is not false in shared preferences
|
||||
if (MainActivity.doSetupNowRunning) {
|
||||
return
|
||||
}
|
||||
modules.clear()
|
||||
updateListener.update(0.0)
|
||||
// Using LinkedHashSet to deduplicate Androidacy entry.
|
||||
val repoDatas = LinkedHashSet(repoData.values).toTypedArray()
|
||||
val repoUpdaters = arrayOfNulls<RepoUpdater>(repoDatas.size)
|
||||
var moduleToUpdate = 0
|
||||
if (!this.hasConnectivity()) {
|
||||
updateListener.update(STEP3)
|
||||
return
|
||||
}
|
||||
for (i in repoDatas.indices) {
|
||||
if (BuildConfig.DEBUG) Timber.d("Preparing to fetch: %s", repoDatas[i].name)
|
||||
moduleToUpdate += RepoUpdater(repoDatas[i]).also { repoUpdaters[i] = it }.fetchIndex()
|
||||
updateListener.update(STEP1 / repoDatas.size * (i + 1))
|
||||
}
|
||||
if (BuildConfig.DEBUG) Timber.d("Updating meta-data")
|
||||
var updatedModules = 0
|
||||
val allowLowQualityModules = isDisableLowQualityModuleFilter
|
||||
for (i in repoUpdaters.indices) {
|
||||
// Check if the repo is enabled
|
||||
if (!repoUpdaters[i]!!.repoData.isEnabled) {
|
||||
if (BuildConfig.DEBUG) Timber.d(
|
||||
"Skipping disabled repo: %s",
|
||||
repoUpdaters[i]!!.repoData.name
|
||||
)
|
||||
continue
|
||||
}
|
||||
val repoModules = repoUpdaters[i]!!.toUpdate()
|
||||
val repoData = repoDatas[i]
|
||||
if (BuildConfig.DEBUG) Timber.d("Registering %s", repoData.name)
|
||||
for (repoModule in repoModules!!) {
|
||||
try {
|
||||
if (repoModule.propUrl != null && repoModule.propUrl!!.isNotEmpty()) {
|
||||
repoData.storeMetadata(
|
||||
repoModule, doHttpGet(
|
||||
repoModule.propUrl!!, false
|
||||
)
|
||||
)
|
||||
write(
|
||||
File(repoData.cacheRoot, repoModule.id + ".prop"), doHttpGet(
|
||||
repoModule.propUrl!!, false
|
||||
)
|
||||
)
|
||||
}
|
||||
if (repoData.tryLoadMetadata(repoModule) && (allowLowQualityModules || !isLowQualityModule(
|
||||
repoModule.moduleInfo
|
||||
))
|
||||
) {
|
||||
// Note: registeredRepoModule may not be null if registered by multiple repos
|
||||
val registeredRepoModule = modules[repoModule.id]
|
||||
if (registeredRepoModule == null) {
|
||||
modules[repoModule.id] = repoModule
|
||||
} else if (instance.isEnabled && registeredRepoModule.repoData === androidacyRepoData) {
|
||||
// empty
|
||||
} else if (instance.isEnabled && repoModule.repoData === androidacyRepoData) {
|
||||
modules[repoModule.id] = repoModule
|
||||
} else if (repoModule.moduleInfo.versionCode > registeredRepoModule.moduleInfo.versionCode) {
|
||||
modules[repoModule.id] = repoModule
|
||||
}
|
||||
} else {
|
||||
repoModule.moduleInfo.flags =
|
||||
repoModule.moduleInfo.flags or ModuleInfo.FLAG_METADATA_INVALID
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e)
|
||||
}
|
||||
updatedModules++
|
||||
updateListener.update(STEP1 + STEP2 / (if (moduleToUpdate != 0) moduleToUpdate else 1) * updatedModules)
|
||||
}
|
||||
for (repoModule in repoUpdaters[i]!!.toApply()!!) {
|
||||
if (repoModule.moduleInfo.flags and ModuleInfo.FLAG_METADATA_INVALID == 0) {
|
||||
val registeredRepoModule = modules[repoModule.id]
|
||||
if (registeredRepoModule == null) {
|
||||
modules[repoModule.id] = repoModule
|
||||
} else if (instance.isEnabled && registeredRepoModule.repoData === androidacyRepoData) {
|
||||
// empty
|
||||
} else if (instance.isEnabled && repoModule.repoData === androidacyRepoData) {
|
||||
modules[repoModule.id] = repoModule
|
||||
} else if (repoModule.moduleInfo.versionCode > registeredRepoModule.moduleInfo.versionCode) {
|
||||
modules[repoModule.id] = repoModule
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (BuildConfig.DEBUG) Timber.d("Finishing update")
|
||||
if (hasConnectivity()) {
|
||||
for (i in repoDatas.indices) {
|
||||
// If repo is not enabled, skip
|
||||
if (!repoDatas[i].isEnabled) {
|
||||
if (BuildConfig.DEBUG) Timber.d("Skipping " + repoDatas[i].name + " because it's disabled")
|
||||
continue
|
||||
}
|
||||
if (BuildConfig.DEBUG) Timber.d("Finishing: %s", repoUpdaters[i]!!.repoData.name)
|
||||
isLastUpdateSuccess = repoUpdaters[i]!!.finish()
|
||||
if (!isLastUpdateSuccess) {
|
||||
Timber.e("Failed to update %s", repoUpdaters[i]!!.repoData.name)
|
||||
// Show snackbar on main looper and add some bottom padding
|
||||
val context: Activity? = MainApplication.INSTANCE!!.lastCompatActivity
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
if (context != null) {
|
||||
// Show material dialogue with the repo name. for androidacy repo, show an option to reset the api key. show a message then a list of errors
|
||||
val builder = MaterialAlertDialogBuilder(context)
|
||||
builder.setTitle(R.string.repo_update_failed)
|
||||
builder.setMessage(
|
||||
context.getString(
|
||||
R.string.repo_update_failed_message,
|
||||
"- " + repoUpdaters[i]!!.repoData.name
|
||||
)
|
||||
)
|
||||
builder.setPositiveButton(android.R.string.ok, null)
|
||||
if (repoUpdaters[i]!!.repoData is AndroidacyRepoData) {
|
||||
builder.setNeutralButton(R.string.reset_api_key) { _: DialogInterface?, _: Int ->
|
||||
val editor = getSharedPreferences("androidacy")!!
|
||||
.edit()
|
||||
editor.putString("androidacy_api_key", "")
|
||||
editor.apply()
|
||||
Toast.makeText(
|
||||
context,
|
||||
R.string.api_key_removed,
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
builder.show()
|
||||
}
|
||||
}
|
||||
repoLastErrorName = repoUpdaters[i]!!.repoData.name
|
||||
}
|
||||
updateListener.update(STEP1 + STEP2 + STEP3 / repoDatas.size * (i + 1))
|
||||
}
|
||||
}
|
||||
Timber.i("Got " + modules.size + " modules!")
|
||||
updateListener.update(1.0)
|
||||
}
|
||||
|
||||
fun updateEnabledStates() {
|
||||
for (repoData in repoData.values) {
|
||||
val wasEnabled = repoData.isEnabled
|
||||
repoData.updateEnabledState()
|
||||
if (!wasEnabled && repoData.isEnabled) {
|
||||
customRepoManager!!.dirty = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun hasConnectivity(): Boolean {
|
||||
return hasConnectivity(MainApplication.INSTANCE!!.applicationContext)
|
||||
}
|
||||
|
||||
private fun addRepoData(url: String, fallBackName: String?): RepoData {
|
||||
val id = internalIdOfUrl(url)
|
||||
val cacheRoot = File(mainApplication.dataDir, "repos/$id")
|
||||
val repoData =
|
||||
if (id.startsWith("repo_")) CustomRepoData(url, cacheRoot) else RepoData(url, cacheRoot)
|
||||
if (!fallBackName.isNullOrEmpty()) {
|
||||
repoData.defaultName = fallBackName
|
||||
if (repoData is CustomRepoData) {
|
||||
repoData.loadedExternal = true
|
||||
customRepoManager!!.dirty = true
|
||||
repoData.updateEnabledState()
|
||||
}
|
||||
}
|
||||
when (url) {
|
||||
MAGISK_REPO, MAGISK_REPO_MANAGER -> repoData.defaultWebsite = MAGISK_REPO_HOMEPAGE
|
||||
}
|
||||
this.repoData[url] = repoData
|
||||
if (initialized) {
|
||||
populateDefaultCache(repoData)
|
||||
}
|
||||
return repoData
|
||||
}
|
||||
|
||||
private fun addAndroidacyRepoData(): AndroidacyRepoData {
|
||||
// cache dir is actually under app data
|
||||
val cacheRoot = mainApplication.getDataDirWithPath("realms/repos/androidacy_repo")
|
||||
val repoData = AndroidacyRepoData(cacheRoot, isAndroidacyTestMode)
|
||||
this.repoData[ANDROIDACY_MAGISK_REPO_ENDPOINT] =
|
||||
repoData
|
||||
this.repoData[ANDROIDACY_TEST_MAGISK_REPO_ENDPOINT] = repoData
|
||||
return repoData
|
||||
}
|
||||
|
||||
val xRepos: Collection<XRepo>
|
||||
get() = LinkedHashSet<XRepo>(repoData.values)
|
||||
|
||||
companion object {
|
||||
const val MAGISK_REPO =
|
||||
"https://raw.githubusercontent.com/Magisk-Modules-Repo/submission/modules/modules.json"
|
||||
const val MAGISK_REPO_HOMEPAGE = "https://github.com/Magisk-Modules-Repo"
|
||||
const val MAGISK_ALT_REPO =
|
||||
"https://raw.githubusercontent.com/Magisk-Modules-Alt-Repo/json/main/modules.json"
|
||||
const val MAGISK_ALT_REPO_HOMEPAGE = "https://github.com/Magisk-Modules-Alt-Repo"
|
||||
const val MAGISK_ALT_REPO_JSDELIVR =
|
||||
"https://cdn.jsdelivr.net/gh/Magisk-Modules-Alt-Repo/json@main/modules.json"
|
||||
const val ANDROIDACY_MAGISK_REPO_ENDPOINT =
|
||||
"https://production-api.androidacy.com/magisk/repo"
|
||||
const val ANDROIDACY_TEST_MAGISK_REPO_ENDPOINT =
|
||||
"https://staging-api.androidacy.com/magisk/repo"
|
||||
const val ANDROIDACY_MAGISK_REPO_HOMEPAGE = "https://www.androidacy.com/modules-repo"
|
||||
private const val MAGISK_REPO_MANAGER =
|
||||
"https://magisk-modules-repo.github.io/submission/modules.json"
|
||||
private val lock = Any()
|
||||
private const val STEP1 = 0.1
|
||||
private const val STEP2 = 0.8
|
||||
private const val STEP3 = 0.1
|
||||
|
||||
@Volatile
|
||||
private var INSTANCE: RepoManager? = null
|
||||
|
||||
@JvmStatic
|
||||
fun getINSTANCE(): RepoManager? {
|
||||
if (INSTANCE == null || !INSTANCE!!.initialized) {
|
||||
synchronized(lock) {
|
||||
if (INSTANCE == null) {
|
||||
val mainApplication = MainApplication.INSTANCE
|
||||
if (mainApplication != null) {
|
||||
INSTANCE = RepoManager(mainApplication)
|
||||
onRepoManagerInitialized()
|
||||
} else {
|
||||
throw RuntimeException("Getting RepoManager too soon!")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return INSTANCE
|
||||
}
|
||||
|
||||
val iNSTANCE_UNSAFE: RepoManager?
|
||||
get() {
|
||||
if (INSTANCE == null) {
|
||||
synchronized(lock) {
|
||||
if (INSTANCE == null) {
|
||||
val mainApplication = MainApplication.INSTANCE
|
||||
if (mainApplication != null) {
|
||||
INSTANCE = RepoManager(mainApplication)
|
||||
onRepoManagerInitialized()
|
||||
} else {
|
||||
throw RuntimeException("Getting RepoManager too soon!")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return INSTANCE
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun internalIdOfUrl(url: String): String {
|
||||
return when (url) {
|
||||
MAGISK_ALT_REPO, MAGISK_ALT_REPO_JSDELIVR -> "magisk_alt_repo"
|
||||
ANDROIDACY_MAGISK_REPO_ENDPOINT, ANDROIDACY_TEST_MAGISK_REPO_ENDPOINT -> "androidacy_repo"
|
||||
else -> "repo_" + hashSha256(url.toByteArray(StandardCharsets.UTF_8))
|
||||
}
|
||||
}
|
||||
|
||||
fun isBuiltInRepo(repo: String?): Boolean {
|
||||
return when (repo) {
|
||||
ANDROIDACY_MAGISK_REPO_ENDPOINT, ANDROIDACY_TEST_MAGISK_REPO_ENDPOINT, MAGISK_ALT_REPO, MAGISK_ALT_REPO_JSDELIVR -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe way to do `RepoManager.getINSTANCE()!!.androidacyRepoData.isEnabled()`
|
||||
* without initializing RepoManager
|
||||
*/
|
||||
val isAndroidacyRepoEnabled: Boolean
|
||||
get() = INSTANCE != null && INSTANCE!!.androidacyRepoData != null && INSTANCE!!.androidacyRepoData!!.isEnabled
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue