com.ichi2.libanki.sync.MediaSyncer.java Source code

Java tutorial

Introduction

Here is the source code for com.ichi2.libanki.sync.MediaSyncer.java

Source

/****************************************************************************************
 * Copyright (c) 2012 Kostas Spyropoulos <inigo.aldana@gmail.com>                       *
 * Copyright (c) 2014 Houssam Salem <houssam.salem.au@gmail.com>                        *
 *                                                                                      *
 * This program is free software; you can redistribute it and/or modify it under        *
 * the terms of the GNU General Public License as published by the Free Software        *
 * Foundation; either version 3 of the License, or (at your option) any later           *
 * version.                                                                             *
 *                                                                                      *
 * This program is distributed in the hope that it will be useful, but WITHOUT ANY      *
 * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A      *
 * PARTICULAR PURPOSE. See the GNU General Public License for more details.             *
 *                                                                                      *
 * You should have received a copy of the GNU General Public License along with         *
 * this program.  If not, see <http://www.gnu.org/licenses/>.                           *
 ****************************************************************************************/

package com.ichi2.libanki.sync;

import android.text.TextUtils;
import android.util.Pair;

import com.ichi2.anki.AnkiDroidApp;
import com.ichi2.anki.R;
import com.ichi2.anki.exception.MediaSyncException;
import com.ichi2.anki.exception.UnknownHttpResponseException;
import com.ichi2.async.Connection;
import com.ichi2.libanki.Collection;
import com.ichi2.libanki.Consts;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.zip.ZipFile;

import timber.log.Timber;

/**
 * About conflicts:
 * - to minimize data loss, if both sides are marked for sending and one
 *   side has been deleted, favour the add
 * - if added/changed on both sides, favour the server version on the
 *   assumption other syncers are in sync with the server
 * 
 * A note about differences to the original python version of this class. We found that:
 *  1 - There is no reliable way to detect changes to the media directory on Android due to the
 *      file systems used (mainly FAT32 for sdcards) and the utilities available to probe them.
 *  2 - Scanning for media changes can take a very long time with thousands of files.
 * 
 * Given these two points, we have decided to avoid the call to findChanges() on every sync and
 * only do it on the first sync to build the initial database. Changes to the media collection
 * made through AnkiDroid (e.g., multimedia note editor, media check) are recorded directly in
 * the media database as they are made. This allows us to skip finding media changes entirely
 * as the database already contains the changes.
 * 
 * The downside to this approach is that changes made to the media directory externally (e.g.,
 * through a file manager) will not be recorded and will not be synced. In this case, the user
 * must issue a media check command through the UI to bring the database up-to-date.
 */
public class MediaSyncer {
    private Collection mCol;
    private RemoteMediaServer mServer;
    private int mDownloadCount;
    // Needed to update progress to UI
    private Connection mCon;

    public MediaSyncer(Collection col, RemoteMediaServer server, Connection con) {
        mCol = col;
        mServer = server;
        mCon = con;
    }

