cz.maresmar.sfm.service.plugin.sync.SyncHandler.java Source code

Java tutorial

Introduction

Here is the source code for cz.maresmar.sfm.service.plugin.sync.SyncHandler.java

Source

/*
 * SmartFoodMenu - Android application for canteens extendable with plugins
 *
 * Copyright  2016-2018  Martin Mare <mmrmartin[at]gmail[dot]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 <https://www.gnu.org/licenses/>.
 */

package cz.maresmar.sfm.service.plugin.sync;

import android.annotation.SuppressLint;
import android.app.IntentService;
import android.app.Service;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.SharedPreferences;
import android.database.Cursor;
import android.os.Bundle;
import android.preference.PreferenceManager;
import android.support.annotation.IntDef;
import android.support.annotation.NonNull;
import android.support.v4.content.LocalBroadcastManager;
import android.support.v4.util.LongSparseArray;

import com.firebase.jobdispatcher.Constraint;
import com.firebase.jobdispatcher.FirebaseJobDispatcher;
import com.firebase.jobdispatcher.GooglePlayDriver;
import com.firebase.jobdispatcher.Job;
import com.firebase.jobdispatcher.Lifetime;
import com.firebase.jobdispatcher.RetryStrategy;
import com.firebase.jobdispatcher.Trigger;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.concurrent.CountDownLatch;

import cz.maresmar.sfm.BuildConfig;
import cz.maresmar.sfm.app.SettingsContract;
import cz.maresmar.sfm.db.DbContract;
import cz.maresmar.sfm.plugin.ActionContract;
import cz.maresmar.sfm.plugin.ActionContract.SyncTask;
import cz.maresmar.sfm.plugin.BroadcastContract;
import cz.maresmar.sfm.provider.ProviderContract;
import cz.maresmar.sfm.service.plugin.PluginUtils;
import cz.maresmar.sfm.service.web.PortalsUpdateService;
import cz.maresmar.sfm.utils.ActionUtils;
import cz.maresmar.sfm.utils.MenuUtils;
import timber.log.Timber;

import static cz.maresmar.sfm.plugin.BroadcastContract.EXTRA_CREDENTIAL_ID;
import static cz.maresmar.sfm.plugin.BroadcastContract.EXTRA_ERROR_MESSAGE;
import static cz.maresmar.sfm.plugin.BroadcastContract.EXTRA_PORTAL_ID;
import static cz.maresmar.sfm.plugin.BroadcastContract.EXTRA_TASKS_LIST;
import static cz.maresmar.sfm.plugin.BroadcastContract.EXTRA_TASKS_RESULTS;
import static cz.maresmar.sfm.plugin.BroadcastContract.EXTRA_WORST_RESULT;
import static cz.maresmar.sfm.plugin.BroadcastContract.RESULT_NOT_SUPPORTED;
import static cz.maresmar.sfm.plugin.BroadcastContract.RESULT_PLUGIN_TIMEOUT;
import static cz.maresmar.sfm.plugin.BroadcastContract.SyncResult;
import static cz.maresmar.sfm.provider.PublicProviderContract.LogData;
import static cz.maresmar.sfm.provider.PublicProviderContract.PortalFeatures;
import static java.util.concurrent.TimeUnit.DAYS;
import static java.util.concurrent.TimeUnit.HOURS;

/**
 * This class performs food data updates in background. It selects plugins and sends them an actions
 * to performs.
 * <p>
 * The class gives plugins some time to do what they want and then checks {@code FoodActions} results and shows
 * notification about credit and menu changes.
 * </p>
 *
 * @see ActionContract
 * @see BroadcastContract
 */
public class SyncHandler {

    // Action supported by service
    static final String ACTION = "cz.maresmar.sfm.action";

    static final int ACTION_FULL_SYNC = 0;
    static final int ACTION_CHANGES_SYNC = 1;
    static final int ACTION_REMAINING_SYNC = 2;

    @IntDef(value = { ACTION_FULL_SYNC, ACTION_CHANGES_SYNC, ACTION_REMAINING_SYNC })
    @Retention(RetentionPolicy.SOURCE)
    @interface Action {
    }

