/* * Copyright (C) 2010-12 Ciaran Gultnieks, ciaran@ciarang.com * Copyright (C) 2009 Roberto Jacinto, roberto.jacinto@caixamagica.pt * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 3 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ package org.fdroid.fdroid; import android.os.Build; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import org.fdroid.fdroid.data.Apk; import org.fdroid.fdroid.data.App; import org.fdroid.fdroid.data.Repo; import org.fdroid.fdroid.data.RepoPushRequest; import org.fdroid.fdroid.data.Schema.ApkTable; import org.xml.sax.Attributes; import org.xml.sax.SAXException; import org.xml.sax.helpers.DefaultHandler; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.regex.Pattern; /** * Parses the index.xml into Java data structures. */ public class RepoXMLHandler extends DefaultHandler { // The repo we're processing. private final Repo repo; private List<Apk> apksList = new ArrayList<>(); private App curapp; private Apk curapk; private String currentApkHashType; // After processing the XML, these will be -1 if the index didn't specify // them - otherwise it will be the value specified. private int repoMaxAge = -1; private int repoVersion; private long repoTimestamp; private String repoDescription; private String repoName; /** * Set of requested permissions per package/APK */ private final HashSet<String> requestedPermissionsSet = new HashSet<>(); /** * the X.509 signing certificate stored in the header of index.xml */ private String repoSigningCert; private final StringBuilder curchars = new StringBuilder(); public interface IndexReceiver { void receiveRepo(String name, String description, String signingCert, int maxage, int version, long timestamp); void receiveApp(App app, List<Apk> packages); void receiveRepoPushRequest(RepoPushRequest repoPushRequest); } private final IndexReceiver receiver; public RepoXMLHandler(Repo repo, @NonNull IndexReceiver receiver) { this.repo = repo; this.receiver = receiver; } @Override public void characters(char[] ch, int start, int length) { curchars.append(ch, start, length); } @Override public void endElement(String uri, String localName, String qName) throws SAXException { if ("application".equals(localName) && curapp != null) { onApplicationParsed(); } else if ("package".equals(localName) && curapk != null && curapp != null) { int size = requestedPermissionsSet.size(); curapk.requestedPermissions = requestedPermissionsSet.toArray(new String[size]); requestedPermissionsSet.clear(); apksList.add(curapk); curapk = null; } else if ("repo".equals(localName)) { onRepoParsed(); } else if (curchars.length() == 0) { // All options below require non-empty content return; } final String str = curchars.toString().trim(); if (curapk != null) { switch (localName) { case ApkTable.Cols.VERSION_NAME: curapk.versionName = str; break; case "versioncode": // ApkTable.Cols.VERSION_CODE curapk.versionCode = Utils.parseInt(str, -1); break; case ApkTable.Cols.SIZE: curapk.size = Utils.parseInt(str, 0); break; case ApkTable.Cols.HASH: if (currentApkHashType == null || "md5".equals(currentApkHashType)) { if (curapk.hash == null) { curapk.hash = str; curapk.hashType = "SHA-256"; } } else if ("sha256".equals(currentApkHashType)) { curapk.hash = str; curapk.hashType = "SHA-256"; } break; case ApkTable.Cols.SIGNATURE: curapk.sig = str; break; case ApkTable.Cols.SOURCE_NAME: curapk.srcname = str; break; case "apkname": // ApkTable.Cols.NAME curapk.apkName = str; break; case "sdkver": // ApkTable.Cols.MIN_SDK_VERSION curapk.minSdkVersion = Utils.parseInt(str, Apk.SDK_VERSION_MIN_VALUE); break; case ApkTable.Cols.TARGET_SDK_VERSION: curapk.targetSdkVersion = Utils.parseInt(str, Apk.SDK_VERSION_MIN_VALUE); break; case "maxsdkver": // ApkTable.Cols.MAX_SDK_VERSION curapk.maxSdkVersion = Utils.parseInt(str, Apk.SDK_VERSION_MAX_VALUE); if (curapk.maxSdkVersion == 0) { // before fc0df0dcf4dd0d5f13de82d7cd9254b2b48cb62d, this could be 0 curapk.maxSdkVersion = Apk.SDK_VERSION_MAX_VALUE; } break; case ApkTable.Cols.OBB_MAIN_FILE: curapk.obbMainFile = str; break; case ApkTable.Cols.OBB_MAIN_FILE_SHA256: curapk.obbMainFileSha256 = str; break; case ApkTable.Cols.OBB_PATCH_FILE: curapk.obbPatchFile = str; break; case ApkTable.Cols.OBB_PATCH_FILE_SHA256: curapk.obbPatchFileSha256 = str; break; case ApkTable.Cols.ADDED_DATE: curapk.added = Utils.parseDate(str, null); break; case "permissions": // together with <uses-permissions* makes ApkTable.Cols.REQUESTED_PERMISSIONS addCommaSeparatedPermissions(str); break; case ApkTable.Cols.FEATURES: curapk.features = Utils.parseCommaSeparatedString(str); break; case ApkTable.Cols.NATIVE_CODE: curapk.nativecode = Utils.parseCommaSeparatedString(str); break; } } else if (curapp != null) { switch (localName) { case "name": curapp.name = str; break; case "icon": curapp.icon = str; break; case "description": // This is the old-style description. We'll read it // if present, to support old repos, but in newer // repos it will get overwritten straight away! curapp.description = "<p>" + str + "</p>"; break; case "desc": // New-style description. curapp.description = str; break; case "summary": curapp.summary = str; break; case "license": curapp.license = str; break; case "author": curapp.author = str; break; case "email": curapp.email = str; break; case "source": curapp.sourceURL = str; break; case "changelog": curapp.changelogURL = str; break; case "donate": curapp.donateURL = str; break; case "bitcoin": curapp.bitcoinAddr = str; break; case "litecoin": curapp.litecoinAddr = str; break; case "flattr": curapp.flattrID = str; break; case "web": curapp.webURL = str; break; case "tracker": curapp.trackerURL = str; break; case "added": curapp.added = Utils.parseDate(str, null); break; case "lastupdated": curapp.lastUpdated = Utils.parseDate(str, null); break; case "marketversion": curapp.upstreamVersionName = str; break; case "marketvercode": curapp.upstreamVersionCode = Utils.parseInt(str, -1); break; case "categories": curapp.categories = Utils.parseCommaSeparatedString(str); break; case "antifeatures": curapp.antiFeatures = Utils.parseCommaSeparatedString(str); break; case "requirements": curapp.requirements = Utils.parseCommaSeparatedString(str); break; } } else if ("description".equals(localName)) { repoDescription = cleanWhiteSpace(str); } } private static final Pattern OLD_FDROID_PERMISSION = Pattern.compile("[A-Z_]+"); /** * It appears that the default Android permissions in android.Manifest.permissions * are prefixed with "android.permission." and then the constant name. * FDroid just includes the constant name in the apk list, so we prefix it * with "android.permission." * * @see <a href="https://gitlab.com/fdroid/fdroidserver/blob/1afa8cfc/update.py#L91"> * More info into index - size, permissions, features, sdk version</a> */ public static String fdroidToAndroidPermission(String permission) { if (OLD_FDROID_PERMISSION.matcher(permission).matches()) { return "android.permission." + permission; } return permission; } private void addRequestedPermission(String permission) { requestedPermissionsSet.add(permission); } private void addCommaSeparatedPermissions(String permissions) { String[] array = Utils.parseCommaSeparatedString(permissions); if (array != null) { for (String permission : array) { requestedPermissionsSet.add(fdroidToAndroidPermission(permission)); } } } private void removeRequestedPermission(String permission) { requestedPermissionsSet.remove(permission); } private void onApplicationParsed() { receiver.receiveApp(curapp, apksList); curapp = null; apksList = new ArrayList<>(); // If the app packageName is already present in this apps list, then it // means the same index file has a duplicate app, which should // not be allowed. // However, I'm thinking that it should be undefined behaviour, // because it is probably a bug in the fdroid server that made it // happen, and I don't *think* it will crash the client, because // the first app will insert, the second one will update the newly // inserted one. } private void onRepoParsed() { receiver.receiveRepo(repoName, repoDescription, repoSigningCert, repoMaxAge, repoVersion, repoTimestamp); } private void onRepoPushRequestParsed(RepoPushRequest repoPushRequest) { receiver.receiveRepoPushRequest(repoPushRequest); } @Override public void startElement(String uri, String localName, String qName, Attributes attributes) throws SAXException { super.startElement(uri, localName, qName, attributes); if ("repo".equals(localName)) { repoSigningCert = attributes.getValue("", "pubkey"); repoMaxAge = Utils.parseInt(attributes.getValue("", "maxage"), -1); repoVersion = Utils.parseInt(attributes.getValue("", "version"), -1); repoName = cleanWhiteSpace(attributes.getValue("", "name")); repoDescription = cleanWhiteSpace(attributes.getValue("", "description")); repoTimestamp = parseLong(attributes.getValue("", "timestamp"), 0); } else if (RepoPushRequest.INSTALL.equals(localName) || RepoPushRequest.UNINSTALL.equals(localName)) { if (repo.pushRequests == Repo.PUSH_REQUEST_ACCEPT_ALWAYS) { RepoPushRequest r = new RepoPushRequest( localName, attributes.getValue("packageName"), attributes.getValue("versionCode")); onRepoPushRequestParsed(r); } } else if ("application".equals(localName) && curapp == null) { curapp = new App(); curapp.repoId = repo.getId(); curapp.packageName = attributes.getValue("", "id"); // To appease the NON NULL constraint in the DB. Usually there is a description, and it // is quite difficult to get an app to _not_ have a description when using fdroidserver. // However, it shouldn't crash the client when this happens. curapp.description = ""; } else if ("package".equals(localName) && curapp != null && curapk == null) { curapk = new Apk(); curapk.packageName = curapp.packageName; curapk.repo = repo.getId(); currentApkHashType = null; } else if ("hash".equals(localName) && curapk != null) { currentApkHashType = attributes.getValue("", "type"); } else if ("uses-permission".equals(localName) && curapk != null) { String maxSdkVersion = attributes.getValue("maxSdkVersion"); if (maxSdkVersion == null || Build.VERSION.SDK_INT <= Integer.valueOf(maxSdkVersion)) { addRequestedPermission(attributes.getValue("name")); } else { removeRequestedPermission(attributes.getValue("name")); } } else if ("uses-permission-sdk-23".equals(localName) && curapk != null) { String maxSdkVersion = attributes.getValue("maxSdkVersion"); if (Build.VERSION.SDK_INT >= 23 && (maxSdkVersion == null || Build.VERSION.SDK_INT <= Integer.valueOf(maxSdkVersion))) { addRequestedPermission(attributes.getValue("name")); } else { removeRequestedPermission(attributes.getValue("name")); } } else if ("uses-feature".equals(localName) && curapk != null) { System.out.println("TODO startElement " + uri + " " + localName + " " + qName); // TODO } curchars.setLength(0); } private static String cleanWhiteSpace(@Nullable String str) { return str == null ? null : str.replaceAll("\\s", " "); } private static long parseLong(String str, long fallback) { if (str == null || str.length() == 0) { return fallback; } long result; try { result = Long.parseLong(str); } catch (NumberFormatException e) { result = fallback; } return result; } }