|
|
|
package com.fox2code.mmm.repo;
|
|
|
|
|
|
|
|
import android.content.SharedPreferences;
|
|
|
|
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.security.NoSuchAlgorithmException;
|
|
|
|
import java.util.ArrayList;
|
|
|
|
import java.util.HashMap;
|
|
|
|
import java.util.Iterator;
|
|
|
|
import java.util.List;
|
|
|
|
import java.util.Objects;
|
|
|
|
|
|
|
|
import io.realm.Realm;
|
|
|
|
import io.realm.RealmConfiguration;
|
|
|
|
import io.realm.RealmResults;
|
|
|
|
import timber.log.Timber;
|
|
|
|
|
|
|
|
public class RepoData extends XRepo {
|
|
|
|
public final String url;
|
|
|
|
public final String id;
|
|
|
|
public final File cacheRoot;
|
|
|
|
public final SharedPreferences cachedPreferences;
|
|
|
|
public final HashMap<String, RepoModule> moduleHashMap;
|
|
|
|
public final JSONObject supportedProperties = new JSONObject();
|
|
|
|
private final Object populateLock = new Object();
|
|
|
|
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, SharedPreferences cachedPreferences) {
|
|
|
|
// 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;
|
|
|
|
this.cachedPreferences = cachedPreferences;
|
|
|
|
// 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.shouldForceHide(this.id);
|
|
|
|
// this.enable is set from the database
|
|
|
|
RealmConfiguration realmConfiguration = new RealmConfiguration.Builder().name("ReposList.realm").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 (BuildConfig.DEBUG) {
|
|
|
|
if (reposList == null) {
|
|
|
|
Timber.d("RepoData for %s not found in database", this.id);
|
|
|
|
} 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() throws NoSuchAlgorithmException {
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
|
|
|
protected List<RepoModule> populate(JSONObject jsonObject) throws JSONException, NoSuchAlgorithmException {
|
|
|
|
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 overriden 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.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() {
|
|
|
|
return this.enabled;
|
|
|
|
}
|
|
|
|
|
|
|
|
@Override
|
|
|
|
public void setEnabled(boolean enabled) {
|
|
|
|
this.enabled = enabled && !this.forceHide;
|
|
|
|
// reposlist realm
|
|
|
|
RealmConfiguration realmConfiguration2 = new RealmConfiguration.Builder().name("ReposList.realm").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;
|
|
|
|
}
|
|
|
|
this.forceHide = AppUpdateManager.shouldForceHide(this.id);
|
|
|
|
// reposlist realm
|
|
|
|
RealmConfiguration realmConfiguration2 = new RealmConfiguration.Builder().name("ReposList.realm").allowQueriesOnUiThread(true).allowWritesOnUiThread(true).directory(MainApplication.getINSTANCE().getDataDirWithPath("realms")).schemaVersion(1).build();
|
|
|
|
Realm realm2 = Realm.getInstance(realmConfiguration2);
|
|
|
|
boolean dbEnabled;
|
|
|
|
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);
|
|
|
|
// for now, throw an exception
|
|
|
|
throw e;
|
|
|
|
}
|
|
|
|
this.enabled = (!this.forceHide) && dbEnabled;
|
|
|
|
}
|
|
|
|
|
|
|
|
public String getUrl() throws NoSuchAlgorithmException {
|
|
|
|
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").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").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");
|
|
|
|
return diffMinutes > (BuildConfig.DEBUG ? 5 : 20);
|
|
|
|
} else {
|
|
|
|
Timber.d("Repo " + this.id + " should update could not find repo in database");
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
}
|