    // Sync event broadcast
    public static final String BROADCAST_SYNC_EVENT = "cz.maresmar.sfm.broadcast.sync-result";

    private static final String SYNC_EVENT = "syncEvent";

    private static final int EVENT_STARTED = 0;
    private static final int EVENT_FINISHED = 1;

    @IntDef(value = { EVENT_STARTED, EVENT_FINISHED })
    @Retention(RetentionPolicy.SOURCE)
    public @interface SyncEvent {
    }

    // JobDispatcher tags
    private static final String TAG_CHANGES_SYNC = "tag-activity-sync";
    private static final String TAG_PLANED_SYNC = "tag-planed-sync";

    // Action params (intended for starting this service)
    private static final String EXTRA_USER_ID = "userId";

    // Action param's constants
    private static final int UNKNOWN_ID = ActionContract.UNKNOWN_ID;

    // Filters for some actions
    @SyncTask
    private static final int TASKS_FOR_CHANGES_SYNC = ActionContract.TASK_ACTION_PRESENT_SYNC
            | ActionContract.TASK_CREDIT_SYNC;
    @SyncTask
    private static final int TASKS_FOR_REMAINING_SYNC = ActionContract.TASK_REMAINING_TO_ORDER_SYNC
            | ActionContract.TASK_REMAINING_TO_TAKE_SYNC | ActionContract.TASK_CREDIT_SYNC;
    @SuppressLint("WrongConstant")
    @SyncTask
    private static final int ALL_TASKS = (1 << ActionContract.PLUGIN_TASKS_LENGTH) - 1;

    // -------------------------------------------------------------------------------------------
    // Variables
    // -------------------------------------------------------------------------------------------

    // Update listener
    private BroadcastReceiver mSyncResultsReceiver = new BroadcastReceiver() {
        @Override
        public void onReceive(Context context, Intent intent) {
            final long portalId = intent.getLongExtra(EXTRA_PORTAL_ID, UNKNOWN_ID);
            final long credentialsID = intent.getLongExtra(EXTRA_CREDENTIAL_ID, UNKNOWN_ID);
            final int[] doneTasks = intent.getIntArrayExtra(EXTRA_TASKS_LIST);
            @SyncResult
            final int[] results = intent.getIntArrayExtra(EXTRA_TASKS_RESULTS);
            @SyncResult
            final int worstResult = intent.getIntExtra(EXTRA_WORST_RESULT, RESULT_NOT_SUPPORTED);
            final String errorMsg = intent.getStringExtra(EXTRA_ERROR_MESSAGE);

            onPluginResultReceived(portalId, credentialsID, doneTasks, results, worstResult, errorMsg);
        }
    };

    private Context mContext;
    private CountDownLatch mRemainingResultsLatch;
    @SyncResult
    private int mWorstResult;
    private LongSparseArray<Integer> mPreviousCredit;
    private LongSparseArray<Long> mPreviousLastMenuDate;
    private SharedPreferences mPrefs;

    // -------------------------------------------------------------------------------------------
    // External starting methods
    // -------------------------------------------------------------------------------------------

    /**
     * Starts full menu sync as {@link IntentService}
     *
     * @param context Some valid context
     */
    public static void startFullSync(@NonNull Context context) {
        Intent intent = new Intent(context, SyncHandleIntentService.class);
        intent.putExtra(ACTION, ACTION_FULL_SYNC);
        context.startService(intent);
    }

    /**
     * Starts sync of remaining food in menu for specific user as {@link IntentService}
     * <p>
     * This could optimally save some mobile data.
     * </p>
     *
     * @param userId  ID of user that will be synced
     * @param context Some valid context
     */
    public static void startRemainingSync(@NonNull Context context, long userId) {
        Intent intent = new Intent(context, SyncHandleIntentService.class);
        intent.putExtra(ACTION, ACTION_REMAINING_SYNC);
        intent.putExtra(EXTRA_USER_ID, userId);
        context.startService(intent);
    }

