package com.aefyr.sai.backup2.impl; import android.app.Notification; import android.app.NotificationChannel; import android.app.NotificationManager; import android.app.PendingIntent; import android.app.Service; import android.content.Context; import android.content.Intent; import android.net.Uri; import android.os.Build; import android.os.Handler; import android.os.HandlerThread; import android.os.IBinder; import android.os.Looper; import android.util.Log; import androidx.annotation.MainThread; import androidx.annotation.Nullable; import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationManagerCompat; import com.aefyr.sai.BuildConfig; import com.aefyr.sai.R; import com.aefyr.sai.backup2.BackupManager; import com.aefyr.sai.backup2.BackupStorage; import com.aefyr.sai.backup2.backuptask.config.BackupTaskConfig; import com.aefyr.sai.backup2.backuptask.config.BatchBackupTaskConfig; import com.aefyr.sai.backup2.backuptask.config.SingleBackupTaskConfig; import com.aefyr.sai.model.common.PackageMeta; import com.aefyr.sai.utils.NotificationHelper; import com.aefyr.sai.utils.Utils; import java.util.HashMap; import java.util.Map; import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicInteger; //TODO make this prettier public class BackupService2 extends Service implements BackupStorage.BackupProgressListener { private static final String TAG = "BackupService"; private static final int NOTIFICATION_ID = 322; private static final String NOTIFICATION_CHANNEL_ID = "backup_service"; private static final int PROGRESS_NOTIFICATION_UPDATE_CD = 500; public static final String ACTION_ENQUEUE_BACKUP = BuildConfig.APPLICATION_ID + ".action.BackupService2.ENQUEUE_BACKUP"; public static final String ACTION_CANCEL_BACKUP = BuildConfig.APPLICATION_ID + ".action.BackupService2.CANCEL_BACKUP"; public static final String EXTRA_STORAGE_ID = "storage_id"; public static final String EXTRA_TASK_TOKEN = "task_token"; public static final String NOTIFICATION_GROUP_BACKUP_ONGOING = BuildConfig.APPLICATION_ID + ".notification_group.BACKUP_ONGOING"; public static final String NOTIFICATION_GROUP_BACKUP_DONE = BuildConfig.APPLICATION_ID + ".notification_group.BACKUP_DONE"; private NotificationHelper mNotificationHelper; private Map<String, BackupTaskInfo> mTasks = new ConcurrentHashMap<>(); private Map<String, BatchBackupTaskInfo> mBatchTasks = new ConcurrentHashMap<>(); private Handler mHandler = new Handler(Looper.getMainLooper()); private HandlerThread mProgressHandlerThread; private Handler mProgressHandler; private BackupManager mBackupManager; private Map<String, AtomicInteger> mStorageDependencies = new HashMap<>(); public static void enqueueBackup(Context c, String storageId, String taskToken) { Intent intent = new Intent(c, BackupService2.class); intent.setAction(ACTION_ENQUEUE_BACKUP); intent.putExtra(EXTRA_STORAGE_ID, storageId); intent.putExtra(EXTRA_TASK_TOKEN, taskToken); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { c.startForegroundService(intent); } else { c.startService(intent); } } @Override public void onCreate() { super.onCreate(); mProgressHandlerThread = new HandlerThread("BackupService2.Progress"); mProgressHandlerThread.start(); mProgressHandler = new Handler(mProgressHandlerThread.getLooper()); mBackupManager = DefaultBackupManager.getInstance(getApplicationContext()); prepareNotificationsStuff(); } @Override public int onStartCommand(Intent intent, int flags, int startId) { switch (Objects.requireNonNull(intent.getAction())) { case ACTION_ENQUEUE_BACKUP: { String storageId = intent.getStringExtra(EXTRA_STORAGE_ID); String taskToken = intent.getStringExtra(EXTRA_TASK_TOKEN); enqueue(storageId, taskToken); break; } case ACTION_CANCEL_BACKUP: String storageId = intent.getStringExtra(EXTRA_STORAGE_ID); String taskToken = intent.getStringExtra(EXTRA_TASK_TOKEN); cancelBackup(storageId, taskToken); break; } return START_NOT_STICKY; } @Nullable @Override public IBinder onBind(Intent intent) { return null; } @Override public void onDestroy() { super.onDestroy(); clearStorageDependencies(); mProgressHandlerThread.quitSafely(); } @MainThread private void cancelBackup(String storageId, String taskToken) { mBackupManager.getBackupStorageProvider(storageId).getStorage().cancelBackupTask(taskToken); } @MainThread private void enqueue(String storageId, String taskToken) { BackupStorage storage = mBackupManager.getBackupStorageProvider(storageId).getStorage(); BackupTaskConfig config = storage.getTaskConfig(taskToken); if (config == null) return; if (config instanceof SingleBackupTaskConfig) { SingleBackupTaskConfig taskConfig = (SingleBackupTaskConfig) config; mTasks.put(taskToken, new BackupTaskInfo(config.getBackupStorageId(), taskConfig.packageMeta(), taskToken, taskToken)); } else if (config instanceof BatchBackupTaskConfig) { mBatchTasks.put(taskToken, new BatchBackupTaskInfo(config.getBackupStorageId(), taskToken, taskToken)); } else { Log.w(TAG, String.format("Got unsupported task config class - %s, task token - %s, ignoring", config.getClass().getCanonicalName(), taskToken)); } addStorageDependency(storageId); updateStatus(); storage.startBackupTask(taskToken); } @MainThread private void taskFinished(String taskTag) { BackupTaskInfo backupTaskInfo = mTasks.remove(taskTag); if (backupTaskInfo != null) { removeStorageDependency(backupTaskInfo.storageId); } BatchBackupTaskInfo batchBackupTaskInfo = mBatchTasks.remove(taskTag); if (batchBackupTaskInfo != null) { removeStorageDependency(batchBackupTaskInfo.storageId); } updateStatus(); } @MainThread private void updateStatus() { if (mTasks.isEmpty() && mBatchTasks.isEmpty()) { die(); } else { startForeground(NOTIFICATION_ID, buildStatusNotification()); } } private void die() { stopForeground(true); stopSelf(); } @MainThread private void addStorageDependency(String storageId) { AtomicInteger dependenciesCount = mStorageDependencies.get(storageId); if (dependenciesCount == null) { dependenciesCount = new AtomicInteger(0); mStorageDependencies.put(storageId, dependenciesCount); } if (dependenciesCount.incrementAndGet() == 1) { mBackupManager.getBackupStorageProvider(storageId).getStorage().addBackupProgressListener(this, mProgressHandler); } } @MainThread private void removeStorageDependency(String storageId) { AtomicInteger dependenciesCount = mStorageDependencies.get(storageId); if (dependenciesCount == null) return; if (dependenciesCount.decrementAndGet() == 0) { mStorageDependencies.remove(storageId); mBackupManager.getBackupStorageProvider(storageId).getStorage().removeBackupProgressListener(this); } } @MainThread private void clearStorageDependencies() { for (String storageId : mStorageDependencies.keySet()) { mBackupManager.getBackupStorageProvider(storageId).getStorage().removeBackupProgressListener(this); } mStorageDependencies.clear(); } private void prepareNotificationsStuff() { NotificationManagerCompat mNotificationManager = NotificationManagerCompat.from(this); mNotificationHelper = NotificationHelper.getInstance(this); if (Utils.apiIsAtLeast(Build.VERSION_CODES.O)) { mNotificationManager.createNotificationChannel(new NotificationChannel(NOTIFICATION_CHANNEL_ID, getString(R.string.backup_backup), NotificationManager.IMPORTANCE_DEFAULT)); } startForeground(NOTIFICATION_ID, buildStatusNotification()); } private Notification buildStatusNotification() { return new NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) .setSmallIcon(R.drawable.ic_backup) .setContentTitle(getString(R.string.backup_backup)) .setContentText(getString(R.string.backup_backup_export_in_progress_2, mTasks.size() + mBatchTasks.size())) .build(); } private void publishProgress(BackupTaskInfo taskInfo, int current, int goal) { if (System.currentTimeMillis() - taskInfo.lastProgressUpdate < PROGRESS_NOTIFICATION_UPDATE_CD) return; taskInfo.lastProgressUpdate = System.currentTimeMillis(); PendingIntent cancelTaskPendingIntent = taskInfo.cachedCancelPendingIntent; if (cancelTaskPendingIntent == null) { Intent cancelTaskIntent = new Intent(this, BackupService2.class); cancelTaskIntent.setData(new Uri.Builder().scheme("cancel").path(taskInfo.taskToken).build()); cancelTaskIntent.setAction(ACTION_CANCEL_BACKUP); cancelTaskIntent.putExtra(EXTRA_STORAGE_ID, taskInfo.storageId); cancelTaskIntent.putExtra(EXTRA_TASK_TOKEN, taskInfo.taskToken); cancelTaskPendingIntent = PendingIntent.getService(this, 0, cancelTaskIntent, PendingIntent.FLAG_UPDATE_CURRENT); taskInfo.cachedCancelPendingIntent = cancelTaskPendingIntent; } Notification notification = new NotificationCompat.Builder(BackupService2.this, NOTIFICATION_CHANNEL_ID) .setOnlyAlertOnce(true) .setWhen(taskInfo.creationTime) .setOngoing(true) .setSmallIcon(R.drawable.ic_backup) .setContentTitle(getString(R.string.backup_backup)) .setProgress(goal, current, false) .setContentText(getString(R.string.backup_backup_in_progress, taskInfo.packageMeta.label)) .addAction(new NotificationCompat.Action(null, getString(R.string.cancel), cancelTaskPendingIntent)) .build(); mNotificationHelper.notify(taskInfo.notificationTag, 0, notification, taskInfo.firstProgressNotificationFired); taskInfo.firstProgressNotificationFired = true; } private void notifyBackupCancelled(BackupTaskInfo taskInfo) { if (taskInfo.cachedCancelPendingIntent != null) taskInfo.cachedCancelPendingIntent.cancel(); mNotificationHelper.cancel(taskInfo.notificationTag, 0); } private void notifyBackupCompleted(BackupTaskInfo taskInfo, boolean successfully) { if (taskInfo.cachedCancelPendingIntent != null) taskInfo.cachedCancelPendingIntent.cancel(); NotificationCompat.Builder builder = new NotificationCompat.Builder(BackupService2.this, NOTIFICATION_CHANNEL_ID) .setWhen(System.currentTimeMillis()) .setOnlyAlertOnce(false) .setOngoing(false) .setSmallIcon(R.drawable.ic_backup) .setContentTitle(getString(R.string.backup_backup)); if (successfully) { builder.setContentText(getString(R.string.backup_backup_success, taskInfo.packageMeta.label)); } else { builder.setContentText(getString(R.string.backup_backup_failed, taskInfo.packageMeta.label)); } mNotificationHelper.notify(taskInfo.notificationTag, 0, builder.build(), false); } @Override public void onBackupTaskStatusChanged(String storageId, BackupStorage.BackupTaskStatus status) { switch (status.state()) { case CREATED: case QUEUED: break; case IN_PROGRESS: int progress = (int) ((float) status.currentProgress() / ((float) status.progressGoal() / 100f)); publishProgress(mTasks.get(status.token()), progress, 100); break; case CANCELLED: notifyBackupCancelled(mTasks.get(status.token())); mHandler.post(() -> taskFinished(status.token())); break; case SUCCEEDED: notifyBackupCompleted(mTasks.get(status.token()), true); mHandler.post(() -> taskFinished(status.token())); break; case FAILED: notifyBackupCompleted(mTasks.get(status.token()), false); mHandler.post(() -> taskFinished(status.token())); break; } } private void publishBatchProgress(BatchBackupTaskInfo taskInfo, int current, int goal, SingleBackupTaskConfig currentBackupConfig) { if (System.currentTimeMillis() - taskInfo.lastProgressUpdate < PROGRESS_NOTIFICATION_UPDATE_CD) return; taskInfo.lastProgressUpdate = System.currentTimeMillis(); PendingIntent cancelTaskPendingIntent = taskInfo.cachedCancelPendingIntent; if (cancelTaskPendingIntent == null) { Intent cancelTaskIntent = new Intent(this, BackupService2.class); cancelTaskIntent.setData(new Uri.Builder().scheme("cancel").path(taskInfo.taskToken).build()); cancelTaskIntent.setAction(ACTION_CANCEL_BACKUP); cancelTaskIntent.putExtra(EXTRA_STORAGE_ID, taskInfo.storageId); cancelTaskIntent.putExtra(EXTRA_TASK_TOKEN, taskInfo.taskToken); cancelTaskPendingIntent = PendingIntent.getService(this, 0, cancelTaskIntent, PendingIntent.FLAG_UPDATE_CURRENT); taskInfo.cachedCancelPendingIntent = cancelTaskPendingIntent; } Notification notification = new NotificationCompat.Builder(BackupService2.this, NOTIFICATION_CHANNEL_ID) .setOnlyAlertOnce(true) .setWhen(taskInfo.creationTime) .setOngoing(true) .setSmallIcon(R.drawable.ic_backup) .setContentTitle(getString(R.string.backup_batch_backup)) .setProgress(goal, current, false) .setContentText(getString(R.string.backup_backup_in_progress, currentBackupConfig.packageMeta().label)) .addAction(new NotificationCompat.Action(null, getString(R.string.cancel), cancelTaskPendingIntent)) .build(); mNotificationHelper.notify(taskInfo.notificationTag, 0, notification, taskInfo.firstProgressNotificationFired); taskInfo.firstProgressNotificationFired = true; } private void notifyBatchBackupCompleted(BatchBackupTaskInfo taskInfo, BackupStorage.BatchBackupTaskStatus status) { if (taskInfo.cachedCancelPendingIntent != null) taskInfo.cachedCancelPendingIntent.cancel(); NotificationCompat.Builder builder = new NotificationCompat.Builder(BackupService2.this, NOTIFICATION_CHANNEL_ID) .setWhen(System.currentTimeMillis()) .setOnlyAlertOnce(false) .setOngoing(false) .setSmallIcon(R.drawable.ic_backup) .setContentTitle(getString(R.string.backup_batch_backup_completed)) .setStyle(new NotificationCompat.BigTextStyle()); StringBuilder resultSb = new StringBuilder(); if (!status.succeededBackups().isEmpty()) { appendWithNewLine(resultSb, getString(R.string.backup_batch_backup_result_succeeded, status.succeededBackups().size())); } if (!status.failedBackups().isEmpty()) { appendWithNewLine(resultSb, getString(R.string.backup_batch_backup_result_failed, status.failedBackups().size())); } if ((status.state() == BackupStorage.BackupTaskState.CANCELLED || status.state() == BackupStorage.BackupTaskState.FAILED) && !status.cancelledBackups().isEmpty()) { appendWithNewLine(resultSb, getString(R.string.backup_batch_backup_result_cancelled, status.cancelledBackups().size())); } builder.setContentText(resultSb.toString()); mNotificationHelper.notify(taskInfo.notificationTag, 0, builder.build(), false); } private void appendWithNewLine(StringBuilder sb, CharSequence text) { if (sb.length() > 0) sb.append("\n"); sb.append(text); } @Override public void onBatchBackupTaskStatusChanged(String storageId, BackupStorage.BatchBackupTaskStatus status) { switch (status.state()) { case CREATED: case QUEUED: break; case IN_PROGRESS: publishBatchProgress(mBatchTasks.get(status.token()), status.completedBackupsCount(), status.totalBackupsCount(), status.currentConfig()); break; case CANCELLED: case SUCCEEDED: case FAILED: notifyBatchBackupCompleted(mBatchTasks.get(status.token()), status); mHandler.post(() -> taskFinished(status.token())); break; } } private static class BackupTaskInfo { String storageId; PackageMeta packageMeta; String taskToken; String notificationTag; long lastProgressUpdate = 0; long creationTime = System.currentTimeMillis(); PendingIntent cachedCancelPendingIntent; boolean firstProgressNotificationFired = false; private BackupTaskInfo(String storageId, PackageMeta packageMeta, String taskToken, String notificationTag) { this.storageId = storageId; this.packageMeta = packageMeta; this.taskToken = taskToken; this.notificationTag = notificationTag; } } private static class BatchBackupTaskInfo { String storageId; String taskToken; String notificationTag; long lastProgressUpdate = 0; long creationTime = System.currentTimeMillis(); PendingIntent cachedCancelPendingIntent; boolean firstProgressNotificationFired = false; private BatchBackupTaskInfo(String storageId, String taskToken, String notificationTag) { this.storageId = storageId; this.taskToken = taskToken; this.notificationTag = notificationTag; } } }