@SuppressWarnings({"WeakerAccess", "unused", "SameParameterValue", "SpellCheckingInspection", "deprecation"})
public class FileUtils {
    // Used on methods like copyFile(src, dst)
    private static final int BUFFER_SIZE = 4096;

    public static String readTextFileFast(final File file) {
        try {
            return new String(readCloseBinaryStream(new FileInputStream(file)));
        } catch (FileNotFoundException e) {
            System.err.println("readTextFileFast: File " + file + " not found.");
        }
        return "";
    }

    public static String readTextFile(final File file) {
        try {
            return readCloseTextStream(new FileInputStream(file));
        } catch (FileNotFoundException e) {
            System.err.println("readTextFile: File " + file + " not found.");
        }

        return "";
    }

    public static String readCloseTextStream(final InputStream stream) {
        return readCloseTextStream(stream, true).get(0);
    }

    public static List<String> readCloseTextStream(final InputStream stream, boolean concatToOneString) {
        final ArrayList<String> lines = new ArrayList<>();
        BufferedReader reader = null;
        String line = "";
        try {
            StringBuilder sb = new StringBuilder();
            reader = new BufferedReader(new InputStreamReader(stream));

            while ((line = reader.readLine()) != null) {
                if (concatToOneString) {
                    sb.append(line).append('\n');
                } else {
                    lines.add(line);
                }
            }
            line = sb.toString();
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (reader != null) {
                try {
                    reader.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
        if (concatToOneString) {
            lines.clear();
            lines.add(line);
        }
        return lines;
    }

    public static byte[] readBinaryFile(final File file) {
        try {
            return readCloseBinaryStream(new FileInputStream(file), (int) file.length());
        } catch (FileNotFoundException e) {
            System.err.println("readBinaryFile: File " + file + " not found.");
        }

        return new byte[0];
    }

    public static byte[] readCloseBinaryStream(final InputStream stream, int byteCount) {
        final ArrayList<String> lines = new ArrayList<>();
        BufferedInputStream reader = null;
        byte[] buf = new byte[byteCount];
        int totalBytesRead = 0;
        try {
            reader = new BufferedInputStream(stream);
            while (totalBytesRead < byteCount) {
                int bytesRead = reader.read(buf, totalBytesRead, byteCount - totalBytesRead);
                if (bytesRead > 0) {
                    totalBytesRead = totalBytesRead + bytesRead;
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (reader != null) {
                try {
                    reader.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
        return buf;
    }

    // Read binary stream (of unknown conf size)
    public static byte[] readCloseBinaryStream(final InputStream stream) {
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        try {
            byte[] buffer = new byte[BUFFER_SIZE];
            int read;
            while ((read = stream.read(buffer)) != -1) {
                baos.write(buffer, 0, read);
            }
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (stream != null) {
                try {
                    stream.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
        return baos.toByteArray();
    }

    public static boolean writeFile(final File file, byte[] data) {
        try {
            OutputStream output = null;
            try {
                output = new BufferedOutputStream(new FileOutputStream(file));
                output.write(data);
                output.flush();
                return true;
            } finally {
                if (output != null) {
                    output.close();
                }
            }
        } catch (Exception ex) {
            return false;
        }
    }

    public static boolean writeFile(final File file, final String content) {
        BufferedWriter writer = null;
        try {
            if (!file.getParentFile().isDirectory() && !file.getParentFile().mkdirs())
                return false;

            writer = new BufferedWriter(new FileWriter(file));
            writer.write(content);
            writer.flush();
            return true;
        } catch (IOException e) {
            e.printStackTrace();
            return false;
        } finally {
            if (writer != null) {
                try {
                    writer.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    public static boolean copyFile(final File src, final File dst) {
        // Just touch file if src is empty
        if (src.length() == 0) {
            return touch(dst);
        }

        InputStream is = null;
        FileOutputStream os = null;
        try {
            try {
                is = new FileInputStream(src);
                os = new FileOutputStream(dst);
                byte[] buf = new byte[BUFFER_SIZE];
                int len;
                while ((len = is.read(buf)) > 0) {
                    os.write(buf, 0, len);
                }
                return true;
            } finally {
                if (is != null) {
                    is.close();
                }
                if (os != null) {
                    os.close();
                }
            }
        } catch (IOException ex) {
            return false;
        }
    }

    // Returns -1 if the file did not contain any of the needles, otherwise,
    // the index of which needle was found in the contents of the file.
    //
    // Needless MUST be in lower-case.
    public static int fileContains(File file, String... needles) {
        try {
            FileInputStream in = new FileInputStream(file);

            int i;
            String line;
            BufferedReader reader = new BufferedReader(new InputStreamReader(in));
            while ((line = reader.readLine()) != null) {
                for (i = 0; i != needles.length; ++i)
                    if (line.toLowerCase(Locale.ROOT).contains(needles[i])) {
                        return i;
                    }
            }

            in.close();
        } catch (IOException e) {
            e.printStackTrace();
        }

        return -1;
    }

    public static boolean deleteRecursive(final File file) {
        boolean ok = true;
        if (file.exists()) {
            if (file.isDirectory()) {
                for (File child : file.listFiles())
                    ok &= deleteRecursive(child);
            }
            ok &= file.delete();
        }
        return ok;
    }

    // Example: Check if this is maybe a conf: (str, "jpg", "png", "jpeg")
    public static boolean hasExtension(String str, String... extensions) {
        String lc = str.toLowerCase(Locale.ROOT);
        for (String extension : extensions) {
            if (lc.endsWith("." + extension.toLowerCase(Locale.ROOT))) {
                return true;
            }
        }
        return false;
    }

    public static boolean renameFile(File srcFile, File destFile) {
        if (srcFile.getAbsolutePath().equals(destFile.getAbsolutePath())) {
            return false;
        }

        // renameTo will fail in case of case-changed filename in same dir.Even on case-sensitive FS!!!
        if (srcFile.getParent().equals(destFile.getParent()) && srcFile.getName().toLowerCase(Locale.getDefault()).equals(destFile.getName().toLowerCase(Locale.getDefault()))) {
            File tmpFile = new File(destFile.getParent(), UUID.randomUUID().getLeastSignificantBits() + ".tmp");
            if (!tmpFile.exists()) {
                renameFile(srcFile, tmpFile);
                srcFile = tmpFile;
            }
        }

        if (!srcFile.renameTo(destFile)) {
            if (copyFile(srcFile, destFile) && !srcFile.delete()) {
                if (!destFile.delete()) {
                    return false;
                }
                return false;
            }
        }
        return true;
    }

    @SuppressWarnings("ResultOfMethodCallIgnored")
    public static boolean renameFileInSameFolder(File srcFile, String destFilename) {
        return renameFile(srcFile, new File(srcFile.getParent(), destFilename));
    }

    public static boolean touch(File file) {
        try {
            if (!file.exists()) {
                new FileOutputStream(file).close();
            }
            return file.setLastModified(System.currentTimeMillis());
        } catch (IOException e) {
            return false;
        }
    }

    // Get relative path to specified destination
    public static String relativePath(File src, File dest) {
        try {
            String[] srcSplit = (src.isDirectory() ? src : src.getParentFile()).getCanonicalPath().split(Pattern.quote(File.separator));
            String[] destSplit = dest.getCanonicalPath().split(Pattern.quote(File.separator));
            StringBuilder sb = new StringBuilder();
            int i = 0;

            for (; i < destSplit.length && i < srcSplit.length; ++i) {
                if (!destSplit[i].equals(srcSplit[i]))
                    break;
            }
            if (i != srcSplit.length) {
                for (int iUpperDir = i; iUpperDir < srcSplit.length; ++iUpperDir) {
                    sb.append("..");
                    sb.append(File.separator);
                }
            }
            for (; i < destSplit.length; ++i) {
                sb.append(destSplit[i]);
                sb.append(File.separator);
            }
            if (!dest.getPath().endsWith("/") && !dest.getPath().endsWith("\\")) {
                sb.delete(sb.length() - File.separator.length(), sb.length());
            }
            return sb.toString();
        } catch (IOException | NullPointerException exception) {
            return null;
        }
    }

    /**
     * Try to detect MimeType by backwards compatible methods
     */
    public static String getMimeType(File file) {
        String guess = null;
        if (file != null && file.exists() && file.isFile()) {
            InputStream is = null;
            try {
                is = new BufferedInputStream(new FileInputStream(file));
                guess = URLConnection.guessContentTypeFromStream(is);
            } catch (IOException e) {
                e.printStackTrace();
            } finally {
                if (is != null) {
                    try {
                        is.close();
                    } catch (IOException ignored) {
                    }
                }
            }

            if (guess == null || guess.isEmpty()) {
                guess = "*/*";
                int dot = file.getName().lastIndexOf(".") + 1;
                if (dot > 0 && dot < file.getName().length()) {
                    switch (file.getName().substring(dot)) {
                        case "md":
                        case "markdown":
                        case "mkd":
                        case "mdown":
                        case "mkdn":
                        case "mdwn":
                        case "rmd":
                            guess = "text/markdown";
                            break;
                        case "txt":
                            guess = "text/plain";
                            break;
                    }
                }
            }
        }
        return guess;
    }

    public static boolean isTextFile(File file) {
        return getMimeType(file).startsWith("text/");
    }

    /**
     * Analyze given textfile and retrieve multiple information from it
     * Information is written back to the {@link AtomicInteger} parameters
     */
    public static void retrieveTextFileSummary(File file, AtomicInteger numCharacters, AtomicInteger numLines) {
        BufferedReader br = null;
        try {
            br = new BufferedReader(new FileReader(file));
            String line;
            while ((line = br.readLine()) != null) {
                numLines.getAndIncrement();
                numCharacters.getAndSet(numCharacters.get() + line.length());
            }
        } catch (Exception e) {
            e.printStackTrace();
            numCharacters.set(-1);
            numLines.set(-1);
        } finally {
            if (br != null) {
                try {
                    br.close();
                } catch (IOException ignored) {
                }
            }
        }
    }

    /**
     * Format filesize to human readable format
     * Get size in bytes e.g. from {@link File} using {@code File#length()}
     */
    public static String getReadableFileSize(long size, boolean abbreviation) {
        if (size <= 0) {
            return "0B";
        }
        String[] units = abbreviation ? new String[]{"B", "kB", "MB", "GB", "TB"} : new String[]{"Bytes", "Kilobytes", "Megabytes", "Gigabytes", "Terabytes"};
        int unit = (int) (Math.log10(size) / Math.log10(1024));
        return new DecimalFormat("#,##0.#").format(size / Math.pow(1024, unit))
                + " " + units[unit];
    }
} @SuppressWarnings({"WeakerAccess", "unused", "SameParameterValue", "SpellCheckingInspection", "deprecation"})
public class ZipUtils {
    private static final int BUFFER_SIZE = 4096;

    public static boolean unzip(final File zipFile, final File destRootFolder, boolean flatten) {
        return unzip(zipFile, destRootFolder, flatten, null);
    }

    public static boolean unzip(final File zipFile, final File destRootFolder,
                                final boolean flatten, final Callback.a1<Float> progressCallback) {
        try {
            final float knownLength = progressCallback == null ? -1f : getZipLength(zipFile);
            return unzip(new FileInputStream(zipFile), destRootFolder, flatten, progressCallback, knownLength);
        } catch (IOException ignored) {
            return false;
        }
    }

    public static boolean unzip(final InputStream input, final File destRootFolder,
                                final boolean flatten, final Callback.a1<Float> progressCallback,
                                final float knownLength) throws IOException {
        String filename;
        final ZipInputStream in = new ZipInputStream(new BufferedInputStream(input));

        int count;
        int written = 0;
        final byte[] buffer = new byte[BUFFER_SIZE];
        float invLength = 1f / knownLength;

        ZipEntry ze;
        while ((ze = in.getNextEntry()) != null) {
            filename = ze.getName();
            if (ze.isDirectory()) {
                if (!flatten && !new File(destRootFolder, filename).mkdirs())
                    return false;
            } else {
                if (flatten) {
                    final int idx = filename.lastIndexOf("/");
                    if (idx != -1)
                        filename = filename.substring(idx + 1);
                }

                final FileOutputStream out = new FileOutputStream(new File(destRootFolder, filename));
                while ((count = in.read(buffer)) != -1) {
                    out.write(buffer, 0, count);
                    if (invLength != -1f) {
                        written += count;
                        progressCallback.callback(written * invLength);
                    }
                }

                out.close();
                in.closeEntry();
            }
        }
        in.close();
        return true;
    }

    // TODO Maybe there's a way to avoid reading the zip twice,
    // but ZipEntry.getSize() may return -1 so we can't just cache the ZipEntries
    private static long getZipLength(final File zipFile) {
        int count;
        long totalSize = 0;
        byte[] buffer = new byte[BUFFER_SIZE];
        try {
            final ZipInputStream in = new ZipInputStream(new BufferedInputStream(new FileInputStream(zipFile)));

            ZipEntry ze;
            while ((ze = in.getNextEntry()) != null) {
                if (!ze.isDirectory()) {
                    if (ze.getSize() == -1) {
                        while ((count = in.read(buffer)) != -1)
                            totalSize += count;
                    } else {
                        totalSize += ze.getSize();
                    }
                }
            }

            in.close();
            return totalSize;
        } catch (IOException ignored) {
            return -1;
        }
    }

    public static void zipFolder(final File srcFolder, final OutputStream out) throws IOException {
        ZipOutputStream outZip = null;
        try {
            outZip = new ZipOutputStream(out);
            addFolderToZip("", srcFolder, outZip);
        } finally {
            if (outZip != null) {
                try {
                    outZip.close();
                } catch (IOException ignored) {
                }
            }
        }
    }

    private static void addFileToZip(final String pathInsideZip, final File fileToZip,
                                     final ZipOutputStream outZip) throws IOException {
        if (fileToZip.isDirectory()) {
            addFolderToZip(pathInsideZip, fileToZip, outZip);
        } else {
            FileInputStream in = null;
            try {
                in = new FileInputStream(fileToZip);
                outZip.putNextEntry(new ZipEntry(pathInsideZip + "/" + fileToZip.getName()));

                int count;
                byte[] buffer = new byte[BUFFER_SIZE];
                while ((count = in.read(buffer)) > 0)
                    outZip.write(buffer, 0, count);

            } finally {
                if (in != null) {
                    try {
                        in.close();
                    } catch (IOException ignored) {
                    }
                }
            }
        }
    }


    private static void addFolderToZip(String pathInsideZip, final File folderToZip,
                                       final ZipOutputStream outZip) throws IOException {
        pathInsideZip = pathInsideZip.isEmpty() ?
                folderToZip.getName() :
                pathInsideZip + "/" + folderToZip.getName();

        File[] files = folderToZip.listFiles();
        if (files != null) {
            for (File file : files)
                addFileToZip(pathInsideZip, file, outZip);
        }
    }
} public class Messenger {

    // This interface is not meant to be used by the Messenger itself but rather
    // wrappers around background threads and handlers to talk to the Messenger.
    public interface OnSyncProgress {
        void onUpdate(int stage, float progress);
    }

    // This interface is meant to be used as the progress of the task running RepoHandler.sync
    public interface OnRepoSync {
        void onUpdate(RepoHandler which, float progress);

        void onFinish(RepoHandler which, boolean okay);
    }

    public interface OnRepoChange {
        void onRepoAdded(RepoHandler which);

        void onRepoRemoved(RepoHandler which);
    }

    public interface OnApplicationsSync {
        void onUpdate(float progress);

        void onFinish(boolean okay);
    }

    public final static ArrayList<OnRepoSync> onRepoSync = new ArrayList<>();
    public final static ArrayList<OnRepoChange> onRepoChange = new ArrayList<>();
    public final static ArrayList<OnApplicationsSync> onApplicationsSync = new ArrayList<>();

    public static void notifyRepoSync(final RepoHandler which, final float progress) {
        for (OnRepoSync x : onRepoSync)
            x.onUpdate(which, progress);
    }

    public static void notifyRepoSyncFinished(final RepoHandler which, final boolean okay) {
        for (OnRepoSync x : onRepoSync)
            x.onFinish(which, okay);
    }

    public static void notifyRepoAdded(final RepoHandler which) {
        for (OnRepoChange x : onRepoChange)
            x.onRepoAdded(which);
    }

    public static void notifyRepoRemoved(final RepoHandler which) {
        for (OnRepoChange x : onRepoChange)
            x.onRepoRemoved(which);
    }

    public static void notifyApplicationSync(final float progress) {
        for (OnApplicationsSync x : onApplicationsSync)
            x.onUpdate(progress);
    }

    public static void notifyApplicationSyncFinished(final boolean okay) {
        for (OnApplicationsSync x : onApplicationsSync)
            x.onFinish(okay);
    }
} @SuppressWarnings({"WeakerAccess", "unused"})
public class GitHub {
    // GitHub OAuth (https://developer.github.com/v3/oauth/#scopes)
    // Scopes joined by '%20', although result scopes are joined by ','
    public final static String GITHUB_API_URL = "https://api.github.com/";
    public final static String GITHUB_AUTH_URL = "https://github.com/login/oauth/authorize?scope=%s&client_id=%s";
    public final static String GITHUB_COMPLETE_AUTH_URL = "https://github.com/login/oauth/access_token";
    public final static String[] GITHUB_WANTED_SCOPES = {"public_repo", "gist"};
    private static final String GITHUB_REPO_URL_TEMPLATE = "https://github.com/%s/%s";

    //region Private methods

    private static String getUrl(final String call, final Object... args) {
        if (args.length > 0)
            return GITHUB_API_URL + String.format(call, args);
        else
            return GITHUB_API_URL + call;
    }

    //endregion

    //region Public methods

    public static JSONObject createGist(String description, boolean isPublic,
                                        HashMap<String, String> fileContents,
                                        String token) {
        try {
            JSONObject params = new JSONObject();

            if (description != null && !description.isEmpty())
                params.put("description", description);

            params.put("public", isPublic);

            JSONObject filesObject = new JSONObject();
            for (Map.Entry<String, String> fileContent : fileContents.entrySet()) {
                JSONObject fileObject = new JSONObject();
                fileObject.put("content", fileContent.getValue());

                // Add this file object (with content) using the filename as key
                filesObject.put(fileContent.getKey(), fileObject);
            }
            params.put("files", filesObject);

            if (token.isEmpty())
                return new JSONObject(NetworkUtils.performCall(getUrl("gists"), params));
            else
                return new JSONObject(NetworkUtils.performCall(getUrl("gists?access_token=" + token), params));
        } catch (JSONException e) {
            e.printStackTrace();
            return null;
        }
    }

    public static JSONObject createIssue(RepoHandler repo,
                                         String title, String description, String token)
            throws InvalidObjectException {
        try {
            JSONObject params = new JSONObject();

            params.put("title", title);
            params.put("body", description);

            return new JSONObject(NetworkUtils.performCall(getUrl("repos/%s/issues?access_token=%s",
                    repo.toOwnerRepo(), token), params));
        } catch (JSONException e) {
            e.printStackTrace();
            return null;
        }
    }

    public static JSONObject commentIssue(RepoHandler repo,
                                          int issueNumber, String body, String token)
            throws InvalidObjectException {
        try {
            JSONObject params = new JSONObject();
            params.put("body", body);
            return new JSONObject(NetworkUtils.performCall(getUrl(
                    "repos/%s/issues/%d/comments?access_token=%s",
                    repo.toOwnerRepo(), issueNumber, token), params));
        } catch (JSONException e) {
            e.printStackTrace();
            return null;
        }
    }

    private static JSONObject getUserInfo(String token) {
        try {
            return new JSONObject(NetworkUtils.performCall(getUrl(
                    "user?access_token=%s", token), NetworkUtils.GET));
        } catch (JSONException e) {
            e.printStackTrace();
            return null;
        }
    }

    private static JSONArray getCollaborators(String token, RepoHandler repo)
            throws InvalidObjectException {
        try {
            return new JSONArray(NetworkUtils.performCall(getUrl(
                    "repos/%s/collaborators?access_token=%s", repo.toOwnerRepo(), token), NetworkUtils.GET));
        } catch (JSONException e) {
            // We might not have permission so the response isn't an array, rather an object:
            // {
            //   "message": "Must have push access to view repository collaborators.",
            //   "documentation_url": "https://developer.github.com/v3/repos/collaborators/#list-collaborators"
            // }
            e.printStackTrace();
            return null;
        }
    }

    // Returns both the authenticated user and whether they have right to push
    public static AbstractMap.SimpleImmutableEntry<String, Boolean> canPush(String token, RepoHandler repo) {
        JSONObject user = getUserInfo(token);
        if (user == null)
            // TODO Actually, maybe throw an NoPermissionException or something
            return new AbstractMap.SimpleImmutableEntry<>(null, false);

        String username = "";
        try {
            username = user.getString("login");

            JSONArray collaborators = getCollaborators(token, repo);
            if (collaborators != null) {
                for (int i = 0; i < collaborators.length(); i++) {
                    JSONObject collaborator = collaborators.getJSONObject(i);
                    if (collaborator.getString("login").equals(username)) {
                        JSONObject permissions = collaborator.getJSONObject("permissions");
                        // TODO Can someone possibly not have 'pull' permissions? Then what?
                        return new AbstractMap.SimpleImmutableEntry<>(username, permissions.getBoolean("push"));
                    }
                }
            }
            // If we're not a collaborator, then we obviously don't have permission
            return new AbstractMap.SimpleImmutableEntry<>(username, false);
        } catch (JSONException | InvalidObjectException e) {
            e.printStackTrace();
            return new AbstractMap.SimpleImmutableEntry<>(username, false);
        }
    }

    public static JSONArray getBranches(final RepoHandler repo) {
        try {
            return new JSONArray(NetworkUtils.performCall(getUrl(
                    "repos/%s/branches", repo.toOwnerRepo()), NetworkUtils.GET));
        } catch (JSONException | InvalidObjectException e) {
            e.printStackTrace();
            return null;
        }
    }

    public static String getDefaultBranch(final RepoHandler repo) {
        try {
            JSONObject result = new JSONObject(NetworkUtils.performCall(getUrl(
                    "repos/%s", repo.toOwnerRepo()), NetworkUtils.GET));
            return result.getString("default_branch");
        } catch (JSONException | InvalidObjectException e) {
            e.printStackTrace();
            return null;
        }
    }

    private static JSONArray getCommits(final String token, final RepoHandler repo)
            throws InvalidObjectException {
        try {
            return new JSONArray(NetworkUtils.performCall(getUrl(
                    "repos/%s/commits?access_token=%s", repo.toOwnerRepo(), token), NetworkUtils.GET));
        } catch (JSONException e) {
            e.printStackTrace();
            return null;
        }
    }

    public static JSONObject createBranch(final String token, final RepoHandler repo, final String branchName)
            throws InvalidObjectException {
        try {
            JSONArray head = new JSONArray(NetworkUtils.performCall(getUrl(
                    "repos/%s/git/refs/heads?access_token=%s", repo.toOwnerRepo(), token), NetworkUtils.GET));

            final String sha = head.getJSONObject(0).getJSONObject("object").getString("sha");
            JSONObject params = new JSONObject();
            params.put("ref", "refs/heads/" + branchName);
            params.put("sha", sha);
            return new JSONObject(NetworkUtils.performCall(getUrl(
                    "repos/%s/git/refs?access_token=%s", repo.toOwnerRepo(), token), params));

        } catch (JSONException e) {
            e.printStackTrace();
            return null;
        }
    }

    public static JSONObject forkRepository(final String token, final RepoHandler repo)
            throws InvalidObjectException {
        try {
            JSONObject result = new JSONObject(NetworkUtils.performCall(getUrl(
                    "repos/%s/forks?access_token=%s", repo.toOwnerRepo(), token), NetworkUtils.POST));

            // "Forking a Repository happens asynchronously."
            // One way to know when forking is done is to fetch the list of commits for the fork.
            // (http://stackoverflow.com/a/33667417/4759433)
            int sleep = 1000;
            do {
                try {
                    Thread.sleep(sleep);
                    sleep *= 2; // Sleep longer if the repo isn't yet forked not to call so often
                } catch (InterruptedException e) {
                    e.printStackTrace();
                    return null;
                }
            } while (getCommits(token, repo) == null);

            return result;
        } catch (JSONException e) {
            e.printStackTrace();
            return null;
        }
    }

    // head = Fork's branch name. If this PR is cross-repository, prefix username:branch.
    // base = Branch's name where the changes will be pulled into. This should exist already.
    public static JSONObject createPullRequest(final String token, final RepoHandler originalRepo,
                                               final String title, final String head,
                                               final String base, final String body)
            throws InvalidObjectException {
        try {
            JSONObject params = new JSONObject();
            params.put("title", title);
            params.put("head", head);
            params.put("base", base);
            if (body != null && !body.isEmpty())
                params.put("body", body);

            return new JSONObject(NetworkUtils.performCall(getUrl(
                    "repos/%s/pulls?access_token=%s", originalRepo.toOwnerRepo(), token), params));
        } catch (JSONException e) {
            e.printStackTrace();
            return null;
        }
    }

    // Really big thanks to http://www.levibotelho.com/development/commit-a-file-with-the-github-api
    public static JSONObject createCommitFile(final String token, final RepoHandler repo,
                                              final String branch,
                                              final HashMap<String, String> pathContents,
                                              final String commitMessage)
            throws JSONException, InvalidObjectException {
        final String tokenQuery = "?access_token=" + token;
        final String ownerRepo = repo.toOwnerRepo();

        // Step 1. Get a reference to HEAD (GET /repos/:owner/:repo/git/refs/:ref)
        // https://developer.github.com/v3/git/refs/#get-a-reference
        JSONObject head = new JSONObject(NetworkUtils.performCall(
                getUrl("repos/%s/git/refs/heads/%s%s", ownerRepo, branch, tokenQuery), NetworkUtils.GET));

        // Step 2. Grab the commit that HEAD points to (GET /repos/:owner/:repo/git/commits/:sha)
        // https://developer.github.com/v3/git/commits/#get-a-commit
        String headCommitUrl = head.getJSONObject("object").getString("url");
        // Equivalent to getting object.sha and then formatting it

        JSONObject commit = new JSONObject(NetworkUtils.performCall(headCommitUrl + tokenQuery, NetworkUtils.GET));

        // Step 3. Post your new file to the server (POST /repos/:owner/:repo/git/blobs)
        // https://developer.github.com/v3/git/blobs/#create-a-blob
        final HashMap<String, JSONObject> pathBlobs = new HashMap<>();

        for (Map.Entry<String, String> pathContent : pathContents.entrySet()) {
            JSONObject newBlob = new JSONObject();
            newBlob.put("content", pathContent.getValue());
            newBlob.put("encoding", "utf-8");

            JSONObject blob = new JSONObject(NetworkUtils.performCall(
                    getUrl("repos/%s/git/blobs%s", ownerRepo, tokenQuery), newBlob));

            pathBlobs.put(pathContent.getKey(), blob);
        }

        // Step 4. Get a hold of the tree that the commit points to (GET /repos/:owner/:repo/git/trees/:sha)
        // https://developer.github.com/v3/git/trees/#get-a-tree
        String treeUrl = commit.getJSONObject("tree").getString("url");
        // Equivalent to getting tree.sha and then formatting it

        JSONObject baseTree = new JSONObject(NetworkUtils.performCall(treeUrl + tokenQuery, NetworkUtils.GET));

        // Step 5. Create a tree containing your new file
        //      5a. The easy way (POST /repos/:owner/:repo/git/trees)
        // https://developer.github.com/v3/git/trees/#create-a-tree
        JSONObject newTree = new JSONObject();
        newTree.put("base_tree", baseTree.get("sha"));
        {
            JSONArray blobFileArray = new JSONArray();
            for (Map.Entry<String, JSONObject> pathBlob : pathBlobs.entrySet()) {
                JSONObject blobFileTree = new JSONObject();
                blobFileTree.put("path", pathBlob.getKey());
                blobFileTree.put("mode", "100644"); // 100644 (blob), 100755 (executable), 040000 (subdirectory/tree), 160000 (submodule/commit), or 120000 (blob specifying path of symlink)
                blobFileTree.put("type", "blob"); // "blob", "tree", or "commit"
                blobFileTree.put("sha", pathBlob.getValue().getString("sha"));

                blobFileArray.put(blobFileTree);
            }

            // Finally put the array with our files
            newTree.put("tree", blobFileArray);
        }

        JSONObject createdTree = new JSONObject(NetworkUtils.performCall(
                getUrl("repos/%s/git/trees%s", ownerRepo, tokenQuery), newTree));

        // Step 6. Create a new commit (POST /repos/:owner/:repo/git/commits)
        // https://developer.github.com/v3/git/commits/#create-a-commit
        JSONObject newCommit = new JSONObject();
        newCommit.put("message", commitMessage);
        // [...] put the SHA of the commit that you retrieved in step #2 in the parents array
        {
            JSONArray parents = new JSONArray();
            parents.put(0, commit.getString("sha"));
            newCommit.put("parents", parents);
        }
        // and the SHA of your newly-created tree from step #5 in the tree field.
        newCommit.put("tree", createdTree.getString("sha"));

        JSONObject repliedNewCommit = new JSONObject(NetworkUtils.performCall(
                getUrl("repos/%s/git/commits%s", ownerRepo, tokenQuery), newCommit));

        // Step 7. Update HEAD (PATCH /repos/:owner/:repo/git/refs/:ref)
        // https://developer.github.com/v3/git/refs/#update-a-reference
        JSONObject patch = new JSONObject();
        patch.put("sha", repliedNewCommit.getString("sha"));

        return new JSONObject(NetworkUtils.performCall(
                getUrl("repos/%s/git/refs/heads/%s%s", ownerRepo, branch, tokenQuery),
                NetworkUtils.PATCH, patch));
    }

    public static String buildGitHubUrl(String owner, String repository) {
        return String.format(GITHUB_REPO_URL_TEMPLATE, owner, repository);
    }

    public static String getGitHubOwnerRepo(final String url)
            throws InvalidObjectException {
        Matcher m = GitWrapper.OWNER_REPO.matcher(url);
        if (m.matches() && m.group(1).equalsIgnoreCase("github.com")) {
            return String.format("%s/%s", m.group(2), m.group(3));
        }
        throw new InvalidObjectException("Not a GitHub repository.");
    }

    //endregion

    //region Authentication
    public static class Authentication {
        public static class CompleteAuthenticationResult {
            public boolean ok;
            public String message, token, grantedScopes;
        }

        public static String getAuthRequestUrl() {
            return String.format(GITHUB_AUTH_URL,
                    StringUtils.join(Arrays.asList(GITHUB_WANTED_SCOPES), "%20"), SlAppSettings.GITHUB_CLIENT_ID);
        }

        public static CompleteAuthenticationResult completeGitHubAuth(SlAppSettings appSettings, String clientId, String clientSecret, String code) {
            final HashMap<String, String> map = new HashMap<>();
            map.put("client_id", clientId);
            map.put("client_secret", clientSecret);
            map.put("code", code);

            CompleteAuthenticationResult ret = new CompleteAuthenticationResult();
            HashMap<String, String> postResult = NetworkUtils.getDataMap(
                    NetworkUtils.performCall(GITHUB_COMPLETE_AUTH_URL, NetworkUtils.POST, map)
            );
            if (postResult.containsKey("error")) {
                ret.message = postResult.get("error_description");
            } else {
                ret.token = postResult.get("access_token");
                ret.grantedScopes = postResult.get("scope");
                appSettings.setGitHubAccess(ret.token, ret.grantedScopes);
                if (appSettings.hasGitHubAuthorization()) {
                    ret.ok = true;
                }
            }
            return ret;
        }
    }
    //endregion
} public class LocaleString {

    final private String mLocale;
    final private String mLocaleDisplay;

    // Support a locale with any separator (i.e. "zh", "zh-TW", "zh_rTW", "zh/TW")
    private final static Pattern SANITIZE_PATTERN = Pattern.compile(
            "^(\\w{2})(?:[ -_/]r?(\\w{2}))?$", Pattern.CASE_INSENSITIVE);

    public LocaleString(String locale) {
        mLocale = locale;
        mLocaleDisplay = getDisplay(locale);
    }

    public String getCode() {
        return mLocale;
    }

    @Override
    public String toString() {
        return mLocaleDisplay;
    }

    public static String getDisplay(String localeCode) {
        final Locale locale = getLocale(localeCode);
        if (isValid(locale))
            return locale.getDisplayName();
        else
            return localeCode;
    }

    // Useful when exporting to issue for example
    public static String getEnglishDisplay(String localeCode) {
        final Locale locale = getLocale(localeCode);
        if (isValid(locale))
            return locale.getDisplayName(Locale.ENGLISH);
        else
            return localeCode;
    }

    public static String getFullCode(Locale locale) {
        String language = locale.getLanguage();
        String country = locale.getCountry();
        return country.isEmpty() ? language : language + "-r" + country;
    }

    public static Locale getLocale(String locale) {
        if (locale.contains("-")) {
            final String[] parts = locale.split("-");
            return new Locale(parts[0], parts[1].substring(1));
        }

        return new Locale(locale);
    }

    private static boolean isValid(final Locale locale) {
        try {
            return locale.getISO3Language() != null && locale.getISO3Country() != null;
        } catch (MissingResourceException ignored) {
            return false;
        }
    }

    public static ArrayList<Locale> getCountriesForLocale(final String localeCode) {
        final ArrayList<Locale> result = new ArrayList<>();
        for (Locale l : Locale.getAvailableLocales())
            if (l.getLanguage().equals(localeCode))
                result.add(l);

        if (result.isEmpty()) {
            final Locale locale = new Locale(localeCode);
            if (isValid(locale))
                result.add(locale);
        }

        return result;
    }

    public static String getEmojiFlag(final Locale locale) {
        String countryCode = locale.getCountry();
        if (countryCode.isEmpty())
            return joinAsRIS(locale.getLanguage().toUpperCase(), true);
        else
            return joinAsRIS(countryCode.toUpperCase(), countryCode.length() != 2);
    }

    // https://en.wikipedia.org/wiki/Regional_Indicator_Symbol
    private static String joinAsRIS(final String text, boolean addInvisibleChar) {
        try {
            final StringBuilder result = new StringBuilder();
            for (int i = 0; i != text.length(); ++i) {
                result.append(Character.toChars(Character.codePointAt(text, i) - 'A' + 0x1F1E6));
                if (addInvisibleChar && i != text.length() - 1)
                    result.append('\u2063'); // http://www.fileformat.info/info/unicode/char/2063/index.htm
            }
            return result.toString();
        } catch (IndexOutOfBoundsException ignored) {
            return "";
        }
    }
} public class Resources implements Iterable<ResTag> {

    //region Members

    private final File mFile; // Keep track of the original file to be able to save()
    private final HashMap<String, ResTag> mStrings;
    private final HashMap<String, ResTag> mReferenceStrings; // Those starting with "@string/"

    private ResTag mLastTag; // The last tag returned by getTag()

    private boolean mSavedChanges;
    private boolean mModified;

    //endregion

    //region Constructors

    public static Resources fromFile(final File file) {
        Resources result = new Resources(file);

        if (file.isFile()) {
            InputStream is = null;
            try {
                is = new FileInputStream(file);
                // Load the resources from the XML into our resulting Resources
                final XmlPullParser parser = XmlPullParserFactory.newInstance().newPullParser();
                ResourcesParser.loadFromXml(is, result, parser);
            } catch (IOException | XmlPullParserException e) {
                e.printStackTrace();
            } finally {
                try {
                    if (is != null)
                        is.close();
                } catch (IOException ignored) {
                }
            }
        }

        // If loading went okay, the resources will have been loaded into the result
        return result;
    }

    // Empty resources cannot be saved
    public static Resources empty() {
        return new Resources(null);
    }

    private Resources(File file) {
        mFile = file;
        mStrings = new HashMap<>();
        mReferenceStrings = new HashMap<>();
        mSavedChanges = mFile != null && mFile.isFile();
    }

    //endregion

    //region Getting content

    public int count() {
        return mStrings.size();
    }

    public boolean isEmpty() {
        return mStrings.isEmpty();
    }

    public boolean contains(String resourceId) {
        return getTag(resourceId) != null;
    }

    public String getContent(String resourceId) {
        ResTag tag = getTag(resourceId);
        return tag == null ? "" : tag.getContent();
    }

    public ResTag getTag(String resourceId) {
        if (mLastTag != null && mLastTag.getId().equals(resourceId)) {
            return mLastTag;
        }

        mLastTag = mStrings.get(resourceId);
        if (mLastTag == null && !resourceId.contains(":")) {
            // We might be looking for a parent string, not the ResTag itself
            // This is a fallback to the slightly slower method
            for (Map.Entry<String, ResTag> srt : mStrings.entrySet()) {
                ResTag rt = srt.getValue();

                if (rt instanceof ResStringArray.Item) {
                    if (((ResStringArray.Item) rt).getParent().getId().equals(resourceId))
                        return mLastTag = rt;
                } else if (rt instanceof ResPlurals.Item) {
                    if (((ResPlurals.Item) rt).getParent().getId().equals(resourceId))
                        return mLastTag = rt;
                }
            }
        }

        return mLastTag;
    }

    // Determines whether the resource ID was modified or not
    // If this resource ID doesn't exist, then it obviously wasn't modified
    public boolean wasModified(String resourceId) {
        ResTag rs = getTag(resourceId);
        return rs != null && rs.wasModified();
    }

    //endregion

    //region Updating (setting) content

    public void setContent(final ResTag original, final String content) {
        if (content == null)
            throw new IllegalArgumentException();
        String resourceId = original == null ? "" : original.getId();
        if (resourceId.isEmpty())
            return;

        // If the content is empty (or null), treat it as deleting this ID
        if (content.isEmpty()) {
            deleteId(resourceId);
            return;
        }

        ResTag rs = getTag(resourceId);
        if (rs != null) {
            if (rs.setContent(content)) {
                mSavedChanges = false;
            }
        } else {
            // We need to treat string arrays and plurals specially
            // For these, we need to find the parent, and if it exists
            // then we need to add the child to the existing parent
            boolean handled = false;
            if (original instanceof ResStringArray.Item) {
                ResStringArray.Item ori = (ResStringArray.Item) original;
                ResStringArray.Item existingChild =
                        (ResStringArray.Item) getTag(ori.getParent().getId());

                if (existingChild != null) {
                    // The parent existed, so add the new string to it, and the
                    // resulting new string to our local array of children
                    ResStringArray parent = existingChild.getParent();
                    ResTag newItem = parent.addItem(content, true, ori.getIndex());
                    mStrings.put(newItem.getId(), newItem);
                    handled = true;
                } // else the parent didn't exist, so behave as the general case

            } else if (original instanceof ResPlurals.Item) {
                ResPlurals.Item ori = (ResPlurals.Item) original;
                ResPlurals.Item existingChild =
                        (ResPlurals.Item) getTag(ori.getParent().getId());

                if (existingChild != null) {
                    // The parent existed, so add the new string to it, and the
                    // resulting new string to our local array of children
                    ResPlurals parent = existingChild.getParent();
                    ResTag newItem = parent.addItem(ori.getQuantity(), content, true);
                    mStrings.put(newItem.getId(), newItem);
                    handled = true;
                } // else the parent didn't exist, so behave as the general case
            }
            if (!handled) {
                ResTag clone = original.clone(content);
                mStrings.put(clone.getId(), clone);
            }
            mSavedChanges = false;
        }
    }

    public void addTag(ResTag rt) {
        // If it's null, there was no old value, so changes won't not saved
        if (mStrings.put(rt.getId(), rt) == null)
            mSavedChanges = false;
    }

    // To be used by the ResourcesParser
    void loadTag(ResTag rt) {
        if (rt.getContent().startsWith("@"))
            mReferenceStrings.put(rt.getId(), rt);
        else
            mStrings.put(rt.getId(), rt);

        mModified |= rt.wasModified();
    }

    //endregion

    //region Deleting content

    public void deleteId(String resourceId) {
        mStrings.remove(resourceId);
        if (mLastTag != null && mLastTag.getId().equals(resourceId))
            mLastTag = null;
    }

    //endregion

    //region File saving and deleting

    public String getFilename() {
        return mFile == null ? "strings.xml" : mFile.getName();
    }

    // Determines whether the file was ever modified or not (any of its strings were modified)
    public boolean wasModified() {
        return mModified;
    }

    // If there are unsaved changes, saves the file
    // If the file was saved successfully or there were no changes to save, returns true
    public boolean save() {
        if (mSavedChanges)
            return true;

        if (mFile == null)
            return false;

        try {
            if (!mFile.getParentFile().isDirectory())
                mFile.getParentFile().mkdirs();

            FileOutputStream out = new FileOutputStream(mFile);
            final XmlSerializer serializer = XmlPullParserFactory.newInstance().newSerializer();
            mSavedChanges = ResourcesParser.parseToXml(this, out, serializer);
            mModified = true;
            out.close();
        } catch (IOException | XmlPullParserException e) {
            e.printStackTrace();
        }
        // We do not want empty files, if it exists and it's empty delete it
        if (mFile.isFile() && mFile.length() == 0)
            mFile.delete();

        return mFile.isFile();
    }

    public boolean delete() {
        boolean ok = mFile != null && mFile.delete();
        if (ok) {
            // If the directory is empty, delete it too
            File parent = mFile.getParentFile();
            String[] children = parent.list();
            if (children == null || children.length == 0) {
                ok = parent.delete();
            }
        }
        return ok;
    }

    //endregion

    //region Iterator wrapper

    @Override
    public Iterator<ResTag> iterator() {
        return sortIterator(null, null);
    }

    public Iterator<ResTag> sortIterator(final Comparator<ResTag> comparator, final Set<String> filterIDs) {
        final ArrayList<ResTag> strings = new ArrayList<>(mStrings.size());
        if (filterIDs == null || filterIDs.isEmpty()) {
            for (Map.Entry<String, ResTag> srt : mStrings.entrySet()) {
                strings.add(srt.getValue());
            }
        } else {
            for (Map.Entry<String, ResTag> srt : mStrings.entrySet()) {
                if (filterIDs.contains(srt.getKey())) {
                    strings.add(srt.getValue());
                }
            }
        }
        if (comparator != null)
            Collections.sort(strings, comparator);

        return strings.iterator();
    }

    //endregion
} public class ResourcesParser {

    //region Constants

    // We don't use namespaces
    private final static String ns = null;

    private final static String RESOURCES = "resources";

    private final static String ID = "name";
    private final static String QUANTITY = "quantity";
    private final static String INDEX = "index";
    private final static String MODIFIED = "modified";

    // Not every application uses the official "translatable" name
    private final static String[] TRANSLATABLE = {
            "translatable", "translate", "translateable"
    };

    // Nor they use "translatable" instead ignoring missing
    private final static String TOOLS_IGNORE = "tools:ignore";
    private final static String MISSING_TRANSLATION = "MissingTranslation";

    private final static boolean DEFAULT_TRANSLATABLE = true;
    private final static boolean DEFAULT_MODIFIED = false;
    private final static int DEFAULT_INDEX = -1;

    //endregion

    //region Xml -> Resources

    static void loadFromXml(final InputStream in, final Resources resources, final XmlPullParser parser)
            throws XmlPullParserException, IOException {

        try {
            parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false);
            parser.setInput(in, null);
            parser.nextTag();
            readResourcesInto(parser, resources);
        } finally {
            try {
                in.close();
            } catch (IOException ignored) {
            }
        }
    }

    private static void readResourcesInto(final XmlPullParser parser, final Resources resources)
            throws XmlPullParserException, IOException {

        parser.require(XmlPullParser.START_TAG, ns, RESOURCES);
        if (MISSING_TRANSLATION.equals(parser.getAttributeValue(null, TOOLS_IGNORE))) {
            // tools:ignore="MissingTranslation"
            // Since missing translations can be ignored, they don't need a translation
            return;
        }

        while (parser.next() != XmlPullParser.END_TAG) {
            if (parser.getEventType() != XmlPullParser.START_TAG)
                continue;

            String name = parser.getName();
            switch (ResType.fromTagName(name)) {
                case STRING:
                    ResTag rt = readResourceString(parser);
                    if (rt != null)
                        resources.loadTag(rt);
                    break;
                case STRING_ARRAY:
                    for (ResTag item : readResourceStringArray(parser))
                        resources.loadTag(item);
                    break;
                case PLURALS:
                    for (ResTag item : readResourcePlurals(parser))
                        resources.loadTag(item);
                    break;
                default:
                    skip(parser);
                    break;
            }
        }
    }

    // Reads a <string name="...">...</string> tag from the xml.
    // This assumes that the .xml has been cleaned (i.e. there are no untranslatable strings)
    private static ResString readResourceString(XmlPullParser parser)
            throws XmlPullParserException, IOException {

        String id, content;
        boolean modified;

        parser.require(XmlPullParser.START_TAG, ns, ResType.STRING.toString());
        if (MISSING_TRANSLATION.equals(parser.getAttributeValue(null, TOOLS_IGNORE))) {
            return null;
        }

        id = parser.getAttributeValue(null, ID);

        // Metadata
        modified = readBooleanAttr(parser, MODIFIED, DEFAULT_MODIFIED);

        // The content must be read last, since it also consumes the tag
        content = ResTag.desanitizeContent(getInnerXml(parser));

        parser.require(XmlPullParser.END_TAG, ns, ResType.STRING.toString());

        if (id == null || content.isEmpty())
            return null;
        else
            return new ResString(ResType.STRING.markID(id), content, modified);
    }

    // Reads a <string-array name="...">...</string-array> tag from the xml.
    private static Iterable<ResStringArray.Item> readResourceStringArray(XmlPullParser parser)
            throws XmlPullParserException, IOException {

        ResStringArray result;
        String id;

        parser.require(XmlPullParser.START_TAG, ns, ResType.STRING_ARRAY.toString());
        if (MISSING_TRANSLATION.equals(parser.getAttributeValue(null, TOOLS_IGNORE))) {
            return null;
        }

        if (!readFirstBooleanAttr(parser, TRANSLATABLE, DEFAULT_TRANSLATABLE)) {
            // We don't care about not-translatable strings
            skipInnerXml(parser);
            parser.require(XmlPullParser.END_TAG, ns, ResType.STRING_ARRAY.toString());
            return new ArrayList<>();
        } else {
            id = parser.getAttributeValue(null, ID);
            result = new ResStringArray(ResType.STRING_ARRAY.markID(id));

            while (parser.next() != XmlPullParser.END_TAG) {
                if (parser.getEventType() != XmlPullParser.START_TAG)
                    continue;

                String name = parser.getName();
                if (ResType.fromTagName(name) == ResType.ITEM) {
                    parser.require(XmlPullParser.START_TAG, ns, name);
                    boolean modified = readBooleanAttr(parser, MODIFIED, DEFAULT_MODIFIED);
                    int index = readIntAttr(parser, INDEX, DEFAULT_INDEX);

                    String content = ResTag.desanitizeContent(getInnerXml(parser));
                    if (!content.isEmpty())
                        result.addItem(content, modified, index);
                } else {
                    skip(parser);
                }
            }

            return result.expand();
        }
    }

    // Reads a <string-array name="...">...</string-array> tag from the xml.
    private static Iterable<ResPlurals.Item> readResourcePlurals(XmlPullParser parser)
            throws XmlPullParserException, IOException {

        ResPlurals result;
        String id;

        parser.require(XmlPullParser.START_TAG, ns, ResType.PLURALS.toString());
        if (MISSING_TRANSLATION.equals(parser.getAttributeValue(null, TOOLS_IGNORE))) {
            return null;
        }

        if (!readFirstBooleanAttr(parser, TRANSLATABLE, DEFAULT_TRANSLATABLE)) {
            // We don't care about not-translatable strings
            skipInnerXml(parser);
            parser.require(XmlPullParser.END_TAG, ns, ResType.PLURALS.toString());
            return new ArrayList<>();
        } else {
            id = parser.getAttributeValue(null, ID);
            result = new ResPlurals(ResType.PLURALS.markID(id));

            while (parser.next() != XmlPullParser.END_TAG) {
                if (parser.getEventType() != XmlPullParser.START_TAG)
                    continue;

                String name = parser.getName();
                if (ResType.fromTagName(name) == ResType.ITEM) {
                    parser.require(XmlPullParser.START_TAG, ns, name);
                    String quantity = parser.getAttributeValue(null, QUANTITY);
                    boolean modified = readBooleanAttr(parser, MODIFIED, DEFAULT_MODIFIED);
                    String content = ResTag.desanitizeContent(getInnerXml(parser));
                    if (!content.isEmpty())
                        result.addItem(quantity, content, modified);
                } else {
                    skip(parser);
                }
            }

            return result.expand();
        }
    }

    // Reads a boolean attribute from an xml tag
    private static boolean readBooleanAttr(XmlPullParser parser, String attr, boolean defaultV) {
        String value = parser.getAttributeValue(null, attr);
        if (value == null)
            return defaultV;

        return Boolean.parseBoolean(value);
    }

    private static boolean readFirstBooleanAttr(XmlPullParser parser, String[] attrs, boolean defaultV) {
        for (String attr : attrs) {
            String value = parser.getAttributeValue(null, attr);
            if (value != null) {
                return Boolean.parseBoolean(value);
            }
        }

        return defaultV;
    }

    private static int readIntAttr(XmlPullParser parser, String attr, int defaultV) {
        String value = parser.getAttributeValue(null, attr);
        if (value == null)
            return defaultV;

        return Integer.parseInt(value);
    }

    // Reads the inner xml of a tag before moving to the next one
    // Original source: stackoverflow.com/a/16069754/4759433 by @Maarten
    // TODO This will fail with: &lt;a&gt;text</a>
    private static String getInnerXml(XmlPullParser parser)
            throws XmlPullParserException, IOException {

        StringBuilder sb = new StringBuilder();
        int depth = 1;
        while (depth != 0) {
            switch (parser.next()) {
                case XmlPullParser.END_TAG:
                    depth--;
                    if (depth > 0) {
                        sb.append("</")
                                .append(parser.getName())
                                .append(">");
                    }
                    break;
                case XmlPullParser.START_TAG:
                    depth++;
                    StringBuilder attrs = new StringBuilder();
                    for (int i = 0; i < parser.getAttributeCount(); i++) {
                        attrs.append(parser.getAttributeName(i))
                                .append("=\"")
                                .append(parser.getAttributeValue(i))
                                .append("\" ");
                    }
                    sb.append("<")
                            .append(parser.getName())
                            .append(" ")
                            .append(attrs.toString())
                            .append(">");
                    break;
                default:
                    sb.append(parser.getText());
                    break;
            }
        }
        return sb.toString();
    }

    // Skips the inner XML once inside a tag
    private static void skipInnerXml(XmlPullParser parser)
            throws XmlPullParserException, IOException {
        int depth = 1;
        while (depth != 0) {
            switch (parser.next()) {
                case XmlPullParser.END_TAG:
                    depth--;
                    break;
                case XmlPullParser.START_TAG:
                    depth++;
                    break;
            }
        }
    }

    // Skips a tag in the xml
    private static void skip(XmlPullParser parser) throws XmlPullParserException, IOException {
        if (parser.getEventType() != XmlPullParser.START_TAG)
            throw new IllegalStateException();

        int depth = 1;
        while (depth != 0) {
            switch (parser.next()) {
                case XmlPullParser.END_TAG:
                    --depth;
                    break;
                case XmlPullParser.START_TAG:
                    ++depth;
                    break;
            }
        }
    }

    //endregion

    //region Resources -> Xml

    static boolean parseToXml(final Resources resources, final OutputStream out, final XmlSerializer serializer) {
        // We need to keep track of the parents which we have done already.
        // This is because we previously expanded the children, but they're
        // wrapped under the same parent (which we cannot duplicate).
        HashSet<String> doneParents = new HashSet<>();
        try {
            serializer.setOutput(out, "UTF-8");
            serializer.startTag(ns, RESOURCES);

            for (ResTag rs : resources) {
                if (!rs.hasContent())
                    continue;

                if (rs instanceof ResString) {
                    parseString(serializer, (ResString) rs);
                } else if (rs instanceof ResStringArray.Item) {
                    ResStringArray parent = ((ResStringArray.Item) rs).getParent();
                    if (!doneParents.contains(parent.getId())) {
                        doneParents.add(parent.getId());
                        parseStringArray(serializer, parent);
                    }
                } else if (rs instanceof ResPlurals.Item) {
                    ResPlurals parent = ((ResPlurals.Item) rs).getParent();
                    if (!doneParents.contains(parent.getId())) {
                        doneParents.add(parent.getId());
                        parsePlurals(serializer, parent);
                    }
                }
            }
            serializer.endTag(ns, RESOURCES);
            serializer.flush();
            return true;
        } catch (IOException e) {
            e.printStackTrace();
            return false;
        }
    }

    private static void parseString(XmlSerializer serializer, ResString string)
            throws IOException {
        serializer.startTag(ns, ResType.STRING.toString());
        serializer.attribute(ns, ID, ResType.resolveID(string.getId()));

        // Only save changes that differ from the default, to save space
        if (string.wasModified() != DEFAULT_MODIFIED)
            serializer.attribute(ns, MODIFIED, Boolean.toString(string.wasModified()));

        serializer.text(ResTag.sanitizeContent(string.getContent()));
        serializer.endTag(ns, ResType.STRING.toString());
    }

    private static void parseStringArray(XmlSerializer serializer, ResStringArray array)
            throws IOException {
        serializer.startTag(ns, ResType.STRING_ARRAY.toString());
        serializer.attribute(ns, ID, ResType.resolveID(array.getId()));

        for (ResStringArray.Item item : array.expand()) {
            serializer.startTag(ns, ResType.ITEM.toString());
            if (item.wasModified() != DEFAULT_MODIFIED)
                serializer.attribute(ns, MODIFIED, Boolean.toString(item.wasModified()));

            // We MUST save the index because the user might have
            // translated first the non-first item from the array. Darn it!
            serializer.attribute(ns, INDEX, Integer.toString(item.getIndex()));
            serializer.text(ResTag.sanitizeContent(item.getContent()));
            serializer.endTag(ns, ResType.ITEM.toString());
        }

        serializer.endTag(ns, ResType.STRING_ARRAY.toString());
    }

    private static void parsePlurals(XmlSerializer serializer, ResPlurals plurals)
            throws IOException {
        serializer.startTag(ns, ResType.PLURALS.toString());
        serializer.attribute(ns, ID, ResType.resolveID(plurals.getId()));

        for (ResPlurals.Item item : plurals.expand()) {
            serializer.startTag(ns, ResType.ITEM.toString());
            serializer.attribute(ns, QUANTITY, item.getQuantity());

            if (item.wasModified() != DEFAULT_MODIFIED)
                serializer.attribute(ns, MODIFIED, Boolean.toString(item.wasModified()));

            serializer.text(ResTag.sanitizeContent(item.getContent()));
            serializer.endTag(ns, ResType.ITEM.toString());
        }

        serializer.endTag(ns, ResType.PLURALS.toString());
    }

    //endregion

    //region Xml -> Xml without untranslatable strings

    private static final Pattern TAG_UNTRANSLATABLE_PATTERN =
            //                 tagName    translatable    =     "false"
            Pattern.compile("<([\\w-]+).*?translatable\\s*=\\s*\"false\".*?>");

    public static boolean cleanXml(final File inFile, final File outFile) {
        return cleanXml(FileUtils.readTextFile(inFile), outFile);
    }

    public static boolean cleanXml(final String xml, final File outFile) {
        try {
            boolean haveAny = false;

            // 1. Find dirty tags (those which are untranslatable)
            Queue<DirtyRange> dirtyRanges = new LinkedList<>();

            Matcher mTag = RES_TAG_PATTERN.matcher(xml);
            while (mTag.find()) {
                String translatable = getAttr(mTag.group(2), TRANSLATABLE);
                if (translatable.equals("false")) {
                    // Decrease the range by 1 not to eat up the next character (due to the i++)
                    dirtyRanges.add(new DirtyRange(mTag.start(), mTag.end() - 1));
                } else {
                    // This file contains at least one translatable string
                    haveAny = true;
                }
            }

            // TODO The rest of code is copied from cleanMissingStrings I only change one part…
            // There are no translatable strings, so do nothing (and don't create any file)
            if (!haveAny)
                return false;

            // There is at least one translatable string, so we need to clean the xml
            if (!outFile.getParentFile().isDirectory())
                outFile.getParentFile().mkdirs();

            FileOutputStream out = new FileOutputStream(outFile);
            BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(out));

            // We might want to early terminate if all strings are translatable
            if (dirtyRanges.isEmpty()) {
                // Simply copy the file, there's nothing to clean
                writer.write(xml);
                writer.close();
                return true;
            }

            removeDirtyRanges(xml, dirtyRanges, out);
            return true;
        } catch (IOException e) {
            e.printStackTrace();
            return false;
        }
    }

    //endregion

    //region Using another file as a template

    //region Applying the template

    // This will match either <string>, <string-array>, <plurals> or <item> from start to end.
    // It will also match the attributes (attribute_a="value" attribute_b="value") and the content.
    private static Pattern RES_TAG_PATTERN = Pattern.compile(
            "<(string(?:-array)?|plurals|item)((?:\\s+\\w+\\s*=\\s*\"\\w+\")*)\\s*>([\\S\\s]*?)(?:</\\s*\\1\\s*>)");

    // This should be matched against the .group(2) from the above pattern
    private static Pattern ATTRIBUTE_PATTERN = Pattern.compile("(\\w+)\\s*=\\s*\"(\\w+)\"");

    //region Actual code

    private static class DirtyRange {
        final int start, end;

        DirtyRange(int s, int e) {
            start = s;
            end = e;
        }
    }

    // Replacement holder - on the original xml (the "template")
    // there will exist several of these to indicate where the
    // replacements should be made
    private static class ReplaceHolder {
        final int start, end;
        final String replacement;

        ReplaceHolder(int start, int end, String replacement) {
            this.start = start;
            this.end = end;
            this.replacement = replacement;
        }
    }

    private static String getAttr(String attrs, String... attrNames) {
        Matcher m = ATTRIBUTE_PATTERN.matcher(attrs);
        while (m.find()) {
            for (String attrName : attrNames) {
                if (m.group(1).equals(attrName)) {
                    return m.group(2);
                }
            }
        }
        return "";
    }

    private static boolean isWhitespace(String string) {
        for (int i = 0; i < string.length(); i++)
            if (!Character.isWhitespace(string.charAt(i)))
                return false;
        return true;
    }

    // Will return an empty string if there is no available translation
    private static String cleanMissingStrings(String xml, Resources resources) {
        // TODO Ignore "<!-- <string name="missing">value</string> -->" comments?
        // Maybe scan for comments and if a match is found inside a comment… Discard it?
        // Or maybe it's just over complication.
        boolean haveAny = false;

        // 1. Find dirty tags (those which we have no translation for)
        Queue<DirtyRange> dirtyRanges = new LinkedList<>();

        Matcher mTag = RES_TAG_PATTERN.matcher(xml);
        while (mTag.find()) {
            String id = getAttr(mTag.group(2), ID);
            if (resources.contains(id)) {
                // This file contains at least one translation
                haveAny = true;
            } else {
                // We don't have a translation, so this tag is dirty. Decrease
                // the range by 1 not to eat up the next character (due to the i++)
                dirtyRanges.add(new DirtyRange(mTag.start(), mTag.end() - 1));
            }
        }

        // There is no string we have a translation for, so return an empty string
        if (!haveAny)
            return "";

        // We might want to early terminate if we have a translation for all the strings
        if (dirtyRanges.isEmpty())
            return xml;

        try {
            ByteArrayOutputStream output = new ByteArrayOutputStream();

            // 2. Remove the dirty tags and mark those lines as dirty too
            removeDirtyRanges(xml, dirtyRanges, output);

            // Return the clean xml
            return output.toString();
        } catch (IOException e) {
            e.printStackTrace();
            return "";
        }
    }

    // This assumes a clean xml, i.e., we have a translation for all the strings in it
    // Returns TRUE if there were no errors
    private static boolean writeReplaceStrings(String xml, Resources resources, OutputStream out) {
        // Match on the original xml to determine where replacements will be made
        Matcher mTag = RES_TAG_PATTERN.matcher(xml);

        // Matches will be in order, so use a queue (first in, first out)
        Queue<ReplaceHolder> holders = new LinkedList<>();
        while (mTag.find()) {
            int contentStart = mTag.start(3);
            int contentEnd = contentStart + mTag.group(3).length();

            String id = getAttr(mTag.group(2), ID);
            if (id.isEmpty())
                continue;

            // Should never fail - plus do we even care it's empty? No resource would be found
            // Also, we obviously have this string translated (xml was cleaned, right?),
            // thus there is no need to ensure whether we need to delete the line or not
            ResType type = ResType.fromTagName(mTag.group(1));
            ResTag tag = resources.getTag(type.markID(id));
            Matcher mItem;
            switch (type) {
                case STRING:
                    holders.add(new ReplaceHolder(contentStart, contentEnd,
                            tag.getContent() == null ? "" : tag.getContent()));
                    break;
                case STRING_ARRAY:
                    ResStringArray array = ((ResStringArray.Item) tag).getParent();
                    int i = 0;
                    mItem = RES_TAG_PATTERN.matcher(mTag.group(3));
                    while (mItem.find()) {
                        if (ResType.fromTagName(mItem.group(1)) == ResType.ITEM) {
                            // We must take the base offset (contentStart) into account…
                            int cs = contentStart + mItem.start(3);
                            int ce = cs + mItem.group(3).length();

                            // We might not have this content, but we wish to keep the order
                            ResStringArray.Item item = array.getItem(i);
                            String content = item == null ? null : item.getContent();
                            holders.add(new ReplaceHolder(cs, ce,
                                    content == null ? "" : content));
                            i++;
                        }
                    }
                    break;
                case PLURALS:
                    ResPlurals plurals = ((ResPlurals.Item) tag).getParent();
                    mItem = RES_TAG_PATTERN.matcher(mTag.group(3));
                    while (mItem.find()) {
                        if (ResType.fromTagName(mItem.group(1)) == ResType.ITEM) {
                            String quantity = getAttr(mItem.group(2), QUANTITY);
                            int cs = contentStart + mItem.start(3);
                            int ce = cs + mItem.group(3).length();

                            // We might not have this content, but we wish to keep the order
                            ResPlurals.Item item = plurals.getItem(quantity);
                            String content = item == null ? "" : item.getContent();
                            holders.add(new ReplaceHolder(cs, ce, content));
                        }
                    }
                    break;
                // case ResType.ITEM: break; // Should not be on the wild though
            }
        }

        // Write the result by applying the required replacements
        PrintWriter writer = new PrintWriter(out);
        ReplaceHolder holder = holders.poll();
        for (int i = 0; i < xml.length(); i++) {
            if (holder == null || i < holder.start) {
                // There are no more holders left, simply copy the characters
                writer.append(xml.charAt(i));
            } else {
                // We reached the current replacement holder
                // Replace the original content with our new content
                writer.append(ResTag.sanitizeContent(holder.replacement));
                i = holder.end - 1; // Skip to the end of the tag
                // Decrease the range by 1 not to eat up the next character (due to the i++)

                holder = holders.poll(); // Next holder
            }
        }
        writer.close();
        return !writer.checkError();
    }

    // Returns TRUE if the template was applied successfully
    public static boolean applyTemplate(File template, Resources resources, OutputStream out) {
        // The xml will be empty if we have no translation for this file.
        String xml = cleanMissingStrings(FileUtils.readTextFile(template), resources);
        return !xml.isEmpty() && writeReplaceStrings(xml, resources, out);
    }

    //endregion

    //endregion

    //endregion

    //region Private utilities

    // Removes the dirty ranges on string. If a line containing
    // a dirty range is then empty, this line will also be removed.
    // The result will be output to the given output stream.
    private static void removeDirtyRanges(final String string,
                                          final Queue<DirtyRange> dirtyRanges,
                                          final OutputStream output)
            throws IOException {
        int line = 0;
        int lastLine = -1; // To avoid adding the same line twice
        Queue<Integer> dirtyLines = new LinkedList<>();

        // Save result here
        StringBuilder noDirty = new StringBuilder();

        // Get first range and iterate over the characters of the xml
        DirtyRange range = dirtyRanges.poll();
        for (int i = 0; i < string.length(); i++) {
            char c = string.charAt(i);

            if (range == null || i < range.start) {
                // Copy this character since it's not part of the dirty tag
                noDirty.append(c);

                // Note how we increment the line iff it was copied,
                // otherwise it was removed and should be excluded
                if (c == '\n') {
                    line++;
                }
            } else {
                // Omit these characters since we're in a dirty tag,
                // and mark the line as dirty iff it wasn't marked before
                if (lastLine != line) {
                    dirtyLines.add(line);
                    lastLine = line;
                }

                // >= not to skip to the next character
                if (i >= range.end) {
                    // We're outside the range now, so pick up the next range
                    range = dirtyRanges.poll();
                }
            }
        }

        // Clean the dirty lines iff they're whitespace only
        String[] lines = noDirty.toString().split("\\n");
        Integer dirtyLine = dirtyLines.poll();

        BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(output));
        for (int i = 0; i < lines.length; i++) {
            // If there are no more dirty lines
            // Or we are not at a dirty line yet
            // Or this line is not all whitespace, append it
            if (dirtyLine == null || i != dirtyLine || !isWhitespace(lines[i])) {
                writer.write(lines[i]);
                writer.write('\n');
            } else {
                // Get the next dirty line while ignoring this line too
                dirtyLine = dirtyLines.poll();
            }
        }
        writer.close();
    }

    //endregion
} public abstract class ResTag implements Comparable<ResTag> {

    //region Static members

    // Escape the backslash for the user unless they are writing a escape sequence
    private static final char[] ESCAPE_SEQUENCES = {
            'u', 'b', 't', 'n', 'f', 'r', '\\',
            'U', 'B', 'T', 'N', 'F', 'R'
    };

    //endregion

    //region Members

    String mContent = "";

    // "metadata" used to keep track whether a string is the original or not. This
    // will be later used when downloading remote changes, to keep local if modified.
    boolean mModified;

    //endregion

    //region Getters

    abstract public String getId();

    @Override
    public int hashCode() {
        return getId().hashCode();
    }

    public String getContent() {
        return mContent;
    }

    public int getContentLength() {
        return mContent.length();
    }

    public boolean hasContent() {
        return !mContent.isEmpty();
    }

    public boolean wasModified() {
        return mModified;
    }

    public abstract ResTag clone(String newContent);

    //endregion

    //region Setters

    // Returns true if the content was successfully set
    public boolean setContent(String content) {
        content = content.trim();
        if (!mContent.equals(content)) {
            mContent = content;
            mModified = true;
            return true;
        } else {
            return false;
        }
    }

    //endregion

    //region Interfaces implementation

    @Override
    public int compareTo(final ResTag o) {
        if (o == null)
            throw new IllegalArgumentException();
        return getId().compareTo(o.getId());
    }

    //endregion

    //region De/sanitize Content

    private static boolean isEscapeSequence(char which) {
        for (int i = 0; i < ESCAPE_SEQUENCES.length; ++i) {
            if (ESCAPE_SEQUENCES[i] == which) {
                return true;
            }
        }
        return false;
    }

    private static boolean isHtmlEntity(String where, int at) {
        char which;
        boolean numbers = false;
        for (; at < where.length(); ++at) {
            which = where.charAt(at);
            if (which == ';') {
                return true;
            } else if (which == '#') {
                numbers = true;
            } else if (numbers) {
                if (which < '0' || which > '9') {
                    return false;
                }
            } else {
                if ((which < 'A' || which > 'Z') && (which < 'a' || which > 'z')) {
                    return false;
                }
            }
        }
        return false;
    }

    // De-sanitizes the content, making it ready to modified outside a strings.xml file
    public static String desanitizeContent(String content) {
        char c;
        int length = content.length();
        StringBuilder sb = new StringBuilder(length + 16); // 16 seems to be the default capacity

        for (int i = 0; i < length; i++) {
            c = content.charAt(i);
            switch (c) {
                // Unescape these sequences iff we know them
                case '\\':
                    i++; // Handle the next character here
                    if (i >= length) {
                        sb.append('\\');
                        break;
                    }
                    c = content.charAt(i);
                    switch (c) {
                        case '"':
                        case '\'':
                        case '\\':
                            sb.append(c);
                            break;
                        case 'n':
                            sb.append('\n');
                            break;
                        default:
                            sb.append('\\').append(c);
                            break;
                    }
                    break;

                case '&':
                    int semicolon = content.indexOf(';', i);
                    if (semicolon < 0) {
                        // Invalid string actually, but nothing we can do.
                        sb.append('&');
                    } else {
                        switch (content.substring(i, semicolon)) {
                            // These are some of the most-common and "dangerous" cases,
                            // since not handling them in "sanitize" would cause invalid XML.
                            case "&lt":
                                sb.append('<');
                                break;
                            case "&gt":
                                sb.append('>');
                                break;
                            case "&amp":
                                sb.append('&');
                                break;
                            default:
                                sb.append(content.substring(i, semicolon)).append(';');
                                break;
                        }
                        i = semicolon;
                    }
                    break;

                // New lines are treated as spaces
                case '\n':
                    sb.append(' ');
                    break;

                // Normal character
                default:
                    sb.append(c);
                    break;
            }
        }
        return sb.toString();
    }

    // Sanitizes the content, making it ready to be written to a strings.xml file
    public static String sanitizeContent(String content) {
        char c;
        int length = content.length();
        StringBuilder sb = new StringBuilder(length + 16); // 16 seems to be the default capacity

        boolean replaceLtGt = false;
        if (content.contains("<")) {
            // The string contains (X|HT)ML tags, ensure it's valid by parsing the content
            // If it's not, then replace every <> with &lt; &gt; not to break the XML file
            try {
                DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
                factory.setValidating(false);
                factory.setNamespaceAware(true);

                DocumentBuilder builder = factory.newDocumentBuilder();
                // Every XML needs to have a root tag, however, this is not the case for
                // Android strings, and so we need to wrap it around some arbitrary tags
                // in order to check whether the inner content is right or not.
                builder.parse(new InputSource(new StringReader("<a>" + content + "</a>")));
            } catch (Exception ignored) {
                replaceLtGt = true;
            }
        }

        // Keep track of insideAngleBrackets to escape " only if we're outside.
        boolean insideAngleBrackets = false;
        for (int i = 0; i < length; i++) {
            c = content.charAt(i);
            if (insideAngleBrackets) {
                if (c == '>') {
                    insideAngleBrackets = false;
                }
                sb.append(c);
                continue;
            }

            switch (c) {
                // These should always be escaped
                case '\'':
                    sb.append("\\'");
                    break;
                // Keep an actual newline afterwards for more readability
                case '\n':
                    sb.append("\\n\n");
                    break;

                case '\\':
                    if (i + 1 < length && isEscapeSequence(content.charAt(i + 1))) {
                        // Don't escape the \ itself if the next
                        // character belongs to a escape sequence.
                        // Also skip the next character to avoid processing it.
                        sb.append(c);
                        i++;
                    } else {
                        sb.append("\\\\");
                    }
                    break;
                case '&':
                    if (isHtmlEntity(content, i + 1)) {
                        sb.append(c);
                    } else {
                        sb.append("&amp;");
                    }
                    break;

                // We might or not need to replace <>
                case '<':
                    if (replaceLtGt) {
                        sb.append("&lt;");
                    } else {
                        insideAngleBrackets = true;
                        sb.append(c);
                    }
                    break;
                case '>':
                    sb.append(replaceLtGt ? "&gt;" : ">");
                    break;

                // Quotes are a bit special
                case '"':
                    if ((i == 0 && content.charAt(length - 1) == '"') ||
                            (i == length - 1 && content.charAt(0) == '"'))
                        sb.append('"');
                    else
                        sb.append("\\\"");
                    break;

                // Normal character
                default:
                    sb.append(c);
                    break;
            }
        }
        return sb.toString();
    }

    //endregion
} public class SourceSettings {

    private static final String FILENAME = "source-settings.json";
    private final File mSettingsFile;
    private JSONObject mSettings;

    private static final String KEY_NAME = "_name";
    private static final String DEFAULT_NAME = "";

    //region Constructor

    public SourceSettings(final File directory) {
        mSettingsFile = new File(directory, FILENAME);
        mSettings = load();
    }

    //endregion

    //region Getters

    public String getName() {
        return mSettings.optString(KEY_NAME, DEFAULT_NAME);
    }

    public Object get(final String name) {
        return mSettings.opt(name);
    }

    @SuppressWarnings("unchecked")
    public <T> ArrayList<T> getArray(final String name) {
        final ArrayList<T> result = new ArrayList<>();
        try {
            JSONArray array = mSettings.optJSONArray(name);
            if (array == null)
                return result;

            for (int i = 0; i < array.length(); ++i) {
                try {
                    result.add((T) array.get(i));
                } catch (ClassCastException ignored) {
                    System.err.println("SourceSettings: Failed to load array item for setting " + name + ": " + array.get(i));
                }
            }
        } catch (JSONException ignored) {
        }
        return result;
    }

    //endregion

    //region Setters

    public void setName(final String name) {
        try {
            mSettings.put(KEY_NAME, name);
        } catch (JSONException ignored) {
        }
        save();
    }

    public void set(final String name, final Object object) {
        if (name.startsWith("_"))
            throw new IllegalArgumentException("Names for the source settings cannot start with underscore (_).");
        try {
            mSettings.put(name, object);
        } catch (JSONException ignored) {
        }
        save();
    }

    public void setArray(final String name, final ArrayList objects) {
        if (name.startsWith("_"))
            throw new IllegalArgumentException("Names for the source settings cannot start with underscore (_).");
        try {
            JSONArray array = new JSONArray();
            for (Object branch : objects)
                array.put(branch);
            mSettings.put(name, array);
        } catch (JSONException ignored) {
        }
        save();
    }

    //endregion

    //region Load/save/reset

    private JSONObject load() {
        try {
            final String json = FileUtils.readTextFile(mSettingsFile);
            if (!json.isEmpty())
                return new JSONObject(json);
        } catch (JSONException e) {
            e.printStackTrace();
        }
        return new JSONObject();
    }

    public boolean save() {
        return FileUtils.writeFile(mSettingsFile, mSettings.toString());
    }

    public void reset(final String newName) {
        mSettings = new JSONObject();
        setName(newName);
    }

    //endregion
} public interface StringsSource {

    // Sources may need to prepare some files first, e.g. strings from git repositories
    // need to pull the repository itself in order to get access to the files.
    boolean setup(final SourceSettings settings,
                  final File workDir,
                  final int desiredIconDpi,
                  final Messenger.OnSyncProgress callback);

    // Indicates that the setup operation should be cancelled
    void cancel();

    // The name for a certain StringsSource, e.g. "git". Must be non-null
    String getName();

    // Should retrieve a list of all locales available for this source. Must be non-null
    List<String> getLocales();

    // Returns all the available, non-default resources for the specified locale. Must be non-null
    Resources getResources(final String locale);

    // Returns all the names for available default resources (since these are named). Must be non-null
    List<String> getDefaultResources();

    // Given the name of a default resource, loads and returns its contents. Must be non-null
    Resources getDefaultResource(final String name);

    // Some developers have a well-defined XML structure already made,
    // for instance for GitSources where the XML itself is known, for
    // their strings.xml files. If this is the case this method should
    // return said XML, otherwise, it can return null.
    String getDefaultResourceXml(final String name);

    // May return a File pointing to an existing icon for this source, or null
    File getIcon();

    // Any temporary resource used by the source should be cleaned up here.
    void dispose();
}
import net.gsantner.opoc.util.FileUtils;
import net.gsantner.opoc.util.ZipUtils;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InvalidObjectException;
import java.io.OutputStream;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.concurrent.locks.ReentrantLock;
import io.github.lonamiwebs.stringlate.classes.Messenger;
import io.github.lonamiwebs.stringlate.classes.git.GitHub;
import io.github.lonamiwebs.stringlate.classes.locales.LocaleString;
import io.github.lonamiwebs.stringlate.classes.resources.Resources;
import io.github.lonamiwebs.stringlate.classes.resources.ResourcesParser;
import io.github.lonamiwebs.stringlate.classes.resources.tags.ResTag;
import io.github.lonamiwebs.stringlate.classes.sources.SourceSettings;
import io.github.lonamiwebs.stringlate.interfaces.StringsSource;
package io.github.lonamiwebs.stringlate.classes.repos;





// Represents a locally saved string repository, which can be synchronized from any StringsSource
public class RepoHandler implements Comparable<RepoHandler> {

    //region Members

    public final RepoSettings settings; // Public to avoid a lot of wrapper methods
    private final SourceSettings mSourceSettings;

    public final File mRoot, mCacheDir;
    private final File mProgressFile;

    private final ArrayList<String> mLocales = new ArrayList<>();

    public static final String DEFAULT_LOCALE = "default";

    private final static ReentrantLock syncingLock = new ReentrantLock();
    private final static HashSet<File> rootsInSync = new HashSet<>();
    private StringsSource mSyncingSource;
    private boolean wasCancelled;

    //endregion

    //region Constructors

    public RepoHandler(final String source, final File workDir, final File cacheDir) {
        mRoot = new File(workDir, getId(source));
        mCacheDir = cacheDir;
        settings = new RepoSettings(mRoot);
        settings.setSource(source);

        mSourceSettings = new SourceSettings(mRoot);
        settings.checkUpgradeSettingsToSpecific(mSourceSettings);
        mProgressFile = new File(mRoot, "translation_progress.json");

        loadLocales();
    }

    public RepoHandler(final File root, final File cacheDir) {
        mRoot = root;
        mCacheDir = cacheDir;
        settings = new RepoSettings(mRoot);
        mSourceSettings = new SourceSettings(mRoot);
        settings.checkUpgradeSettingsToSpecific(mSourceSettings);

        mProgressFile = new File(mRoot, "translation_progress.json");

        loadLocales();
    }

    //endregion

    //region Utilities

    // Retrieves the File object for the given locale
    private File getResourcesFile(final String locale) {
        if (locale == null)
            throw new IllegalArgumentException("locale cannot be null");
        return new File(mRoot, locale + "/strings.xml");
    }

    private File getDefaultResourcesFile(final String filename) {
        if (filename == null)
            throw new IllegalArgumentException("filename cannot be null");
        return new File(mRoot, DEFAULT_LOCALE + "/" + filename);
    }

    // An application may want to split their translations into several files. We
    // can just save them as strings.xml, strings2.xml, strings3.xml... and save
    // a map somewhere else to store "strings%d.xml -> original-path.xml".
    private File getUniqueDefaultResourcesFile() {
        File result = getDefaultResourcesFile("strings.xml");
        if (result.isFile()) {
            int i = 2;
            while (result.isFile()) {
                result = getDefaultResourcesFile("strings" + i + ".xml");
                i++;
            }
        }
        return result;
    }

    // Never returns null
    public File[] getDefaultResourcesFiles() {
        File root = new File(mRoot, DEFAULT_LOCALE);
        if (root.isDirectory()) {
            File[] files = root.listFiles();
            if (files != null)
                return files;
        }
        return new File[0];
    }

    public boolean hasDefaultLocale() {
        return getDefaultResourcesFiles().length > 0;
    }

    // Determines whether a given locale is saved or not
    private boolean hasLocale(final String locale) {
        return getResourcesFile(locale).isFile();
    }

    // Determines whether any file has been modified,
    // i.e. it is not the original downloaded file any more.
    // Note that previous modifications do NOT imply the file being unsaved.
    public boolean anyModified() {
        for (String locale : mLocales)
            if (Resources.fromFile(getResourcesFile(locale)).wasModified())