    /**
     * Plan sync of changed actions (orders) using {@link com.firebase.jobdispatcher.JobService}
     *
     * @param context Some valid context
     */
    public static void planChangesSync(@NonNull Context context) {
        Bundle extras = new Bundle();
        extras.putInt(ACTION, ACTION_CHANGES_SYNC);

        // Create a new dispatcher using the Google Play driver. Create a new dispatc
        FirebaseJobDispatcher dispatcher = new FirebaseJobDispatcher(new GooglePlayDriver(context));

        Job job = dispatcher.newJobBuilder().setService(SyncHandlerJob.class).setTag(TAG_CHANGES_SYNC)
                .setRecurring(false).setReplaceCurrent(true).setTrigger(Trigger.executionWindow(0, 0))
                .setConstraints(Constraint.ON_ANY_NETWORK).setLifetime(Lifetime.FOREVER)
                .setRetryStrategy(RetryStrategy.DEFAULT_EXPONENTIAL).setExtras(extras).build();

        dispatcher.mustSchedule(job);
    }

    /**
     * Plan full menu sync using {@link com.firebase.jobdispatcher.JobService}
     *
     * @param context       Some valid context
     * @param frequencyDays How often (in days) the sync should be done
     * @param unmeteredOnly If it should start only on unmetered network
     * @param chargerOnly   If it should start only when phone is in charger
     */
    public static void planFullSync(@NonNull Context context, int frequencyDays, boolean unmeteredOnly,
            boolean chargerOnly) {
        // Create a new dispatcher using the Google Play driver.
        FirebaseJobDispatcher dispatcher = new FirebaseJobDispatcher(new GooglePlayDriver(context));

        if (frequencyDays > 0) {
            Bundle extras = new Bundle();
            extras.putInt(ACTION, ACTION_FULL_SYNC);

            int freqSec = (int) DAYS.toSeconds(frequencyDays);
            int thresholdSec = (int) HOURS.toSeconds(4);

            Job.Builder builder = dispatcher.newJobBuilder()
                    // the JobService that will be called
                    .setService(SyncHandlerJob.class)
                    // uniquely identifies the job
                    .setTag(TAG_PLANED_SYNC)
                    // one-off job
                    .setRecurring(true)
                    // don't persist past a device reboot
                    .setLifetime(Lifetime.FOREVER)
                    // start between 0 and 60 seconds from now
                    .setTrigger(Trigger.executionWindow(freqSec - thresholdSec, freqSec + thresholdSec))
                    // don't overwrite an existing job with the same tag
                    .setReplaceCurrent(true)
                    // retry with exponential backoff
                    .setRetryStrategy(RetryStrategy.DEFAULT_EXPONENTIAL).setExtras(extras);

            if (unmeteredOnly) {
                // only run on an unmetered network
                builder.addConstraint(Constraint.ON_UNMETERED_NETWORK);
            }

            if (chargerOnly) {
                // only run when the device is charging
                builder.addConstraint(Constraint.DEVICE_CHARGING);
            }

            Job myJob = builder.build();

            dispatcher.mustSchedule(myJob);
        } else {
            dispatcher.cancel(TAG_PLANED_SYNC);
        }
    }

    // -------------------------------------------------------------------------------------------
    // Service lifecycle
    // -------------------------------------------------------------------------------------------

    /**
     * {@link Service#onCreate()} method equivalent in this class
     *
     * @param context Some valid context
     */
    public void onCreate(@NonNull Context context) {
        mContext = context;

        mPrefs = PreferenceManager.getDefaultSharedPreferences(mContext);

        IntentFilter filter = new IntentFilter();
        filter.addAction(BroadcastContract.BROADCAST_PLUGIN_SYNC_RESULT);
        mContext.registerReceiver(mSyncResultsReceiver, filter);
    }

    /**
     * {@link Service#onDestroy()} method equivalent in this class
     */
    public void onDestroy() {
        mContext.unregisterReceiver(mSyncResultsReceiver);
    }

    // -------------------------------------------------------------------------------------------
    // Start sync
    // -------------------------------------------------------------------------------------------

