package eu.faircode.email;
/*
This file is part of FairEmail.
FairEmail is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
FairEmail is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with FairEmail. If not, see .
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.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
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;
import java.util.zip.GZIPInputStream;
import java.util.zip.GZIPOutputStream;
public class FairEmailBackupAgent extends BackupAgent {
// https://developer.android.com/identity/data/keyvaluebackup#BackupAgent
// https://developer.android.com/identity/data/testingbackup#Preparing
// bmgr backupnow "eu.faircode.email.debug"
private static final String KEY_JSON = "eu.faircode.email.json";
@Override
public void onBackup(ParcelFileDescriptor oldState, BackupDataOutput data, ParcelFileDescriptor newState) {
try {
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;
JSONObject jroot = new JSONObject();
jroot.put("version", 1);
JSONObject jsettings = new JSONObject();
jsettings.put("enabled", prefs.getBoolean("enabled", true));
jsettings.put("poll_interval", prefs.getInt("poll_interval", 0));
jsettings.put("startup", prefs.getString("startup", "unified"));
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 + " size=" + dataBuf.length);
if (write) {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
try (GZIPOutputStream gos = new GZIPOutputStream(bos)) {
gos.write(dataBuf, 0, dataBuf.length);
}
dataBuf = bos.toByteArray();
EntityLog.log(this, "Backup compressed=" + dataBuf.length);
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);
} finally {
EntityLog.log(this, "Backup end");
}
}
@Override
public void onQuotaExceeded(long backupDataBytes, long quotaBytes) {
Log.e("Backup quota exceeded " + backupDataBytes + "/" + quotaBytes);
}
@Override
public void onRestore(BackupDataInput data, int appVersionCode, ParcelFileDescriptor newState) {
try {
EntityLog.log(this, "Restore start version=" + appVersionCode);
while (data.readNextHeader()) {
String dataKey = data.getKey();
int dataSize = data.getDataSize();
EntityLog.log(this, "Restore key=" + dataKey + " size=" + dataSize);
if (KEY_JSON.equals(dataKey))
try {
byte[] dataBuf = new byte[dataSize];
data.readEntityData(dataBuf, 0, dataSize);
ByteArrayOutputStream bos = new ByteArrayOutputStream();
try (GZIPInputStream gis = new GZIPInputStream(new ByteArrayInputStream(dataBuf))) {
Helper.copy(gis, bos);
}
dataBuf = bos.toByteArray();
EntityLog.log(this, "Restore decompressed=" + dataBuf.length);
JSONObject jroot = new JSONObject(new String(dataBuf, StandardCharsets.UTF_8));
EntityLog.log(this, "Restore version=" + jroot.optInt("version", 0));
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
SharedPreferences.Editor editor = prefs.edit();
JSONObject jsettings = jroot.getJSONObject("settings");
editor.putBoolean("enabled", jsettings.optBoolean("enabled"));
editor.putInt("poll_interval", jsettings.optInt("poll_interval", 0));
editor.putString("startup", jsettings.optString("startup", "unified"));
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();
}
}
} catch (Throwable ex) {
Log.e(ex);
} finally {
EntityLog.log(this, "Restore end");
}
}
@Override
public void onRestoreFinished() {
EntityLog.log(this, "Restore finished");
}
static void dataChanged(Context context) {
try {
new BackupManager(context).dataChanged();
} catch (Throwable ex) {
Log.e(ex);
}
}
}