io.github.hidroh.materialistic.data.SyncDelegate.java Source code

Java tutorial

Introduction

Here is the source code for io.github.hidroh.materialistic.data.SyncDelegate.java

Source

/*
 * Copyright (c) 2016 Ha Duy Trung
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package io.github.hidroh.materialistic.data;

import android.accounts.Account;
import android.accounts.AccountManager;
import android.annotation.SuppressLint;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.job.JobInfo;
import android.app.job.JobScheduler;
import android.content.ComponentName;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.graphics.BitmapFactory;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import android.os.PersistableBundle;
import android.os.Process;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.annotation.RequiresApi;
import android.support.annotation.UiThread;
import android.support.annotation.VisibleForTesting;
import android.support.v4.app.NotificationCompat;
import android.text.TextUtils;
import android.text.format.DateUtils;
import android.webkit.WebView;

import java.io.IOException;
import java.util.Set;
import java.util.concurrent.Executor;

import javax.inject.Inject;

import io.github.hidroh.materialistic.AppUtils;
import io.github.hidroh.materialistic.BuildConfig;
import io.github.hidroh.materialistic.ItemActivity;
import io.github.hidroh.materialistic.Preferences;
import io.github.hidroh.materialistic.R;
import io.github.hidroh.materialistic.annotation.Synthetic;
import io.github.hidroh.materialistic.widget.AdBlockWebViewClient;
import io.github.hidroh.materialistic.widget.CacheableWebView;
import retrofit2.Call;
import retrofit2.Callback;

public class SyncDelegate {
    static final String SYNC_PREFERENCES_FILE = "_syncpreferences";
    private static final String NOTIFICATION_GROUP_KEY = "group";
    private static final String SYNC_ACCOUNT_NAME = "Materialistic";
    private static final long TIMEOUT_MILLIS = DateUtils.MINUTE_IN_MILLIS;

    private final HackerNewsClient.RestService mHnRestService;
    private final ReadabilityClient mReadabilityClient;
    private final SharedPreferences mSharedPreferences;
    private final NotificationManager mNotificationManager;
    private final NotificationCompat.Builder mNotificationBuilder;
    private final Handler mHandler = new Handler();
    private SyncProgress mSyncProgress;
    private final Context mContext;
    private ProgressListener mListener;
    private Job mJob;
    @VisibleForTesting
    CacheableWebView mWebView;

    @Inject
    SyncDelegate(Context context, RestServiceFactory factory, ReadabilityClient readabilityClient) {
        mContext = context;
        mSharedPreferences = context.getSharedPreferences(context.getPackageName() + SYNC_PREFERENCES_FILE,
                Context.MODE_PRIVATE);
        mHnRestService = factory.create(HackerNewsClient.BASE_API_URL, HackerNewsClient.RestService.class,
                new BackgroundThreadExecutor());
        mReadabilityClient = readabilityClient;
        mNotificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
        mNotificationBuilder = new NotificationCompat.Builder(context)
                .setLargeIcon(BitmapFactory.decodeResource(context.getResources(), R.mipmap.ic_launcher))
                .setSmallIcon(R.drawable.ic_notification).setGroup(NOTIFICATION_GROUP_KEY)
                .setCategory(NotificationCompat.CATEGORY_PROGRESS).setAutoCancel(true);
    }

    @UiThread
    public static void scheduleSync(Context context, Job job) {
        if (!Preferences.Offline.isEnabled(context)) {
            return;
        }
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && !TextUtils.isEmpty(job.id)) {
            JobInfo.Builder builder = new JobInfo.Builder(Long.valueOf(job.id).intValue(),
                    new ComponentName(context.getPackageName(), ItemSyncJobService.class.getName()))
                            .setRequiredNetworkType(
                                    Preferences.Offline.isWifiOnly(context) ? JobInfo.NETWORK_TYPE_UNMETERED
                                            : JobInfo.NETWORK_TYPE_ANY)
                            .setExtras(job.toPersistableBundle());
            if (Preferences.Offline.currentConnectionEnabled(context)) {
                builder.setOverrideDeadline(0);
            }
            ((JobScheduler) context.getSystemService(Context.JOB_SCHEDULER_SERVICE)).schedule(builder.build());
        } else {
            Bundle extras = new Bundle(job.toBundle());
            extras.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true);
            Account syncAccount;
            AccountManager accountManager = AccountManager.get(context);
            Account[] accounts = accountManager.getAccountsByType(BuildConfig.APPLICATION_ID);
            if (accounts.length == 0) {
                syncAccount = new Account(SYNC_ACCOUNT_NAME, BuildConfig.APPLICATION_ID);
                accountManager.addAccountExplicitly(syncAccount, null, null);
            } else {
                syncAccount = accounts[0];
            }
            ContentResolver.requestSync(syncAccount, MaterialisticProvider.PROVIDER_AUTHORITY, extras);
        }
    }

    void subscribe(ProgressListener listener) {
        mListener = listener;
    }

    void performSync(@NonNull Job job) {
        // assume that connection wouldn't change until we finish syncing
        mJob = job;
        if (!TextUtils.isEmpty(mJob.id)) {
            Message message = Message.obtain(mHandler, this::stopSync);
            message.what = Integer.valueOf(mJob.id);
            mHandler.sendMessageDelayed(message, TIMEOUT_MILLIS);
            mSyncProgress = new SyncProgress(mJob);
            sync(mJob.id);
        } else {
            syncDeferredItems();
        }
    }

    private void syncDeferredItems() {
        Set<String> itemIds = mSharedPreferences.getAll().keySet();
        for (String itemId : itemIds) {
            scheduleSync(mContext, new JobBuilder(mContext, itemId).setNotificationEnabled(false).build());
        }
    }

    private void sync(String itemId) {
        if (!mJob.connectionEnabled) {
            defer(itemId);
            return;
        }
        HackerNewsItem cachedItem;
        if ((cachedItem = getFromCache(itemId)) != null) {
            sync(cachedItem);
        } else {
            updateProgress();
            // TODO defer on low battery as well?
            mHnRestService.networkItem(itemId).enqueue(new Callback<HackerNewsItem>() {
                @Override
                public void onResponse(Call<HackerNewsItem> call, retrofit2.Response<HackerNewsItem> response) {
                    HackerNewsItem item;
                    if ((item = response.body()) != null) {
                        sync(item);
                    }
                }

                @Override
                public void onFailure(Call<HackerNewsItem> call, Throwable t) {
                    notifyItem(itemId, null);
                }
            });
        }
    }

    @Synthetic
    void sync(@NonNull HackerNewsItem item) {
        mSharedPreferences.edit().remove(item.getId()).apply();
        notifyItem(item.getId(), item);
        syncReadability(item);
        syncArticle(item);
        syncChildren(item);
    }

    private void syncReadability(@NonNull HackerNewsItem item) {
        if (mJob.readabilityEnabled && item.isStoryType()) {
            final String itemId = item.getId();
            mReadabilityClient.parse(itemId, item.getRawUrl(), content -> notifyReadability());
        }
    }

    private void syncArticle(@NonNull HackerNewsItem item) {
        if (mJob.articleEnabled && item.isStoryType() && !TextUtils.isEmpty(item.getUrl())) {
            if (Looper.myLooper() == Looper.getMainLooper()) {
                loadArticle(item);
            } else {
                mContext.startService(new Intent(mContext, WebCacheService.class)
                        .putExtra(WebCacheService.EXTRA_URL, item.getUrl()));
                notifyArticle(100);
            }
        }
    }

    private void loadArticle(@NonNull final HackerNewsItem item) {
        mWebView = new CacheableWebView(mContext);
        mWebView.setWebViewClient(new AdBlockWebViewClient(Preferences.adBlockEnabled(mContext)));
        mWebView.setWebChromeClient(new CacheableWebView.ArchiveClient() {
            @Override
            public void onProgressChanged(WebView view, int newProgress) {
                super.onProgressChanged(view, newProgress);
                notifyArticle(newProgress);
            }
        });
        notifyArticle(0);
        mWebView.loadUrl(item.getUrl());
    }

    private void syncChildren(@NonNull HackerNewsItem item) {
        if (mJob.commentsEnabled && item.getKids() != null) {
            for (long id : item.getKids()) {
                sync(String.valueOf(id));
            }
        }
    }

    private void defer(String itemId) {
        mSharedPreferences.edit().putBoolean(itemId, true).apply();
    }

    private HackerNewsItem getFromCache(String itemId) {
        try {
            return mHnRestService.cachedItem(itemId).execute().body();
        } catch (IOException e) {
            return null;
        }
    }

    @Synthetic
    void notifyItem(@NonNull String id, @Nullable HackerNewsItem item) {
        mSyncProgress.finishItem(id, item, mJob.commentsEnabled && mJob.connectionEnabled,
                mJob.readabilityEnabled && mJob.connectionEnabled);
        updateProgress();
    }

    private void notifyReadability() {
        mSyncProgress.finishReadability();
        updateProgress();
    }

    @Synthetic
    void notifyArticle(int newProgress) {
        mSyncProgress.updateArticle(newProgress, 100);
        updateProgress();
    }

    private void updateProgress() {
        if (mSyncProgress.getProgress() >= mSyncProgress.getMax()) { // TODO may never done
            finish(); // TODO finish once only
        } else if (mJob.notificationEnabled) {
            showProgress();
        }
    }

    private void showProgress() {
        mNotificationManager.notify(Integer.valueOf(mJob.id),
                mNotificationBuilder.setContentTitle(mSyncProgress.title)
                        .setContentText(mContext.getString(R.string.download_in_progress))
                        .setContentIntent(getItemActivity(mJob.id))
                        .setProgress(mSyncProgress.getMax(), mSyncProgress.getProgress(), false).setSortKey(mJob.id)
                        .build());
    }

    private void finish() {
        if (mListener != null) {
            mListener.onDone(mJob.id);
            mListener = null;
        }
        stopSync();
    }

    void stopSync() {
        // TODO
        mJob.connectionEnabled = false;
        int id = Integer.valueOf(mJob.id);
        mNotificationManager.cancel(id);
        mHandler.removeMessages(id);
    }

    private PendingIntent getItemActivity(String itemId) {
        return PendingIntent.getActivity(mContext, 0,
                new Intent(Intent.ACTION_VIEW).setData(AppUtils.createItemUri(itemId))
                        .putExtra(ItemActivity.EXTRA_CACHE_MODE, ItemManager.MODE_CACHE)
                        .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
                PendingIntent.FLAG_ONE_SHOT);
    }

    private static class SyncProgress {
        private final String id;
        private Boolean self;
        private int totalKids, finishedKids, webProgress, maxWebProgress;
        private Boolean readability;
        String title;

        @Synthetic
        SyncProgress(Job job) {
            this.id = job.id;
            if (job.commentsEnabled) {
                totalKids = 1;
            }
            if (job.articleEnabled) {
                maxWebProgress = 100;
            }
            if (job.readabilityEnabled) {
                readability = false;
            }
        }

        int getMax() {
            return 1 + totalKids + (readability != null ? 1 : 0) + maxWebProgress;
        }

        int getProgress() {
            return (self != null ? 1 : 0) + finishedKids + (readability != null && readability ? 1 : 0)
                    + webProgress;
        }

        @Synthetic
        void finishItem(@NonNull String id, @Nullable HackerNewsItem item, boolean kidsEnabled,
                boolean readabilityEnabled) {
            if (TextUtils.equals(id, this.id)) {
                finishSelf(item, kidsEnabled, readabilityEnabled);
            } else {
                finishKid();
            }
        }

        @Synthetic
        void finishReadability() {
            readability = true;
        }

        @Synthetic
        void updateArticle(int webProgress, int maxWebProgress) {
            this.webProgress = webProgress;
            this.maxWebProgress = maxWebProgress;
        }

        private void finishSelf(@Nullable HackerNewsItem item, boolean kidsEnabled, boolean readabilityEnabled) {
            self = item != null;
            title = item != null ? item.getTitle() : null;
            if (kidsEnabled && item != null && item.getKids() != null) {
                // fetch recursively but only notify for immediate children
                totalKids = item.getKids().length;
            } else {
                totalKids = 0;
            }
            if (readabilityEnabled) {
                readability = false;
            }
        }

        private void finishKid() {
            finishedKids++;
        }
    }

    private static class BackgroundThreadExecutor implements Executor {

        @Synthetic
        BackgroundThreadExecutor() {
        }

        @Override
        public void execute(@NonNull Runnable r) {
            Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND);
            r.run();
        }
    }

    interface ProgressListener {
        void onDone(String token);
    }

    static class Job {
        private static final String EXTRA_ID = "extra:id";
        private static final String EXTRA_CONNECTION_ENABLED = "extra:connectionEnabled";
        private static final String EXTRA_READABILITY_ENABLED = "extra:readabilityEnabled";
        private static final String EXTRA_ARTICLE_ENABLED = "extra:articleEnabled";
        private static final String EXTRA_COMMENTS_ENABLED = "extra:commentsEnabled";
        private static final String EXTRA_NOTIFICATION_ENABLED = "extra:notificationEnabled";
        final String id;
        boolean connectionEnabled;
        boolean readabilityEnabled;
        boolean articleEnabled;
        boolean commentsEnabled;
        boolean notificationEnabled;

        Job(String id) {
            this.id = id;
        }

        @SuppressLint("NewApi") // TODO http://b.android.com/225519
        @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
        Job(PersistableBundle bundle) {
            id = bundle.getString(EXTRA_ID);
            connectionEnabled = bundle.getBoolean(EXTRA_CONNECTION_ENABLED);
            readabilityEnabled = bundle.getBoolean(EXTRA_READABILITY_ENABLED);
            articleEnabled = bundle.getBoolean(EXTRA_ARTICLE_ENABLED);
            commentsEnabled = bundle.getBoolean(EXTRA_COMMENTS_ENABLED);
            notificationEnabled = bundle.getBoolean(EXTRA_NOTIFICATION_ENABLED);
        }

        Job(Bundle bundle) {
            id = bundle.getString(EXTRA_ID);
            connectionEnabled = bundle.getBoolean(EXTRA_CONNECTION_ENABLED);
            readabilityEnabled = bundle.getBoolean(EXTRA_READABILITY_ENABLED);
            articleEnabled = bundle.getBoolean(EXTRA_ARTICLE_ENABLED);
            commentsEnabled = bundle.getBoolean(EXTRA_COMMENTS_ENABLED);
            notificationEnabled = bundle.getBoolean(EXTRA_NOTIFICATION_ENABLED);
        }

        @SuppressLint("NewApi") // TODO http://b.android.com/225519
        @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
        @Synthetic
        PersistableBundle toPersistableBundle() {
            PersistableBundle bundle = new PersistableBundle();
            bundle.putString(EXTRA_ID, id);
            bundle.putBoolean(EXTRA_CONNECTION_ENABLED, connectionEnabled);
            bundle.putBoolean(EXTRA_READABILITY_ENABLED, readabilityEnabled);
            bundle.putBoolean(EXTRA_ARTICLE_ENABLED, articleEnabled);
            bundle.putBoolean(EXTRA_COMMENTS_ENABLED, commentsEnabled);
            bundle.putBoolean(EXTRA_NOTIFICATION_ENABLED, notificationEnabled);
            return bundle;
        }

        @Synthetic
        Bundle toBundle() {
            Bundle bundle = new Bundle();
            bundle.putString(EXTRA_ID, id);
            bundle.putBoolean(EXTRA_CONNECTION_ENABLED, connectionEnabled);
            bundle.putBoolean(EXTRA_READABILITY_ENABLED, readabilityEnabled);
            bundle.putBoolean(EXTRA_ARTICLE_ENABLED, articleEnabled);
            bundle.putBoolean(EXTRA_COMMENTS_ENABLED, commentsEnabled);
            bundle.putBoolean(EXTRA_NOTIFICATION_ENABLED, notificationEnabled);
            return bundle;
        }
    }

    public static class JobBuilder {
        private final Job job;

        public JobBuilder(Context context, String id) {
            job = new Job(id);
            setConnectionEnabled(Preferences.Offline.currentConnectionEnabled(context));
            setReadabilityEnabled(Preferences.Offline.isReadabilityEnabled(context));
            setArticleEnabled(Preferences.Offline.isArticleEnabled(context));
            setCommentsEnabled(Preferences.Offline.isCommentsEnabled(context));
            setNotificationEnabled(Preferences.Offline.isNotificationEnabled(context));
        }

        JobBuilder setConnectionEnabled(boolean connectionEnabled) {
            job.connectionEnabled = connectionEnabled;
            return this;
        }

        JobBuilder setReadabilityEnabled(boolean readabilityEnabled) {
            job.readabilityEnabled = readabilityEnabled;
            return this;
        }

        JobBuilder setArticleEnabled(boolean articleEnabled) {
            job.articleEnabled = articleEnabled;
            return this;
        }

        JobBuilder setCommentsEnabled(boolean commentsEnabled) {
            job.commentsEnabled = commentsEnabled;
            return this;
        }

        public JobBuilder setNotificationEnabled(boolean notificationEnabled) {
            job.notificationEnabled = notificationEnabled;
            return this;
        }

        public Job build() {
            return job;
        }
    }
}