    /**
     * Start the sync with given actions
     *
     * @param action Action to be done
     * @param extras Action's parameters
     * @return {@code true} if the action was successful, {@code false} otherwise
     */
    boolean startAction(@Action int action, @NonNull Bundle extras) {
        broadcastStart();

        boolean successful;
        try {
            switch (action) {
            case ACTION_FULL_SYNC:
                startPlugins(ALL_TASKS, null, null);
                break;
            case ACTION_CHANGES_SYNC:
                startPlugins(TASKS_FOR_CHANGES_SYNC, null, null);
                break;
            case ACTION_REMAINING_SYNC: {
                final long userId = extras.getLong(EXTRA_USER_ID, UNKNOWN_ID);
                startRemainingSync(userId);
                break;
            }
            default: {
                Timber.e("Unknown action %d", action);
                // As there cold be more important don't lost other tasks in queue, crash only in debug
                if (BuildConfig.DEBUG) {
                    throw new IllegalArgumentException("Unknown action " + action);
                }
            }
            }

            // Give plugins some time to make it done
            try {
                int pluginsTimeout = Integer.valueOf(mPrefs.getString(SettingsContract.PLUGINS_TIMEOUT,
                        SettingsContract.PLUGINS_TIMEOUT_DEFAULT));
                boolean doneInTime = mRemainingResultsLatch.await(pluginsTimeout,
                        java.util.concurrent.TimeUnit.SECONDS);

                if (!doneInTime) {
                    mWorstResult = RESULT_PLUGIN_TIMEOUT;
                }
            } catch (InterruptedException e) {
                Timber.e("Waiting to plugins was interrupted");
            }

            // Updates portals for next start
            if (mPrefs.getBoolean(SettingsContract.UPDATE_PORTALS_AUTOMATICALLY,
                    SettingsContract.UPDATE_PORTALS_AUTOMATICALLY_DEFAULT)) {
                PortalsUpdateService.handleActionUpdate(mContext);
            }
        } finally {
            // Check actions only if I had an connection
            if (mWorstResult != BroadcastContract.RESULT_IO_EXCEPTION
                    && mWorstResult != BroadcastContract.RESULT_PORTAL_TEMPORALLY_INACCESSIBLE) {

                // Check actions
                ActionUtils.deleteConflictFailedActions(mContext);
                ActionUtils.checkActionResults(mContext);

                // Save time to settings
                mPrefs.edit().putLong(SettingsContract.LAST_DONE_SYNC, System.currentTimeMillis()).apply();

                successful = true;
            } else {
                successful = false;
            }

            // Send broadcast
            broadcastResults(mWorstResult);
        }

        return successful;
    }

    private void startPlugins(@SyncTask int taskFilter, String logDataSelection, String[] logDataArgs) {
        mPreviousCredit = new LongSparseArray<>();
        mPreviousLastMenuDate = new LongSparseArray<>();

        // Prepare query
        String[] projection = new String[] { LogData.PORTAL_ID, LogData.CREDENTIAL_ID, LogData.CREDENTIALS_GROUP_ID,
                LogData.PORTAL_PLUGIN, LogData.PORTAL_FEATURES, LogData.CREDIT };
        String sortOrder = LogData.CREDENTIALS_GROUP_ID + " ASC, " + LogData.PORTAL_ID + " ASC";

        // Finds data
        try (Cursor cursor = mContext.getContentResolver().query(LogData.getUri(), projection, logDataSelection,
                logDataArgs, sortOrder)) {
            long lastPortalId = UNKNOWN_ID;
            long lastCredentialId = UNKNOWN_ID;

            // Number of plugins to wait for
            mRemainingResultsLatch = new CountDownLatch(cursor.getCount());

            // For each log-data entry
            while (cursor.moveToNext()) {
                final long portalId = cursor.getLong(0);
                final long credentialId = cursor.getLong(1);
                final long credentialsGroupId = cursor.getLong(2);
                final String portalPlugin = cursor.getString(3);
                @PortalFeatures
                final int portalFeatures = cursor.getInt(4);
                int credit;
                if (!cursor.isNull(5)) {
                    credit = cursor.getInt(5);
                } else {
                    credit = Integer.MAX_VALUE;
                }

                if (mPreviousLastMenuDate.indexOfKey(portalId) < 0) {
                    long lastMenuDate = MenuUtils.getLastMenuDate(mContext, portalId);
                    mPreviousLastMenuDate.put(portalId, lastMenuDate);
                }

                @SyncTask
                int taskToDo = 0;

                // If first in group
                if (lastCredentialId != credentialId) {
                    lastCredentialId = credentialId;

                    taskToDo |= ActionContract.TASK_CREDIT_SYNC;
                }

                // If first in portal
                if (lastPortalId != portalId) {
                    lastPortalId = credentialId;

                    taskToDo |= ActionContract.TASK_MENU_SYNC | ActionContract.TASK_GROUP_DATA_MENU_SYNC
                            | ActionContract.TASK_REMAINING_TO_TAKE_SYNC
                            | ActionContract.TASK_REMAINING_TO_ORDER_SYNC;
                } else if ((portalFeatures
                        & ProviderContract.FEATURE_GROUP_FULL_SYNC) == ProviderContract.FEATURE_GROUP_FULL_SYNC) { // If not first in portal and portal needs it for everyone
                    taskToDo |= ActionContract.TASK_GROUP_DATA_MENU_SYNC;
                }

                // Everyone
                taskToDo |= ActionContract.TASK_ACTION_PRESENT_SYNC | ActionContract.TASK_ACTION_HISTORY_SYNC;

                // Apply filter
                taskToDo &= taskFilter;

                // If I have what to do
                if (taskToDo != 0) {
                    if (mPreviousCredit.indexOfKey(credentialId) < 0) {
                        mPreviousCredit.put(credentialId, credit);
                    }

                    startPlugin(portalPlugin, portalId, credentialId, taskToDo);
                } else {
                    mRemainingResultsLatch.countDown();
                }
            }
        }
    }

