From 540147470d8d60dedda06ae98ecdd8dfe1173866 Mon Sep 17 00:00:00 2001
From: Torsten Grote <t@grobox.de>
Date: Fri, 7 Jun 2019 16:41:48 -0300
Subject: [PATCH] Allow the user to schedule full background backups

---
 app/src/main/AndroidManifest.xml              |  7 ++
 .../java/com/stevesoltys/backup/Backup.java   |  3 +
 .../backup/activity/MainActivity.java         | 12 +++
 .../activity/MainActivityController.java      | 37 +++++++-
 .../service/backup/BackupJobService.java      | 89 +++++++++++++++++++
 .../backup/settings/SettingsManager.java      | 13 +++
 app/src/main/res/layout/activity_main.xml     |  9 ++
 7 files changed, 167 insertions(+), 3 deletions(-)
 create mode 100644 app/src/main/java/com/stevesoltys/backup/service/backup/BackupJobService.java

diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index eb5ca293..0a6050ca 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -14,6 +14,8 @@
         android:name="android.permission.BACKUP"
         tools:ignore="ProtectedPermissions" />
 
+    <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
+
     <application
         android:name=".Backup"
         android:supportsRtl="true"
@@ -48,5 +50,10 @@
             </intent-filter>
         </service>
 
+        <service
+            android:name=".service.backup.BackupJobService"
+            android:exported="false"
+            android:permission="android.permission.BIND_JOB_SERVICE" />
+
     </application>
 </manifest>
diff --git a/app/src/main/java/com/stevesoltys/backup/Backup.java b/app/src/main/java/com/stevesoltys/backup/Backup.java
index d8989f78..00c1c7c5 100644
--- a/app/src/main/java/com/stevesoltys/backup/Backup.java
+++ b/app/src/main/java/com/stevesoltys/backup/Backup.java
@@ -2,6 +2,7 @@ package com.stevesoltys.backup;
 
 import android.app.Application;
 import android.content.Intent;
