/*
* 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;
}
}
}