    private void startRemainingSync(long userId) {
        String logDataSelection = LogData.CREDENTIAL_ID + " IN (" + "SELECT " + DbContract.Credential._ID + " "
                + "FROM " + DbContract.Credential.TABLE_NAME + " " + "WHERE "
                + DbContract.Credential.COLUMN_NAME_UID + " = " + userId + ")";

        startPlugins(TASKS_FOR_REMAINING_SYNC, logDataSelection, null);
    }

    // -------------------------------------------------------------------------------------------
    // Start plugin
    // -------------------------------------------------------------------------------------------

    private void startPlugin(@NonNull String plugin, long portalId, long credentialsID, @SyncTask int pluginTasks) {
        Intent intent = PluginUtils.buildPluginIntent(plugin);

        // Puts action details
        intent.setAction(ActionContract.ACTION_SYNC);
        intent.putExtra(ActionContract.EXTRA_PORTAL_ID, portalId);
        intent.putExtra(ActionContract.EXTRA_CREDENTIAL_ID, credentialsID);
        intent.putExtra(ActionContract.EXTRA_TASKS, pluginTasks);

        // Provides access to login data
        intent.setData(LogData.getUri(portalId, credentialsID));

        // Give permissions to access data
        int rwFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION;

        String packageName = intent.getComponent().getPackageName();
        mContext.grantUriPermission(packageName, intent.getData(), rwFlags);
        mContext.grantUriPermission(packageName, ProviderContract.Action.getCredentialUri(credentialsID), rwFlags);
        mContext.grantUriPermission(packageName, ProviderContract.GroupMenuEntry.getPortalUri(portalId), rwFlags);
        mContext.grantUriPermission(packageName, ProviderContract.MenuEntry.getPortalUri(portalId), rwFlags);

        Timber.i("Starting plugin \"%s\" on portal %d with credential %d", plugin, portalId, credentialsID);
        // Fire start
        PluginUtils.startPlugin(mContext, intent);
    }

    // -------------------------------------------------------------------------------------------
    // Sync results
    // -------------------------------------------------------------------------------------------

