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 @@