diff --git a/PLAYSTORE.txt b/PLAYSTORE.txt
index 7c533f5b7e..818846a224 100644
--- a/PLAYSTORE.txt
+++ b/PLAYSTORE.txt
@@ -64,7 +64,7 @@ This app starts a foreground service with a low-priority status bar notification
* No special permissions required
* No advertisements
* No analytics and no tracking (error reporting via Bugsnag is opt-in)
-* No Google backup
+* Optional Google backup
* No Firebase Cloud Messaging
* FairEmail is an original work, not a fork or a clone
diff --git a/README.md b/README.md
index e1f8e357db..1045c640ea 100644
--- a/README.md
+++ b/README.md
@@ -75,7 +75,7 @@ This app starts a foreground service with a low-priority status bar notification
* No special permissions required
* No advertisements
* No analytics and no tracking ([error reporting](https://m66b.github.io/FairEmail/#faq104) via Bugsnag is opt-in)
-* No [Google backup](https://developer.android.com/guide/topics/data/backup)
+* Optional [Google backup](https://developer.android.com/guide/topics/data/backup)
* No [Firebase Cloud Messaging](https://firebase.google.com/docs/cloud-messaging)
* FairEmail is an original work, not a fork or a clone
diff --git a/app/src/amazon/AndroidManifest.xml b/app/src/amazon/AndroidManifest.xml
index 172851a974..5d323278e2 100644
--- a/app/src/amazon/AndroidManifest.xml
+++ b/app/src/amazon/AndroidManifest.xml
@@ -112,8 +112,9 @@
.
+
+ Copyright 2018-2024 by Marcel Bokhorst (M66B)
+*/
+
+import android.app.backup.BackupAgent;
+import android.app.backup.BackupDataInput;
+import android.app.backup.BackupDataOutput;
+import android.app.backup.BackupManager;
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.os.ParcelFileDescriptor;
+import android.text.TextUtils;
+
+import androidx.preference.PreferenceManager;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Objects;
+
+public class FairEmailBackupAgent extends BackupAgent {
+ // https://developer.android.com/identity/data/keyvaluebackup#BackupAgent
+ // https://developer.android.com/identity/data/testingbackup#Preparing
+
+ private static final String KEY_JSON = "eu.faircode.email.json";
+
+ @Override
+ public void onBackup(ParcelFileDescriptor oldState, BackupDataOutput data, ParcelFileDescriptor newState) throws IOException {
+ DB db = DB.getInstance(this);
+
+ SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
+ boolean enabled = prefs.getBoolean("google_backup", BuildConfig.PLAY_STORE_RELEASE);
+
+ EntityLog.log(this, "Backup start enabled=" + enabled);
+
+ if (!enabled)
+ return;
+
+ try {
+ JSONObject jroot = new JSONObject();
+
+ JSONObject jsettings = new JSONObject();
+ jsettings.put("enabled", prefs.getBoolean("enabled", true));
+ jsettings.put("poll_interval", prefs.getInt("poll_interval", 0));
+ String theme = prefs.getString("theme", null);
+ if (!TextUtils.isEmpty(theme))
+ jsettings.put("theme", theme);
+ jsettings.put("beige", prefs.getBoolean("beige", true));
+ jsettings.put("cards", prefs.getBoolean("cards", true));
+ jsettings.put("threading", prefs.getBoolean("threading", true));
+ jroot.put("settings", jsettings);
+
+ JSONArray jaccounts = new JSONArray();
+ List accounts = db.account().getAccounts();
+ EntityLog.log(this, "Backup accounts=" + accounts.size());
+ for (EntityAccount account : accounts)
+ try {
+ JSONObject jaccount = account.toJSON();
+
+ JSONArray jfolders = new JSONArray();
+ List folders = db.folder().getFolders(account.id, false, true);
+ for (EntityFolder folder : folders)
+ if (!EntityFolder.USER.equals(folder.type))
+ jfolders.put(folder.toJSON());
+ jaccount.put("folders", jfolders);
+
+ JSONArray jidentities = new JSONArray();
+ List identities = db.identity().getIdentities(account.id);
+ for (EntityIdentity identity : identities)
+ jidentities.put(identity.toJSON());
+ jaccount.put("identities", jidentities);
+
+ jaccounts.put(jaccount);
+ } catch (JSONException ex) {
+ Log.e(ex);
+ }
+ jroot.put("accounts", jaccounts);
+
+ byte[] dataBuf = jroot.toString().getBytes(StandardCharsets.UTF_8);
+ String dataHash = Helper.sha256(dataBuf);
+
+ String lastHash = null;
+ try {
+ FileInputStream instream = new FileInputStream(oldState.getFileDescriptor());
+ DataInputStream in = new DataInputStream(instream);
+ lastHash = in.readUTF();
+ } catch (IOException ex) {
+ Log.i(ex);
+ }
+
+ boolean write = !Objects.equals(dataHash, lastHash);
+ EntityLog.log(this, "Backup write=" + write);
+ if (write) {
+ data.writeEntityHeader(KEY_JSON, dataBuf.length);
+ data.writeEntityData(dataBuf, dataBuf.length);
+ }
+
+ FileOutputStream outstream = new FileOutputStream(newState.getFileDescriptor());
+ DataOutputStream out = new DataOutputStream(outstream);
+ out.writeUTF(dataHash);
+ } catch (Throwable ex) {
+ Log.e(ex);
+ }
+
+ EntityLog.log(this, "Backup end");
+ }
+
+ @Override
+ public void onRestore(BackupDataInput data, int appVersionCode, ParcelFileDescriptor newState) throws
+ IOException {
+ EntityLog.log(this, "Restore start");
+
+ SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
+
+ while (data.readNextHeader()) {
+ String dataKey = data.getKey();
+ int dataSize = data.getDataSize();
+ EntityLog.log(this, "Restore key=" + dataKey + " size=" + dataSize);
+
+ if (KEY_JSON.equals(dataKey)) {
+ byte[] dataBuf = new byte[dataSize];
+ data.readEntityData(dataBuf, 0, dataSize);
+ try {
+ JSONObject jroot = new JSONObject(new String(dataBuf, StandardCharsets.UTF_8));
+
+ JSONObject jsettings = jroot.getJSONObject("settings");
+ SharedPreferences.Editor editor = prefs.edit();
+ editor.putBoolean("enabled", jsettings.optBoolean("enabled"));
+ editor.putInt("poll_interval", jsettings.optInt("poll_interval", 0));
+ String theme = jsettings.optString("theme", null);
+ if (!TextUtils.isEmpty(theme))
+ editor.putString("theme", theme);
+ editor.putBoolean("beige", jsettings.optBoolean("beige", true));
+ editor.putBoolean("cards", jsettings.optBoolean("cards", true));
+ editor.putBoolean("threading", jsettings.optBoolean("threading", true));
+ editor.putBoolean("google_backup", true);
+ editor.apply();
+
+ JSONArray jaccounts = jroot.getJSONArray("accounts");
+ EntityLog.log(this, "Restore accounts=" + jaccounts.length());
+
+ DB db = DB.getInstance(this);
+ for (int i = 0; i < jaccounts.length(); i++)
+ try {
+ db.beginTransaction();
+
+ EntityLog.log(this, "Restoring account=" + i);
+
+ JSONObject jaccount = jaccounts.getJSONObject(i);
+ JSONArray jfolders = jaccount.getJSONArray("folders");
+ JSONArray jidentities = jaccount.getJSONArray("identities");
+ EntityAccount account = EntityAccount.fromJSON(jaccount);
+ if (TextUtils.isEmpty(account.uuid) ||
+ db.account().getAccountByUUID(account.uuid) != null)
+ continue;
+
+ if (account.auth_type == ServiceAuthenticator.AUTH_TYPE_GMAIL)
+ account.synchronize = false;
+
+ account.id = db.account().insertAccount(account);
+
+ for (int j = 0; j < jfolders.length(); j++) {
+ EntityFolder folder = EntityFolder.fromJSON(jfolders.getJSONObject(j));
+ folder.account = account.id;
+ db.folder().insertFolder(folder);
+ }
+
+ for (int j = 0; j < jidentities.length(); j++) {
+ EntityIdentity identity = EntityIdentity.fromJSON(jidentities.getJSONObject(j));
+ identity.account = account.id;
+ db.identity().insertIdentity(identity);
+ }
+
+ EntityLog.log(this, "Restored account=" + account.name);
+
+ db.setTransactionSuccessful();
+ } catch (Throwable ex) {
+ Log.e(ex);
+ } finally {
+ db.endTransaction();
+ }
+ } catch (Throwable ex) {
+ Log.e(ex);
+ }
+ } else {
+ data.skipEntityData();
+ }
+ }
+
+ EntityLog.log(this, "Restore end");
+ }
+
+ static void dataChanged(Context context) {
+ try {
+ new BackupManager(context).dataChanged();
+ } catch (Throwable ex) {
+ Log.e(ex);
+ }
+ }
+}
diff --git a/app/src/main/java/eu/faircode/email/FragmentAccount.java b/app/src/main/java/eu/faircode/email/FragmentAccount.java
index 7f9e84a358..7f21a0bf76 100644
--- a/app/src/main/java/eu/faircode/email/FragmentAccount.java
+++ b/app/src/main/java/eu/faircode/email/FragmentAccount.java
@@ -1476,6 +1476,8 @@ public class FragmentAccount extends FragmentBase {
editor.putBoolean("unset." + account.id + "." + EntityFolder.JUNK, junk == null);
editor.apply();
+ FairEmailBackupAgent.dataChanged(context);
+
return false;
}
diff --git a/app/src/main/java/eu/faircode/email/FragmentGmail.java b/app/src/main/java/eu/faircode/email/FragmentGmail.java
index 438e2fa798..7f30b92e30 100644
--- a/app/src/main/java/eu/faircode/email/FragmentGmail.java
+++ b/app/src/main/java/eu/faircode/email/FragmentGmail.java
@@ -610,6 +610,8 @@ public class FragmentGmail extends FragmentBase {
ServiceSynchronize.eval(context, "Gmail");
args.putBoolean("updated", update != null);
+ FairEmailBackupAgent.dataChanged(context);
+
return null;
}
diff --git a/app/src/main/java/eu/faircode/email/FragmentIdentity.java b/app/src/main/java/eu/faircode/email/FragmentIdentity.java
index 122b2efdb4..5602c46d56 100644
--- a/app/src/main/java/eu/faircode/email/FragmentIdentity.java
+++ b/app/src/main/java/eu/faircode/email/FragmentIdentity.java
@@ -1222,6 +1222,8 @@ public class FragmentIdentity extends FragmentBase {
Core.clearIdentities();
+ FairEmailBackupAgent.dataChanged(context);
+
return false;
}
diff --git a/app/src/main/java/eu/faircode/email/FragmentOAuth.java b/app/src/main/java/eu/faircode/email/FragmentOAuth.java
index 0ef47ad225..7cc6972430 100644
--- a/app/src/main/java/eu/faircode/email/FragmentOAuth.java
+++ b/app/src/main/java/eu/faircode/email/FragmentOAuth.java
@@ -1049,6 +1049,8 @@ public class FragmentOAuth extends FragmentBase {
ServiceSynchronize.eval(context, "OAuth");
args.putBoolean("updated", update != null);
+ FairEmailBackupAgent.dataChanged(context);
+
return null;
}
diff --git a/app/src/main/java/eu/faircode/email/FragmentOptionsMisc.java b/app/src/main/java/eu/faircode/email/FragmentOptionsMisc.java
index 56929e94d9..e29630de22 100644
--- a/app/src/main/java/eu/faircode/email/FragmentOptionsMisc.java
+++ b/app/src/main/java/eu/faircode/email/FragmentOptionsMisc.java
@@ -146,6 +146,7 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc
private Button btnCleanup;
private TextView tvLastCleanup;
private TextView tvSdcard;
+ private SwitchCompat swGoogleBackup;
private CardView cardAdvanced;
private SwitchCompat swWatchdog;
@@ -279,6 +280,7 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc
"language",
"updates", "weekly", "beta", "show_changelog", "announcements",
"crash_reports", "cleanup_attachments",
+ "google_backup",
"watchdog", "experiments", "main_log", "main_log_memory", "protocol", "log_level", "debug", "leak_canary",
"test1", "test2", "test3", "test4", "test5",
"emergency_file", "work_manager", "task_description", // "external_storage",
@@ -395,6 +397,7 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc
btnCleanup = view.findViewById(R.id.btnCleanup);
tvLastCleanup = view.findViewById(R.id.tvLastCleanup);
tvSdcard = view.findViewById(R.id.tvSdcard);
+ swGoogleBackup = view.findViewById(R.id.swGoogleBackup);
cardAdvanced = view.findViewById(R.id.cardAdvanced);
swWatchdog = view.findViewById(R.id.swWatchdog);
@@ -876,6 +879,14 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc
}
});
+ swGoogleBackup.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
+ @Override
+ public void onCheckedChanged(CompoundButton compoundButton, boolean checked) {
+ prefs.edit().putBoolean("google_backup", checked).apply();
+ FairEmailBackupAgent.dataChanged(compoundButton.getContext());
+ }
+ });
+
swWatchdog.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
@Override
public void onCheckedChanged(CompoundButton compoundButton, boolean checked) {
@@ -2332,6 +2343,7 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc
swCrashReports.setChecked(prefs.getBoolean("crash_reports", false));
tvUuid.setText(prefs.getString("uuid", null));
swCleanupAttachments.setChecked(prefs.getBoolean("cleanup_attachments", false));
+ swGoogleBackup.setChecked(prefs.getBoolean("google_backup", BuildConfig.PLAY_STORE_RELEASE));
ArrayAdapter adapter = new ArrayAdapter<>(getContext(), android.R.layout.simple_spinner_item, android.R.id.text1, display);
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
diff --git a/app/src/main/java/eu/faircode/email/FragmentPop.java b/app/src/main/java/eu/faircode/email/FragmentPop.java
index 8a53912b27..07d71418e4 100644
--- a/app/src/main/java/eu/faircode/email/FragmentPop.java
+++ b/app/src/main/java/eu/faircode/email/FragmentPop.java
@@ -750,6 +750,8 @@ public class FragmentPop extends FragmentBase {
args.putBoolean("saved", true);
+ FairEmailBackupAgent.dataChanged(context);
+
return false;
}
diff --git a/app/src/main/java/eu/faircode/email/FragmentQuickSetup.java b/app/src/main/java/eu/faircode/email/FragmentQuickSetup.java
index 6a94b888aa..37f4f432b1 100644
--- a/app/src/main/java/eu/faircode/email/FragmentQuickSetup.java
+++ b/app/src/main/java/eu/faircode/email/FragmentQuickSetup.java
@@ -673,6 +673,8 @@ public class FragmentQuickSetup extends FragmentBase {
ServiceSynchronize.eval(context, "quick setup");
args.putBoolean("updated", update != null);
+ FairEmailBackupAgent.dataChanged(context);
+
return provider;
} catch (Throwable ex) {
Log.w(ex);
diff --git a/app/src/main/java/eu/faircode/email/WorkerCleanup.java b/app/src/main/java/eu/faircode/email/WorkerCleanup.java
index 088cc04197..855eb8a4e1 100644
--- a/app/src/main/java/eu/faircode/email/WorkerCleanup.java
+++ b/app/src/main/java/eu/faircode/email/WorkerCleanup.java
@@ -385,6 +385,8 @@ public class WorkerCleanup extends Worker {
" size=" + Helper.humanReadableByteCount(size) +
"/" + Helper.humanReadableByteCount(available));
}
+
+ FairEmailBackupAgent.dataChanged(context);
} catch (Throwable ex) {
Log.e(ex);
} finally {
diff --git a/app/src/main/res/layout/fragment_options_misc.xml b/app/src/main/res/layout/fragment_options_misc.xml
index 33cc94a885..329d9e608b 100644
--- a/app/src/main/res/layout/fragment_options_misc.xml
+++ b/app/src/main/res/layout/fragment_options_misc.xml
@@ -623,6 +623,17 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tvLastCleanup" />
+
+
Delete attachments of old messages
Cleanup
Last cleanup: %1$s
+ Use Google data backup
App settings
More options
Main logging
diff --git a/app/src/play/AndroidManifest.xml b/app/src/play/AndroidManifest.xml
index cb780fcba1..0c3ba68c3f 100644
--- a/app/src/play/AndroidManifest.xml
+++ b/app/src/play/AndroidManifest.xml
@@ -112,8 +112,9 @@