    private void onPluginResultReceived(long portalId, long credentialsId, int[] doneTasks,
            @SyncResult int[] results, @SyncResult int worstResult, String errorMsg) {
        // Credential change check
        int previousCredit = mPreviousCredit.get(credentialsId);
        if (previousCredit != Integer.MAX_VALUE) {
            boolean changed = MenuUtils.checkCreditChanges(mContext, portalId, credentialsId, previousCredit);
            if (changed) {
                mPreviousCredit.put(credentialsId, Integer.MAX_VALUE);
            }
        }

        // Long new menu check
        long previousLastMenuDate = mPreviousLastMenuDate.get(portalId);
        if (previousLastMenuDate != 0) {
            boolean changed = MenuUtils.checkMenuChanges(mContext, portalId, previousLastMenuDate);
            if (changed) {
                mPreviousLastMenuDate.put(portalId, 0L);
            }
        }

        switch (worstResult) {
        case BroadcastContract.RESULT_OK:
            // It is OK ;-)
            break;
        case BroadcastContract.RESULT_IO_EXCEPTION:
            Timber.w("Portal %d IO exception \n%s", portalId, errorMsg);
            break;
        case BroadcastContract.RESULT_PORTAL_TEMPORALLY_INACCESSIBLE:
            // Don't have to do anything special (will be solved on the end)
            break;
        case BroadcastContract.RESULT_UNKNOWN_PORTAL_FORMAT:
            Timber.e("Portal %d changed format \n%s", portalId, errorMsg);
            break;
        case BroadcastContract.RESULT_WRONG_CREDENTIALS:
            Timber.w("Portal %d with credentials %d has wrong credentials", portalId, credentialsId);
            break;
        case BroadcastContract.RESULT_NOT_SUPPORTED:
            Timber.w("Portal %d does not support anything", portalId);
            break;
        case BroadcastContract.RESULT_PLUGIN_TIMEOUT:
            throw new IllegalArgumentException("Cannot get RESULT_PLUGIN_TIMEOUT from plugin");
        default:
            throw new UnsupportedOperationException("Unknown sync result " + worstResult);
        }

        // Update results for UI
        mWorstResult = Math.max(mWorstResult, worstResult);
        mRemainingResultsLatch.countDown();
    }

    // -------------------------------------------------------------------------------------------
    // Broadcast results to UI
    // -------------------------------------------------------------------------------------------

    private void broadcastStart() {
        Timber.i("Sync started");

        Intent intent = new Intent(BROADCAST_SYNC_EVENT);
        intent.putExtra(SYNC_EVENT, EVENT_STARTED);

        LocalBroadcastManager localBroadcastManager = LocalBroadcastManager.getInstance(mContext);
        localBroadcastManager.sendBroadcast(intent);
    }

    private void broadcastResults(@SyncResult int worstResult) {
        Timber.i("Sync finished with %d", worstResult);

        Intent intent = new Intent(BROADCAST_SYNC_EVENT);
        intent.putExtra(SYNC_EVENT, EVENT_FINISHED);
        intent.putExtra(BroadcastContract.EXTRA_WORST_RESULT, worstResult);

        LocalBroadcastManager localBroadcastManager = LocalBroadcastManager.getInstance(mContext);
        localBroadcastManager.sendBroadcast(intent);
    }

    /**
     * {@link BroadcastReceiver} that binds sync results to {@link SyncResultListener}
     * <p>
     * This is more stable to API changes in future.
     * </p>
     */
    public static class SyncResultReceiver extends BroadcastReceiver {

        SyncResultListener mListener;

        /**
         * Create new receiver with specific listener
         *
         * @param listener Listener that will receive sync results
         */
        public SyncResultReceiver(@NonNull SyncResultListener listener) {
            mListener = listener;
        }

        @Override
        public void onReceive(Context context, Intent intent) {
            @SyncEvent
            final int event = intent.getIntExtra(SYNC_EVENT, -1);

            switch (event) {
            case EVENT_STARTED:
                mListener.onSyncStarted();
                break;
            case EVENT_FINISHED: {
                @SyncResult
                final int worstResult = intent.getIntExtra(BroadcastContract.EXTRA_WORST_RESULT,
                        BroadcastContract.RESULT_NOT_SUPPORTED);
                mListener.onSyncFinished(worstResult);
                break;
            }
            default:
                throw new UnsupportedOperationException("Unknown event type " + event);
            }
        }
    }

    /**
     * Sync result listener
     */
    public interface SyncResultListener {
        /**
         * Called when sync starts
         */
        void onSyncStarted();

        /**
         * Called when sync is finished
         *
         * @param worstResult The worst plugin result in whole sync (the worst result should be shown
         *                    to user)
         */
        void onSyncFinished(@SyncResult int worstResult);
    }
}