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 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= //name= //version= //versionCode= //author= //description= //minApi= //maxApi= //minMagisk= //needRamdisk= //support= //donate= //config= //changeBoot= //mmtReborn= // extra properties only useful for the database //repoId= //installed= //installedVersionCode= (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.shouldForceHide(this.id); // 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 populate(JSONObject jsonObject) throws JSONException { List 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 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() { 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.shouldForceHide(this.id); // 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 = 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; } }