+
 import com.stevesoltys.backup.transport.ConfigurableBackupTransportService;
 
 /**
@@ -9,6 +10,8 @@ import com.stevesoltys.backup.transport.ConfigurableBackupTransportService;
  */
 public class Backup extends Application {
 
+    public static final int JOB_ID_BACKGROUND_BACKUP = 1;
+
     @Override
     public void onCreate() {
         super.onCreate();
diff --git a/app/src/main/java/com/stevesoltys/backup/activity/MainActivity.java b/app/src/main/java/com/stevesoltys/backup/activity/MainActivity.java
index 64b06014..3d666957 100644
--- a/app/src/main/java/com/stevesoltys/backup/activity/MainActivity.java
+++ b/app/src/main/java/com/stevesoltys/backup/activity/MainActivity.java
@@ -11,6 +11,7 @@ import com.stevesoltys.backup.R;
 
 import static android.view.View.GONE;
 import static android.view.View.VISIBLE;
+import static com.stevesoltys.backup.settings.SettingsManager.areBackupsScheduled;
 
 public class MainActivity extends Activity implements View.OnClickListener {
 
@@ -21,6 +22,7 @@ public class MainActivity extends Activity implements View.OnClickListener {
     public static final int LOAD_DOCUMENT_REQUEST_CODE = 3;
 
     private MainActivityController controller;
+    private Button automaticBackupsButton;
     private Button changeLocationButton;
 
     @Override
@@ -33,6 +35,10 @@ public class MainActivity extends Activity implements View.OnClickListener {
         findViewById(R.id.create_backup_button).setOnClickListener(this);
         findViewById(R.id.restore_backup_button).setOnClickListener(this);
 
+        automaticBackupsButton = findViewById(R.id.automatic_backups_button);
+        automaticBackupsButton.setOnClickListener(this);
+        if (areBackupsScheduled(this)) automaticBackupsButton.setVisibility(GONE);
+
         changeLocationButton = findViewById(R.id.change_backup_location_button);
         changeLocationButton.setOnClickListener(this);
     }
@@ -61,6 +67,12 @@ public class MainActivity extends Activity implements View.OnClickListener {
                 controller.showLoadDocumentActivity(this);
                 break;
 
+            case R.id.automatic_backups_button:
+                if (controller.onAutomaticBackupsButtonClicked(this)) {
+                    automaticBackupsButton.setVisibility(GONE);
+                }
+                break;
+
             case R.id.change_backup_location_button:
                 controller.onChangeBackupLocationButtonClicked(this);
                 break;
diff --git a/app/src/main/java/com/stevesoltys/backup/activity/MainActivityController.java b/app/src/main/java/com/stevesoltys/backup/activity/MainActivityController.java
index c92ba84d..0c7d6c6a 100644
--- a/app/src/main/java/com/stevesoltys/backup/activity/MainActivityController.java
+++ b/app/src/main/java/com/stevesoltys/backup/activity/MainActivityController.java
@@ -1,7 +1,10 @@
 package com.stevesoltys.backup.activity;
 
 import android.app.Activity;
+import android.app.job.JobInfo;
+import android.app.job.JobScheduler;
 import android.content.ActivityNotFoundException;
+import android.content.ComponentName;
 import android.content.ContentResolver;
 import android.content.Intent;
 import android.net.Uri;
@@ -10,12 +13,14 @@ import android.widget.Toast;
 
 import com.stevesoltys.backup.activity.backup.CreateBackupActivity;
 import com.stevesoltys.backup.activity.restore.RestoreBackupActivity;
+import com.stevesoltys.backup.service.backup.BackupJobService;
 
 import java.io.IOException;
 import java.text.SimpleDateFormat;
 import java.util.Date;
 import java.util.Locale;
 
+import static android.app.job.JobInfo.NETWORK_TYPE_UNMETERED;
 import static android.content.Intent.ACTION_OPEN_DOCUMENT;
 import static android.content.Intent.ACTION_OPEN_DOCUMENT_TREE;
 import static android.content.Intent.CATEGORY_OPENABLE;
@@ -25,16 +30,21 @@ import static android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION;
 import static android.provider.DocumentsContract.buildDocumentUriUsingTree;
 import static android.provider.DocumentsContract.createDocument;
 import static android.provider.DocumentsContract.getTreeDocumentId;
+import static com.stevesoltys.backup.Backup.JOB_ID_BACKGROUND_BACKUP;
 import static com.stevesoltys.backup.activity.MainActivity.OPEN_DOCUMENT_TREE_BACKUP_REQUEST_CODE;
 import static com.stevesoltys.backup.activity.MainActivity.OPEN_DOCUMENT_TREE_REQUEST_CODE;
 import static com.stevesoltys.backup.settings.SettingsManager.getBackupFolderUri;
+import static com.stevesoltys.backup.settings.SettingsManager.getBackupPassword;
 import static com.stevesoltys.backup.settings.SettingsManager.setBackupFolderUri;
+import static com.stevesoltys.backup.settings.SettingsManager.setBackupsScheduled;
+import static java.util.Objects.requireNonNull;
+import static java.util.concurrent.TimeUnit.DAYS;
 
 /**
  * @author Steve Soltys
  * @author Torsten Grote
  */
-class MainActivityController {
+public class MainActivityController {
 
     private static final String TAG = MainActivityController.class.getName();
 
@@ -90,6 +100,27 @@ class MainActivityController {
         }
     }
 
+    boolean onAutomaticBackupsButtonClicked(Activity parent) {
+        if (getBackupFolderUri(parent) == null || getBackupPassword(parent) == null) {
+            Toast.makeText(parent, "Please make at least one manual backup first.", Toast.LENGTH_SHORT).show();
+            return false;
+        }
+        final ComponentName serviceName = new ComponentName(parent, BackupJobService.class);
+        JobInfo job = new JobInfo.Builder(JOB_ID_BACKGROUND_BACKUP, serviceName)
+                .setRequiredNetworkType(NETWORK_TYPE_UNMETERED)
+                .setRequiresBatteryNotLow(true)
+                .setRequiresStorageNotLow(true)  // TODO warn the user instead
+                .setPeriodic(DAYS.toMillis(1))
+                .setRequiresCharging(true)
+                .setPersisted(true)
+                .build();
+        JobScheduler scheduler = requireNonNull(parent.getSystemService(JobScheduler.class));
+        scheduler.schedule(job);
+        setBackupsScheduled(parent);
+        Toast.makeText(parent, "Backups will run automatically now", Toast.LENGTH_SHORT).show();
+        return true;
+    }
+
     void onChangeBackupLocationButtonClicked(Activity parent) {
         showChooseFolderActivity(parent, false);
     }
@@ -143,7 +174,7 @@ class MainActivityController {
         parent.startActivity(intent);
     }
 
-    private Uri createBackupFile(ContentResolver contentResolver, Uri folderUri) throws IOException {
+    public static Uri createBackupFile(ContentResolver contentResolver, Uri folderUri) throws IOException {
         Uri documentUri = buildDocumentUriUsingTree(folderUri, getTreeDocumentId(folderUri));
         try {
             Uri fileUri = createDocument(contentResolver, documentUri, DOCUMENT_MIME_TYPE, getBackupFileName());
@@ -156,7 +187,7 @@ class MainActivityController {
         }
     }
 
-    private String getBackupFileName() {
+    private static String getBackupFileName() {
         SimpleDateFormat dateFormat = new SimpleDateFormat(DOCUMENT_SUFFIX, Locale.US);
         String date = dateFormat.format(new Date());
         return "backup-" + date;
diff --git a/app/src/main/java/com/stevesoltys/backup/service/backup/BackupJobService.java b/app/src/main/java/com/stevesoltys/backup/service/backup/BackupJobService.java
new file mode 100644
index 00000000..ab74da64
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/service/backup/BackupJobService.java
@@ -0,0 +1,89 @@
+package com.stevesoltys.backup.service.backup;
+
+import android.app.backup.BackupManager;
+import android.app.backup.IBackupManager;
+import android.app.job.JobParameters;
+import android.app.job.JobService;
+import android.net.Uri;
+import android.os.RemoteException;
+import android.util.Log;
+
+import com.google.android.collect.Sets;
+import com.stevesoltys.backup.service.PackageService;
+import com.stevesoltys.backup.service.TransportService;
+import com.stevesoltys.backup.transport.component.provider.ContentProviderBackupConfiguration;
+import com.stevesoltys.backup.transport.component.provider.ContentProviderBackupConfigurationBuilder;
+
+import java.io.IOException;
+import java.util.HashSet;
+import java.util.LinkedList;
+import java.util.Set;
+
+import static android.app.backup.BackupManager.FLAG_NON_INCREMENTAL_BACKUP;
+import static android.os.ServiceManager.getService;
+import static com.stevesoltys.backup.activity.MainActivityController.createBackupFile;
+import static com.stevesoltys.backup.settings.SettingsManager.getBackupFolderUri;
+import static com.stevesoltys.backup.settings.SettingsManager.getBackupPassword;
+
+public class BackupJobService extends JobService {
+
+    private final static String TAG = BackupJobService.class.getName();
+
+    private static final Set<String> IGNORED_PACKAGES = Sets.newArraySet(
+            "com.android.providers.downloads.ui", "com.android.providers.downloads", "com.android.providers.media",
+            "com.android.providers.calendar", "com.android.providers.contacts", "com.stevesoltys.backup"
+    );
+
+    private final IBackupManager backupManager;
+    private final TransportService transportService = new TransportService();
+
+    public BackupJobService() {
+        backupManager = IBackupManager.Stub.asInterface(getService("backup"));
+    }
+
+    @Override
+    public boolean onStartJob(JobParameters params) {
+        Log.i(TAG, "Triggering full backup");
+        try {
+            LinkedList<String> packages = new LinkedList<>(new PackageService().getEligiblePackages());
+            packages.removeAll(IGNORED_PACKAGES);
+            Uri fileUri = createBackupFile(getContentResolver(), getBackupFolderUri(this));
+            ContentProviderBackupConfiguration backupConfiguration = new ContentProviderBackupConfigurationBuilder()
+                    .setContext(this)
+                    .setPackages(new HashSet<>(packages))
+                    .setOutputUri(fileUri)
+                    .setPassword(getBackupPassword(this))
+                    .build();
+            transportService.initializeBackupTransport(backupConfiguration);
+
+            // TODO use an observer to know when backups fail
+            String[] packageArray = packages.toArray(new String[packages.size()]);
+            int result = backupManager.requestBackup(packageArray, null, null, FLAG_NON_INCREMENTAL_BACKUP);
+            if (result == BackupManager.SUCCESS) {
+                Log.i(TAG, "Backup succeeded ");
+            } else {
+                Log.e(TAG, "Backup failed: " + result);
+            }
+
+        // TODO show notification on backup error
+        } catch (IOException e) {
+            Log.e(TAG, "Error creating backup file: ", e);
+        } catch (RemoteException e) {
+            Log.e(TAG, "Error during backup: ", e);
+        } finally {
+            jobFinished(params, false);
+        }
+        return true;
+    }
+
+    @Override
+    public boolean onStopJob(JobParameters params) {
+        try {
+            backupManager.cancelBackups();
+        } catch (RemoteException e) {
+            Log.e(TAG, "Error cancelling backup: ", e);
+        }
+        return true;
+    }
+
+}
diff --git a/app/src/main/java/com/stevesoltys/backup/settings/SettingsManager.java b/app/src/main/java/com/stevesoltys/backup/settings/SettingsManager.java
index d493765d..fa4b41ad 100644
--- a/app/src/main/java/com/stevesoltys/backup/settings/SettingsManager.java
+++ b/app/src/main/java/com/stevesoltys/backup/settings/SettingsManager.java
@@ -10,6 +10,7 @@ public class SettingsManager {
 
     private static final String PREF_KEY_BACKUP_URI = "backupUri";
     private static final String PREF_KEY_BACKUP_PASSWORD = "backupLegacyPassword";
+    private static final String PREF_KEY_BACKUPS_SCHEDULED = "backupsScheduled";
 
     public static void setBackupFolderUri(Context context, Uri uri) {
         getDefaultSharedPreferences(context)
@@ -41,4 +42,16 @@ public class SettingsManager {
         return getDefaultSharedPreferences(context).getString(PREF_KEY_BACKUP_PASSWORD, null);
     }
 
+    public static void setBackupsScheduled(Context context) {
+        getDefaultSharedPreferences(context)
+                .edit()
+                .putBoolean(PREF_KEY_BACKUPS_SCHEDULED, true)
+                .apply();
+    }
+
+    @Nullable
+    public static Boolean areBackupsScheduled(Context context) {
+        return getDefaultSharedPreferences(context).getBoolean(PREF_KEY_BACKUPS_SCHEDULED, false);
+    }
+
 }
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 705c21a7..aaf4c790 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -25,6 +25,15 @@
         android:layout_marginRight="@dimen/button_horizontal_margin"
         android:text="@string/restore_backup_button" />
 
+    <Button
+        android:id="@+id/automatic_backups_button"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="48dp"
+        android:layout_marginLeft="@dimen/button_horizontal_margin"
+        android:layout_marginRight="@dimen/button_horizontal_margin"
+        android:text="Activate automatic backups" />
+
     <Button
         android:id="@+id/change_backup_location_button"
         android:layout_width="match_parent"