    public String sync() throws UnknownHttpResponseException, MediaSyncException {
        try {
            // check if there have been any changes
            // If we haven't built the media db yet, do so on this sync. See note at the top
            // of this class about this difference to the original.
            if (mCol.getMedia().needScan()) {
                mCon.publishProgress(R.string.sync_media_find);
                mCol.log("findChanges");
                mCol.getMedia().findChanges();
            }

            // begin session and check if in sync
            int lastUsn = mCol.getMedia().lastUsn();
            JSONObject ret = mServer.begin();
            int srvUsn = ret.getInt("usn");
            if ((lastUsn == srvUsn) && !(mCol.getMedia().haveDirty())) {
                return "noChanges";
            }
            // loop through and process changes from server
            mCol.log("last local usn is " + lastUsn);
            mDownloadCount = 0;
            while (true) {
                // Allow cancellation
                if (Connection.getIsCancelled()) {
                    Timber.i("Sync was cancelled");
                    try {
                        mServer.finish();
                    } catch (UnknownHttpResponseException e) {
                    }
                    throw new RuntimeException("UserAbortedSync");
                }
                JSONArray data = mServer.mediaChanges(lastUsn);
                mCol.log("mediaChanges resp count: ", data.length());
                if (data.length() == 0) {
                    break;
                }

                List<String> need = new ArrayList<String>();
                lastUsn = data.getJSONArray(data.length() - 1).getInt(1);
                for (int i = 0; i < data.length(); i++) {
                    String fname = data.getJSONArray(i).getString(0);
                    int rusn = data.getJSONArray(i).getInt(1);
                    String rsum = data.getJSONArray(i).optString(2);
                    Pair<String, Integer> info = mCol.getMedia().syncInfo(fname);
                    String lsum = info.first;
                    int ldirty = info.second;
                    mCol.log(String.format("check: lsum=%s rsum=%s ldirty=%d rusn=%d fname=%s",
                            TextUtils.isEmpty(lsum) ? "" : lsum.subSequence(0, 4),
                            TextUtils.isEmpty(rsum) ? "" : rsum.subSequence(0, 4), ldirty, rusn, fname));

                    if (!TextUtils.isEmpty(rsum)) {
                        // added/changed remotely
                        if (TextUtils.isEmpty(lsum) || !lsum.equals(rsum)) {
                            mCol.log("will fetch");
                            need.add(fname);
                        } else {
                            mCol.log("have same already");
                        }
                        mCol.getMedia().markClean(Arrays.asList(fname));

                    } else if (!TextUtils.isEmpty(lsum)) {
                        // deleted remotely
                        if (ldirty != 0) {
                            mCol.log("delete local");
                            mCol.getMedia().syncDelete(fname);
                        } else {
                            // conflict: local add overrides remote delete
                            mCol.log("conflict; will send");
                        }
                    } else {
                        // deleted both sides
                        mCol.log("both sides deleted");
                        mCol.getMedia().markClean(Arrays.asList(fname));
                    }
                }
                _downloadFiles(need);

                mCol.log("update last usn to " + lastUsn);
                mCol.getMedia().setLastUsn(lastUsn); // commits
            }

            // at this point, we're all up to date with the server's changes,
            // and we need to send our own

            boolean updateConflict = false;
            int toSend = mCol.getMedia().dirtyCount();
            while (true) {
                Pair<File, List<String>> changesZip = mCol.getMedia().mediaChangesZip();
                File zip = changesZip.first;
                try {
                    List<String> fnames = changesZip.second;
                    if (fnames.size() == 0) {
                        break;
                    }

                    mCon.publishProgress(String.format(
                            AnkiDroidApp.getAppResources().getString(R.string.sync_media_changes_count), toSend));

                    JSONArray changes = mServer.uploadChanges(zip);
                    int processedCnt = changes.getInt(0);
                    int serverLastUsn = changes.getInt(1);
                    mCol.getMedia().markClean(fnames.subList(0, processedCnt));

                    mCol.log(String.format("processed %d, serverUsn %d, clientUsn %d", processedCnt, serverLastUsn,
                            lastUsn));

                    if (serverLastUsn - processedCnt == lastUsn) {
                        mCol.log("lastUsn in sync, updating local");
                        lastUsn = serverLastUsn;
                        mCol.getMedia().setLastUsn(serverLastUsn); // commits
                    } else {
                        mCol.log("concurrent update, skipping usn update");
                        // commit for markClean
                        mCol.getMedia().getDb().commit();
                        updateConflict = true;
                    }

                    toSend -= processedCnt;
                } finally {
                    zip.delete();
                }
            }
            if (updateConflict) {
                mCol.log("restart sync due to concurrent update");
                return sync();
            }

            int lcnt = mCol.getMedia().mediacount();
            String sRet = mServer.mediaSanity(lcnt);
            if (sRet.equals("OK")) {
                return "OK";
            } else {
                mCol.getMedia().forceResync();
                return sRet;
            }
        } catch (JSONException e) {
            throw new RuntimeException(e);
        }
    }

    private void _downloadFiles(List<String> fnames) {
        mCol.log(fnames.size() + " files to fetch");
        while (fnames.size() > 0) {
            try {
                List<String> top = fnames.subList(0, Math.min(fnames.size(), Consts.SYNC_ZIP_COUNT));
                mCol.log("fetch " + top);
                ZipFile zipData = mServer.downloadFiles(top);
                int cnt = mCol.getMedia().addFilesFromZip(zipData);
                mDownloadCount += cnt;
                mCol.log("received " + cnt + " files");
                // NOTE: The python version uses slices which return an empty list when indexed beyond what
                // the list contains. Since we can't slice out an empty sublist in Java, we must check
                // if we've reached the end and clear the fnames list manually.
                if (cnt == fnames.size()) {
                    fnames.clear();
                } else {
                    fnames = fnames.subList(cnt, fnames.size());
                }
                mCon.publishProgress(String.format(
                        AnkiDroidApp.getAppResources().getString(R.string.sync_media_downloaded_count),
                        mDownloadCount));
            } catch (IOException e) {
                Timber.e(e, "Error downloading media files");
                throw new RuntimeException(e);
            } catch (UnknownHttpResponseException e) {
                Timber.e(e, "Error downloading media files");
                throw new RuntimeException(e);
            }
        }
    }
}