First version of the import code that reads the new file format
This commit is contained in:
parent
d5197fdc56
commit
12d87854ac
2 changed files with 836 additions and 180 deletions
|
@ -1,6 +1,8 @@
|
|||
|
||||
package com.fsck.k9.activity;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
|
@ -22,6 +24,7 @@ import android.os.AsyncTask;
|
|||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.util.Log;
|
||||
import android.util.SparseBooleanArray;
|
||||
import android.util.TypedValue;
|
||||
import android.view.ContextMenu;
|
||||
import android.view.Menu;
|
||||
|
@ -34,14 +37,17 @@ import android.view.View.OnClickListener;
|
|||
import android.webkit.WebView;
|
||||
import android.widget.AdapterView;
|
||||
import android.widget.ArrayAdapter;
|
||||
import android.widget.CheckedTextView;
|
||||
import android.widget.ImageButton;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.ListAdapter;
|
||||
import android.widget.ListView;
|
||||
import android.widget.RelativeLayout;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
import android.widget.AdapterView.AdapterContextMenuInfo;
|
||||
import android.widget.AdapterView.OnItemClickListener;
|
||||
import android.widget.AdapterView.OnItemSelectedListener;
|
||||
|
||||
import com.fsck.k9.Account;
|
||||
import com.fsck.k9.AccountStats;
|
||||
|
@ -64,6 +70,9 @@ import com.fsck.k9.mail.store.StorageManager;
|
|||
import com.fsck.k9.view.ColorChip;
|
||||
import com.fsck.k9.preferences.StorageExporter;
|
||||
import com.fsck.k9.preferences.StorageImportExportException;
|
||||
import com.fsck.k9.preferences.StorageImporter;
|
||||
import com.fsck.k9.preferences.StorageImporter.AccountDescription;
|
||||
import com.fsck.k9.preferences.StorageImporter.ImportContents;
|
||||
|
||||
|
||||
public class Accounts extends K9ListActivity implements OnItemClickListener, OnClickListener {
|
||||
|
@ -854,53 +863,13 @@ public class Accounts extends K9ListActivity implements OnItemClickListener, OnC
|
|||
}
|
||||
|
||||
private void onImport(Uri uri) {
|
||||
Toast.makeText(this, "Import is disabled for now", Toast.LENGTH_SHORT).show();
|
||||
/*
|
||||
Log.i(K9.LOG_TAG, "onImport importing from URI " + uri.getPath());
|
||||
//Toast.makeText(this, "Import is disabled for now", Toast.LENGTH_SHORT).show();
|
||||
|
||||
final String fileName = uri.getPath();
|
||||
AsyncUIProcessor.getInstance(Accounts.this.getApplication()).importSettings(this, uri, new ImportListener() {
|
||||
@Override
|
||||
public void success(int numAccounts) {
|
||||
mHandler.progress(false);
|
||||
String accountQtyText = getResources().getQuantityString(R.plurals.settings_import_success, numAccounts, numAccounts);
|
||||
String messageText = getString(R.string.settings_import_success, accountQtyText, fileName);
|
||||
showDialog(Accounts.this, R.string.settings_import_success_header, messageText);
|
||||
runOnUiThread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
Log.i(K9.LOG_TAG, "onImport importing from URI " + uri.toString());
|
||||
|
||||
@Override
|
||||
public void failure(String message, Exception e) {
|
||||
mHandler.progress(false);
|
||||
showDialog(Accounts.this, R.string.settings_import_failed_header, Accounts.this.getString(R.string.settings_import_failure, fileName, e.getLocalizedMessage()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void canceled() {
|
||||
mHandler.progress(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void started() {
|
||||
runOnUiThread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
mHandler.progress(true);
|
||||
String toastText = Accounts.this.getString(R.string.settings_importing);
|
||||
Toast toast = Toast.makeText(Accounts.this, toastText, Toast.LENGTH_SHORT);
|
||||
toast.show();
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
});
|
||||
*/
|
||||
new ListImportContentsAsyncTask(uri, null).execute();
|
||||
}
|
||||
|
||||
private void showDialog(final Context context, final int headerRes, final String message) {
|
||||
this.runOnUiThread(new Runnable() {
|
||||
@Override
|
||||
|
@ -1180,7 +1149,6 @@ public class Accounts extends K9ListActivity implements OnItemClickListener, OnC
|
|||
|
||||
@Override
|
||||
protected void onPostExecute(Boolean success) {
|
||||
setProgress(false);
|
||||
if (success) {
|
||||
showDialog(Accounts.this, R.string.settings_export_success_header,
|
||||
Accounts.this.getString(R.string.settings_export_success, mFileName));
|
||||
|
@ -1191,4 +1159,166 @@ public class Accounts extends K9ListActivity implements OnItemClickListener, OnC
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class ImportAsyncTask extends AsyncTask<Void, Void, Boolean> {
|
||||
private boolean mIncludeGlobals;
|
||||
private Set<String> mAccountUuids;
|
||||
private boolean mOverwrite;
|
||||
private String mEncryptionKey;
|
||||
private InputStream mInputStream;
|
||||
|
||||
private ImportAsyncTask(boolean includeGlobals, Set<String> accountUuids,
|
||||
boolean overwrite, String encryptionKey, InputStream is) {
|
||||
mIncludeGlobals = includeGlobals;
|
||||
mAccountUuids = accountUuids;
|
||||
mOverwrite = overwrite;
|
||||
mEncryptionKey = encryptionKey;
|
||||
mInputStream = is;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPreExecute() {
|
||||
//TODO: show progress bar instead of displaying toast
|
||||
String toastText = Accounts.this.getString(R.string.settings_importing);
|
||||
Toast toast = Toast.makeText(Accounts.this, toastText, Toast.LENGTH_SHORT);
|
||||
toast.show();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Boolean doInBackground(Void... params) {
|
||||
try {
|
||||
StorageImporter.importSettings(Accounts.this, mInputStream, mEncryptionKey,
|
||||
mIncludeGlobals, mAccountUuids, mOverwrite);
|
||||
} catch (StorageImportExportException e) {
|
||||
Log.w(K9.LOG_TAG, "Exception during export", e);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPostExecute(Boolean success) {
|
||||
if (success) {
|
||||
showDialog(Accounts.this, R.string.settings_import_success_header,
|
||||
//FIXME: use correct number of imported accounts
|
||||
Accounts.this.getString(R.string.settings_import_success, 3, "unknown"));
|
||||
refresh();
|
||||
} else {
|
||||
//TODO: make the importer return an error code; translate that error code to a localized string here
|
||||
showDialog(Accounts.this, R.string.settings_import_failed_header,
|
||||
Accounts.this.getString(R.string.settings_import_failure, "unknown", "Something went wrong"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ImportContents mImportContents;
|
||||
private class ListImportContentsAsyncTask extends AsyncTask<Void, Void, Boolean> {
|
||||
private Uri mUri;
|
||||
private String mEncryptionKey;
|
||||
private InputStream mInputStream;
|
||||
|
||||
private ListImportContentsAsyncTask(Uri uri, String encryptionKey) {
|
||||
mUri = uri;
|
||||
mEncryptionKey = encryptionKey;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPreExecute() {
|
||||
//TODO: show progress bar
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Boolean doInBackground(Void... params) {
|
||||
try {
|
||||
|
||||
InputStream is = getContentResolver().openInputStream(mUri);
|
||||
mImportContents = StorageImporter.getImportStreamContents(
|
||||
Accounts.this, is, mEncryptionKey);
|
||||
|
||||
// Open another InputStream in the background. This is used later by ImportAsyncTask
|
||||
mInputStream = getContentResolver().openInputStream(mUri);
|
||||
|
||||
} catch (StorageImportExportException e) {
|
||||
Log.w(K9.LOG_TAG, "Exception during export", e);
|
||||
return false;
|
||||
}
|
||||
catch (FileNotFoundException e) {
|
||||
Log.w(K9.LOG_TAG, "Couldn't read content from URI " + mUri);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPostExecute(Boolean success) {
|
||||
if (success) {
|
||||
final ListView importSelectionView = new ListView(Accounts.this);
|
||||
List<String> contents = new ArrayList<String>();
|
||||
if (mImportContents.globalSettings) {
|
||||
contents.add("Global settings");
|
||||
}
|
||||
for (AccountDescription account : mImportContents.accounts) {
|
||||
contents.add(account.name);
|
||||
}
|
||||
importSelectionView.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE);
|
||||
importSelectionView.setAdapter(new ArrayAdapter<String>(Accounts.this, android.R.layout.simple_list_item_checked, contents));
|
||||
importSelectionView.setOnItemSelectedListener(new OnItemSelectedListener() {
|
||||
@Override
|
||||
public void onItemSelected(AdapterView<?> parent, View view, int pos, long id) {
|
||||
CheckedTextView ctv = (CheckedTextView)view;
|
||||
ctv.setChecked(!ctv.isChecked());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNothingSelected(AdapterView<?> arg0) {}
|
||||
});
|
||||
|
||||
//TODO: listview header: "Please select the settings you wish to import"
|
||||
//TODO: listview footer: "Select all" / "Select none" buttons?
|
||||
//TODO: listview footer: "Overwrite existing accounts?" checkbox
|
||||
|
||||
final AlertDialog.Builder builder = new AlertDialog.Builder(Accounts.this);
|
||||
builder.setTitle("Import selection");
|
||||
builder.setView(importSelectionView);
|
||||
builder.setInverseBackgroundForced(true);
|
||||
builder.setPositiveButton(R.string.okay_action,
|
||||
new DialogInterface.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(DialogInterface dialog, int which) {
|
||||
ListAdapter adapter = importSelectionView.getAdapter();
|
||||
int count = adapter.getCount();
|
||||
SparseBooleanArray pos = importSelectionView.getCheckedItemPositions();
|
||||
|
||||
boolean includeGlobals = mImportContents.globalSettings ? pos.get(0) : false;
|
||||
Set<String> accountUuids = new HashSet<String>();
|
||||
for (int i = 1; i < count; i++) {
|
||||
if (pos.get(i)) {
|
||||
accountUuids.add(mImportContents.accounts.get(i-1).uuid);
|
||||
}
|
||||
}
|
||||
|
||||
boolean overwrite = false; //TODO: get value from dialog
|
||||
|
||||
dialog.dismiss();
|
||||
new ImportAsyncTask(includeGlobals, accountUuids, overwrite, mEncryptionKey, mInputStream).execute();
|
||||
}
|
||||
});
|
||||
builder.setNegativeButton(R.string.cancel_action,
|
||||
new DialogInterface.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(DialogInterface dialog, int which) {
|
||||
dialog.dismiss();
|
||||
try {
|
||||
mInputStream.close();
|
||||
} catch (Exception e) { /* Ignore */ }
|
||||
}
|
||||
});
|
||||
builder.show();
|
||||
} else {
|
||||
//TODO: make the importer return an error code; translate that error code to a localized string here
|
||||
showDialog(Accounts.this, R.string.settings_import_failed_header,
|
||||
Accounts.this.getString(R.string.settings_import_failure, "unknown", "Something went wrong"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,190 +1,716 @@
|
|||
package com.fsck.k9.preferences;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.StringReader;
|
||||
import java.io.InputStreamReader;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Stack;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import javax.xml.parsers.SAXParser;
|
||||
import javax.xml.parsers.SAXParserFactory;
|
||||
|
||||
import org.xml.sax.Attributes;
|
||||
import org.xml.sax.InputSource;
|
||||
import org.xml.sax.SAXException;
|
||||
import org.xml.sax.XMLReader;
|
||||
import org.xml.sax.helpers.DefaultHandler;
|
||||
import org.xmlpull.v1.XmlPullParser;
|
||||
import org.xmlpull.v1.XmlPullParserException;
|
||||
import org.xmlpull.v1.XmlPullParserFactory;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.util.Log;
|
||||
|
||||
import com.fsck.k9.Account;
|
||||
import com.fsck.k9.Identity;
|
||||
import com.fsck.k9.K9;
|
||||
import com.fsck.k9.Preferences;
|
||||
import com.fsck.k9.helper.DateFormatter;
|
||||
import com.fsck.k9.helper.Utility;
|
||||
|
||||
public class StorageImporter {
|
||||
|
||||
public static void importPreferences(Context context, InputStream is, String encryptionKey,
|
||||
boolean globalSettings, String[] importAccountUuids, boolean overwrite)
|
||||
throws StorageImportExportException {
|
||||
/**
|
||||
* Class to list the contents of an import file/stream.
|
||||
*
|
||||
* @see StorageImporter#getImportStreamContents(Context,InputStream,String)
|
||||
*/
|
||||
public static class ImportContents {
|
||||
/**
|
||||
* True, if the import file contains global settings.
|
||||
*/
|
||||
public final boolean globalSettings;
|
||||
|
||||
/**
|
||||
* The list of accounts found in the import file. Never {@code null}.
|
||||
*/
|
||||
public final List<AccountDescription> accounts;
|
||||
|
||||
private ImportContents(boolean globalSettings, List<AccountDescription> accounts) {
|
||||
this.globalSettings = globalSettings;
|
||||
this.accounts = accounts;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Class to describe an account (name, UUID).
|
||||
*
|
||||
* @see ImportContents
|
||||
*/
|
||||
public static class AccountDescription {
|
||||
/**
|
||||
* The name of the account.
|
||||
*/
|
||||
public final String name;
|
||||
|
||||
/**
|
||||
* The UUID of the account.
|
||||
*/
|
||||
public final String uuid;
|
||||
|
||||
private AccountDescription(String name, String uuid) {
|
||||
this.name = name;
|
||||
this.uuid = uuid;
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean isImportStreamEncrypted(Context context, InputStream inputStream) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an import {@link InputStream} and returns information on whether it contains global
|
||||
* settings and/or account settings. For all account configurations found, the name of the
|
||||
* account along with the account UUID is returned.
|
||||
*
|
||||
* @param context
|
||||
* @param inputStream
|
||||
* @param encryptionKey
|
||||
* @return
|
||||
* @throws StorageImportExportException
|
||||
*/
|
||||
public static ImportContents getImportStreamContents(Context context, InputStream inputStream,
|
||||
String encryptionKey) throws StorageImportExportException {
|
||||
|
||||
try {
|
||||
SAXParserFactory spf = SAXParserFactory.newInstance();
|
||||
SAXParser sp = spf.newSAXParser();
|
||||
XMLReader xr = sp.getXMLReader();
|
||||
StorageImporterHandler handler = new StorageImporterHandler();
|
||||
xr.setContentHandler(handler);
|
||||
// Parse the import stream but don't save individual settings (overview=true)
|
||||
Imported imported = parseSettings(inputStream, false, null, false, true);
|
||||
|
||||
xr.parse(new InputSource(is));
|
||||
// If the stream contains global settings the "globalSettings" member will not be null
|
||||
boolean globalSettings = (imported.globalSettings != null);
|
||||
|
||||
ImportElement dataset = handler.getRootElement();
|
||||
String storageFormat = dataset.attributes.get("version");
|
||||
Log.i(K9.LOG_TAG, "Got settings file version " + storageFormat);
|
||||
final List<AccountDescription> accounts = new ArrayList<AccountDescription>();
|
||||
// If the stream contains at least one account configuration the "accounts" member
|
||||
// will not be null.
|
||||
if (imported.accounts != null) {
|
||||
for (ImportedAccount account : imported.accounts.values()) {
|
||||
accounts.add(new AccountDescription(account.name, account.uuid));
|
||||
}
|
||||
}
|
||||
|
||||
return new ImportContents(globalSettings, accounts);
|
||||
|
||||
} catch (StorageImportExportException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new StorageImportExportException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads an import {@link InputStream} and imports the global settings and/or account
|
||||
* configurations specified by the arguments.
|
||||
*
|
||||
* @param context
|
||||
* @param inputStream
|
||||
* @param encryptionKey
|
||||
* @param globalSettings
|
||||
* @param accountUuids
|
||||
* @param overwrite
|
||||
* @throws StorageImportExportException
|
||||
*/
|
||||
public static void importSettings(Context context, InputStream inputStream, String encryptionKey,
|
||||
boolean globalSettings, Set<String> accountUuids, boolean overwrite)
|
||||
throws StorageImportExportException {
|
||||
|
||||
try
|
||||
{
|
||||
Imported imported = parseSettings(inputStream, globalSettings, accountUuids, overwrite, false);
|
||||
|
||||
Preferences preferences = Preferences.getPreferences(context);
|
||||
SharedPreferences storage = preferences.getPreferences();
|
||||
SharedPreferences.Editor editor = storage.edit();
|
||||
|
||||
String data = dataset.data.toString();
|
||||
List<Integer> accountNumbers = Account.getExistingAccountNumbers(preferences);
|
||||
Log.i(K9.LOG_TAG, "Existing accountNumbers = " + accountNumbers);
|
||||
/**
|
||||
* We translate UUIDs in the import file into new UUIDs in the local instance for the following reasons:
|
||||
* 1) Accidentally importing the same file twice cannot damage settings in an existing account.
|
||||
* (Say, an account that was imported two months ago and has since had significant settings changes.)
|
||||
* 2) Importing a single file multiple times allows for creating multiple accounts from the same template.
|
||||
* 3) Exporting an account and importing back into the same instance is a poor-man's account copy (until a real
|
||||
* copy function is created, if ever)
|
||||
*/
|
||||
Map<String, String> uuidMapping = new HashMap<String, String>();
|
||||
String accountUuids = preferences.getPreferences().getString("accountUuids", null);
|
||||
|
||||
StringReader sr = new StringReader(data);
|
||||
BufferedReader br = new BufferedReader(sr);
|
||||
String line = null;
|
||||
int settingsImported = 0;
|
||||
int numAccounts = 0;
|
||||
K9Krypto krypto = new K9Krypto(encryptionKey, K9Krypto.MODE.DECRYPT);
|
||||
do {
|
||||
line = br.readLine();
|
||||
if (line != null) {
|
||||
//Log.i(K9.LOG_TAG, "Got line " + line);
|
||||
String[] comps = line.split(":");
|
||||
if (comps.length > 1) {
|
||||
String keyEnc = comps[0];
|
||||
String valueEnc = comps[1];
|
||||
String key = krypto.decrypt(keyEnc);
|
||||
String value = krypto.decrypt(valueEnc);
|
||||
String[] keyParts = key.split("\\.");
|
||||
if (keyParts.length > 1) {
|
||||
String oldUuid = keyParts[0];
|
||||
String newUuid = uuidMapping.get(oldUuid);
|
||||
if (newUuid == null) {
|
||||
newUuid = UUID.randomUUID().toString();
|
||||
uuidMapping.put(oldUuid, newUuid);
|
||||
|
||||
Log.i(K9.LOG_TAG, "Mapping oldUuid " + oldUuid + " to newUuid " + newUuid);
|
||||
}
|
||||
keyParts[0] = newUuid;
|
||||
if ("accountNumber".equals(keyParts[1])) {
|
||||
int accountNumber = Account.findNewAccountNumber(accountNumbers);
|
||||
accountNumbers.add(accountNumber);
|
||||
value = Integer.toString(accountNumber);
|
||||
accountUuids += (accountUuids.length() != 0 ? "," : "") + newUuid;
|
||||
numAccounts++;
|
||||
}
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (String part : keyParts) {
|
||||
if (builder.length() > 0) {
|
||||
builder.append(".");
|
||||
}
|
||||
builder.append(part);
|
||||
}
|
||||
key = builder.toString();
|
||||
}
|
||||
//Log.i(K9.LOG_TAG, "Setting " + key + " = " + value);
|
||||
settingsImported++;
|
||||
editor.putString(key, value);
|
||||
}
|
||||
if (globalSettings) {
|
||||
if (imported.globalSettings != null) {
|
||||
importGlobalSettings(editor, imported.globalSettings);
|
||||
} else {
|
||||
Log.w(K9.LOG_TAG, "Was asked to import global settings but none found.");
|
||||
}
|
||||
}
|
||||
|
||||
} while (line != null);
|
||||
if (accountUuids != null && accountUuids.size() > 0) {
|
||||
if (imported.accounts != null) {
|
||||
List<String> newUuids = new ArrayList<String>();
|
||||
for (String accountUuid : accountUuids) {
|
||||
if (imported.accounts.containsKey(accountUuid)) {
|
||||
String newUuid = importAccount(context, editor, imported.accounts.get(accountUuid), overwrite);
|
||||
if (newUuid != null) {
|
||||
newUuids.add(newUuid);
|
||||
}
|
||||
} else {
|
||||
Log.w(K9.LOG_TAG, "Was asked to import account with UUID " +
|
||||
accountUuid + ". But this account wasn't found.");
|
||||
}
|
||||
}
|
||||
if (newUuids.size() > 0) {
|
||||
String oldAccountUuids = storage.getString("accountUuids", "");
|
||||
String appendUuids = Utility.combine(newUuids.toArray(new String[0]), ',');
|
||||
String prefix = "";
|
||||
if (oldAccountUuids.length() > 0) {
|
||||
prefix = oldAccountUuids + ",";
|
||||
}
|
||||
editor.putString("accountUuids", prefix + appendUuids);
|
||||
}
|
||||
} else {
|
||||
Log.w(K9.LOG_TAG, "Was asked to import at least one account but none found.");
|
||||
}
|
||||
}
|
||||
|
||||
editor.putString("accountUuids", accountUuids);
|
||||
Log.i(K9.LOG_TAG, "Imported " + settingsImported + " settings and " + numAccounts + " accounts");
|
||||
if (!editor.commit()) {
|
||||
throw new StorageImportExportException("Couldn't save imported settings");
|
||||
}
|
||||
|
||||
editor.commit();
|
||||
Preferences.getPreferences(context).refreshAccounts();
|
||||
preferences.refreshAccounts();
|
||||
DateFormatter.clearChosenFormat();
|
||||
K9.loadPrefs(Preferences.getPreferences(context));
|
||||
K9.loadPrefs(preferences);
|
||||
K9.setServicesEnabled(context);
|
||||
|
||||
} catch (StorageImportExportException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new StorageImportExportException();
|
||||
throw new StorageImportExportException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void importGlobalSettings(SharedPreferences.Editor editor,
|
||||
ImportedSettings settings) {
|
||||
//TODO: input validation
|
||||
|
||||
public static class ImportElement {
|
||||
String name;
|
||||
Map<String, String> attributes = new HashMap<String, String>();
|
||||
Map<String, ImportElement> subElements = new HashMap<String, ImportElement>();
|
||||
StringBuilder data = new StringBuilder();
|
||||
for (Map.Entry<String, String> setting : settings.settings.entrySet()) {
|
||||
String key = setting.getKey();
|
||||
String value = setting.getValue();
|
||||
//FIXME: drop this key during input validation. then remove this check
|
||||
if ("accountUuids".equals(key)) {
|
||||
continue;
|
||||
}
|
||||
editor.putString(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
private static class StorageImporterHandler extends DefaultHandler {
|
||||
private ImportElement rootElement = new ImportElement();
|
||||
private Stack<ImportElement> mOpenTags = new Stack<ImportElement>();
|
||||
private static String importAccount(Context context, SharedPreferences.Editor editor,
|
||||
ImportedAccount account, boolean overwrite) {
|
||||
|
||||
public ImportElement getRootElement() {
|
||||
return this.rootElement;
|
||||
//TODO: input validation
|
||||
//TODO: remove latestOldMessageSeenTime?
|
||||
|
||||
Preferences prefs = Preferences.getPreferences(context);
|
||||
Account[] accounts = prefs.getAccounts();
|
||||
|
||||
String uuid = account.uuid;
|
||||
Account existingAccount = prefs.getAccount(uuid);
|
||||
if (!overwrite && existingAccount != null) {
|
||||
// An account with this UUID already exists, but we're not allowed to overwrite it.
|
||||
// So generate a new UUID.
|
||||
uuid = UUID.randomUUID().toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void startDocument() throws SAXException {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void endDocument() throws SAXException {
|
||||
/* Do nothing */
|
||||
}
|
||||
|
||||
@Override
|
||||
public void startElement(String namespaceURI, String localName,
|
||||
String qName, Attributes attributes) throws SAXException {
|
||||
Log.i(K9.LOG_TAG, "Starting element " + localName);
|
||||
ImportElement element = new ImportElement();
|
||||
element.name = localName;
|
||||
mOpenTags.push(element);
|
||||
for (int i = 0; i < attributes.getLength(); i++) {
|
||||
String key = attributes.getLocalName(i);
|
||||
String value = attributes.getValue(i);
|
||||
Log.i(K9.LOG_TAG, "Got attribute " + key + " = " + value);
|
||||
element.attributes.put(key, value);
|
||||
String accountName = account.name;
|
||||
if (isAccountNameUsed(accountName, accounts)) {
|
||||
// Account name is already in use. So generate a new one by appending " (x)", where x
|
||||
// is the first number >= 1 that results in an unused account name.
|
||||
for (int i = 1; i <= accounts.length; i++) {
|
||||
accountName = account.name + " (" + i + ")";
|
||||
if (!isAccountNameUsed(accountName, accounts)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void endElement(String namespaceURI, String localName, String qName) {
|
||||
Log.i(K9.LOG_TAG, "Ending element " + localName);
|
||||
ImportElement element = mOpenTags.pop();
|
||||
ImportElement superElement = mOpenTags.empty() ? null : mOpenTags.peek();
|
||||
if (superElement != null) {
|
||||
superElement.subElements.put(element.name, element);
|
||||
} else {
|
||||
rootElement = element;
|
||||
String accountKeyPrefix = uuid + ".";
|
||||
editor.putString(accountKeyPrefix + Account.ACCOUNT_DESCRIPTION_KEY, accountName);
|
||||
|
||||
// Write account settings
|
||||
for (Map.Entry<String, String> setting : account.settings.settings.entrySet()) {
|
||||
//FIXME: drop this key during input validation. then remove this check
|
||||
if ("accountNumber".equals(setting.getKey())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String key = accountKeyPrefix + setting.getKey();
|
||||
String value = setting.getValue();
|
||||
editor.putString(key, value);
|
||||
}
|
||||
|
||||
// If it's a new account generate and write a new "accountNumber"
|
||||
if (existingAccount == null || !uuid.equals(account.uuid)) {
|
||||
int newAccountNumber = Account.generateAccountNumber(prefs);
|
||||
editor.putString(accountKeyPrefix + "accountNumber", Integer.toString(newAccountNumber));
|
||||
}
|
||||
|
||||
if (account.identities != null) {
|
||||
importIdentities(editor, uuid, account, overwrite, existingAccount);
|
||||
}
|
||||
|
||||
// Write folder settings
|
||||
if (account.folders != null) {
|
||||
for (ImportedFolder folder : account.folders) {
|
||||
String folderKeyPrefix = uuid + "." + folder.name + ".";
|
||||
for (Map.Entry<String, String> setting : folder.settings.settings.entrySet()) {
|
||||
String key = folderKeyPrefix + setting.getKey();
|
||||
String value = setting.getValue();
|
||||
editor.putString(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void characters(char ch[], int start, int length) {
|
||||
String value = new String(ch, start, length);
|
||||
mOpenTags.peek().data.append(value);
|
||||
//TODO: sync folder settings with localstore?
|
||||
|
||||
return (overwrite && existingAccount != null) ? null : uuid;
|
||||
}
|
||||
|
||||
private static void importIdentities(SharedPreferences.Editor editor, String uuid,
|
||||
ImportedAccount account, boolean overwrite, Account existingAccount) {
|
||||
|
||||
String accountKeyPrefix = uuid + ".";
|
||||
|
||||
// Gather information about existing identities for this account (if any)
|
||||
int nextIdentityIndex = 0;
|
||||
final List<Identity> existingIdentities;
|
||||
if (overwrite && existingAccount != null) {
|
||||
existingIdentities = existingAccount.getIdentities();
|
||||
nextIdentityIndex = existingIdentities.size();
|
||||
} else {
|
||||
existingIdentities = new ArrayList<Identity>();
|
||||
}
|
||||
|
||||
// Write identities
|
||||
for (ImportedIdentity identity : account.identities) {
|
||||
int writeIdentityIndex = nextIdentityIndex;
|
||||
if (existingIdentities.size() > 0) {
|
||||
int identityIndex = findIdentity(identity, existingIdentities);
|
||||
if (overwrite && identityIndex != -1) {
|
||||
writeIdentityIndex = identityIndex;
|
||||
}
|
||||
}
|
||||
if (writeIdentityIndex == nextIdentityIndex) {
|
||||
nextIdentityIndex++;
|
||||
}
|
||||
|
||||
String identityDescription = identity.description;
|
||||
if (isIdentityDescriptionUsed(identityDescription, existingIdentities)) {
|
||||
// Identity description is already in use. So generate a new one by appending
|
||||
// " (x)", where x is the first number >= 1 that results in an unused identity
|
||||
// description.
|
||||
for (int i = 1; i <= existingIdentities.size(); i++) {
|
||||
identityDescription = identity.description + " (" + i + ")";
|
||||
if (!isIdentityDescriptionUsed(identityDescription, existingIdentities)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
editor.putString(accountKeyPrefix + Account.IDENTITY_NAME_KEY + "." +
|
||||
writeIdentityIndex, identity.name);
|
||||
editor.putString(accountKeyPrefix + Account.IDENTITY_EMAIL_KEY + "." +
|
||||
writeIdentityIndex, identity.email);
|
||||
editor.putString(accountKeyPrefix + Account.IDENTITY_DESCRIPTION_KEY + "." +
|
||||
writeIdentityIndex, identityDescription);
|
||||
|
||||
// Write identity settings
|
||||
for (Map.Entry<String, String> setting : identity.settings.settings.entrySet()) {
|
||||
String key = setting.getKey();
|
||||
String value = setting.getValue();
|
||||
editor.putString(accountKeyPrefix + key + "." + writeIdentityIndex, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isAccountNameUsed(String name, Account[] accounts) {
|
||||
for (Account account : accounts) {
|
||||
if (account.getDescription().equals(name)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean isIdentityDescriptionUsed(String description, List<Identity> identities) {
|
||||
for (Identity identitiy : identities) {
|
||||
if (identitiy.getDescription().equals(description)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static int findIdentity(ImportedIdentity identity,
|
||||
List<Identity> identities) {
|
||||
for (int i = 0; i < identities.size(); i++) {
|
||||
Identity existingIdentity = identities.get(i);
|
||||
if (existingIdentity.getName().equals(identity.name) &&
|
||||
existingIdentity.getEmail().equals(identity.email)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static Imported parseSettings(InputStream inputStream, boolean globalSettings,
|
||||
Set<String> accountUuids, boolean overwrite, boolean overview)
|
||||
throws StorageImportExportException {
|
||||
|
||||
if (!overview && accountUuids == null) {
|
||||
throw new IllegalArgumentException("Argument 'accountUuids' must not be null.");
|
||||
}
|
||||
|
||||
try {
|
||||
XmlPullParserFactory factory = XmlPullParserFactory.newInstance();
|
||||
//factory.setNamespaceAware(true);
|
||||
XmlPullParser xpp = factory.newPullParser();
|
||||
|
||||
InputStreamReader reader = new InputStreamReader(inputStream);
|
||||
xpp.setInput(reader);
|
||||
|
||||
Imported imported = null;
|
||||
int eventType = xpp.getEventType();
|
||||
while (eventType != XmlPullParser.END_DOCUMENT) {
|
||||
if(eventType == XmlPullParser.START_TAG) {
|
||||
if (StorageExporter.ROOT_ELEMENT.equals(xpp.getName())) {
|
||||
imported = parseRoot(xpp, globalSettings, accountUuids, overview);
|
||||
} else {
|
||||
Log.w(K9.LOG_TAG, "Unexpected start tag: " + xpp.getName());
|
||||
}
|
||||
}
|
||||
eventType = xpp.next();
|
||||
}
|
||||
|
||||
if (imported == null || (overview && imported.globalSettings == null &&
|
||||
imported.accounts == null)) {
|
||||
throw new StorageImportExportException("Invalid import data");
|
||||
}
|
||||
|
||||
return imported;
|
||||
} catch (Exception e) {
|
||||
throw new StorageImportExportException(e);
|
||||
} finally {
|
||||
try {
|
||||
inputStream.close();
|
||||
} catch (Exception e) { /* Ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
private static void skipToEndTag(XmlPullParser xpp, String endTag)
|
||||
throws XmlPullParserException, IOException {
|
||||
|
||||
int eventType = xpp.next();
|
||||
while (!(eventType == XmlPullParser.END_TAG && endTag.equals(xpp.getName()))) {
|
||||
eventType = xpp.next();
|
||||
}
|
||||
}
|
||||
|
||||
private static String getText(XmlPullParser xpp)
|
||||
throws XmlPullParserException, IOException {
|
||||
|
||||
int eventType = xpp.next();
|
||||
if (eventType != XmlPullParser.TEXT) {
|
||||
return null;
|
||||
}
|
||||
return xpp.getText();
|
||||
}
|
||||
|
||||
private static Imported parseRoot(XmlPullParser xpp, boolean globalSettings,
|
||||
Set<String> accountUuids, boolean overview)
|
||||
throws XmlPullParserException, IOException {
|
||||
|
||||
Imported result = new Imported();
|
||||
|
||||
//TODO: check version attribute
|
||||
|
||||
int eventType = xpp.next();
|
||||
while (!(eventType == XmlPullParser.END_TAG &&
|
||||
StorageExporter.ROOT_ELEMENT.equals(xpp.getName()))) {
|
||||
|
||||
if(eventType == XmlPullParser.START_TAG) {
|
||||
String element = xpp.getName();
|
||||
if (StorageExporter.GLOBAL_ELEMENT.equals(element)) {
|
||||
if (overview || globalSettings) {
|
||||
if (result.globalSettings == null) {
|
||||
if (overview) {
|
||||
result.globalSettings = new ImportedSettings();
|
||||
skipToEndTag(xpp, StorageExporter.GLOBAL_ELEMENT);
|
||||
} else {
|
||||
result.globalSettings = parseSettings(xpp, StorageExporter.GLOBAL_ELEMENT);
|
||||
}
|
||||
} else {
|
||||
skipToEndTag(xpp, StorageExporter.GLOBAL_ELEMENT);
|
||||
Log.w(K9.LOG_TAG, "More than one global settings element. Only using the first one!");
|
||||
}
|
||||
} else {
|
||||
skipToEndTag(xpp, StorageExporter.GLOBAL_ELEMENT);
|
||||
Log.i(K9.LOG_TAG, "Skipping global settings");
|
||||
}
|
||||
} else if (StorageExporter.ACCOUNTS_ELEMENT.equals(element)) {
|
||||
if (result.accounts == null) {
|
||||
result.accounts = parseAccounts(xpp, accountUuids, overview);
|
||||
} else {
|
||||
Log.w(K9.LOG_TAG, "More than one accounts element. Only using the first one!");
|
||||
}
|
||||
} else {
|
||||
Log.w(K9.LOG_TAG, "Unexpected start tag: " + xpp.getName());
|
||||
}
|
||||
}
|
||||
eventType = xpp.next();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static ImportedSettings parseSettings(XmlPullParser xpp, String endTag)
|
||||
throws XmlPullParserException, IOException {
|
||||
|
||||
ImportedSettings result = null;
|
||||
|
||||
int eventType = xpp.next();
|
||||
while (!(eventType == XmlPullParser.END_TAG && endTag.equals(xpp.getName()))) {
|
||||
|
||||
if(eventType == XmlPullParser.START_TAG) {
|
||||
String element = xpp.getName();
|
||||
if (StorageExporter.VALUE_ELEMENT.equals(element)) {
|
||||
String key = xpp.getAttributeValue(null, StorageExporter.KEY_ATTRIBUTE);
|
||||
String value = getText(xpp);
|
||||
|
||||
if (result == null) {
|
||||
result = new ImportedSettings();
|
||||
}
|
||||
|
||||
if (result.settings.containsKey(key)) {
|
||||
Log.w(K9.LOG_TAG, "Already read key \"" + key + "\". Ignoring value \"" + value + "\"");
|
||||
} else {
|
||||
result.settings.put(key, value);
|
||||
}
|
||||
} else {
|
||||
Log.w(K9.LOG_TAG, "Unexpected start tag: " + xpp.getName());
|
||||
}
|
||||
}
|
||||
eventType = xpp.next();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Map<String, ImportedAccount> parseAccounts(XmlPullParser xpp,
|
||||
Set<String> accountUuids, boolean overview)
|
||||
throws XmlPullParserException, IOException {
|
||||
|
||||
Map<String, ImportedAccount> accounts = null;
|
||||
|
||||
int eventType = xpp.next();
|
||||
while (!(eventType == XmlPullParser.END_TAG &&
|
||||
StorageExporter.ACCOUNTS_ELEMENT.equals(xpp.getName()))) {
|
||||
|
||||
if(eventType == XmlPullParser.START_TAG) {
|
||||
String element = xpp.getName();
|
||||
if (StorageExporter.ACCOUNT_ELEMENT.equals(element)) {
|
||||
if (accounts == null) {
|
||||
accounts = new HashMap<String, ImportedAccount>();
|
||||
}
|
||||
|
||||
ImportedAccount account = parseAccount(xpp, accountUuids, overview);
|
||||
|
||||
if (!accounts.containsKey(account.uuid)) {
|
||||
accounts.put(account.uuid, account);
|
||||
} else {
|
||||
Log.w(K9.LOG_TAG, "Duplicate account entries with UUID " + account.uuid +
|
||||
". Ignoring!");
|
||||
}
|
||||
} else {
|
||||
Log.w(K9.LOG_TAG, "Unexpected start tag: " + xpp.getName());
|
||||
}
|
||||
}
|
||||
eventType = xpp.next();
|
||||
}
|
||||
|
||||
return accounts;
|
||||
}
|
||||
|
||||
private static ImportedAccount parseAccount(XmlPullParser xpp, Set<String> accountUuids,
|
||||
boolean overview)
|
||||
throws XmlPullParserException, IOException {
|
||||
|
||||
ImportedAccount account = new ImportedAccount();
|
||||
|
||||
String uuid = xpp.getAttributeValue(null, StorageExporter.UUID_ATTRIBUTE);
|
||||
account.uuid = uuid;
|
||||
|
||||
if (overview || accountUuids.contains(uuid)) {
|
||||
int eventType = xpp.next();
|
||||
while (!(eventType == XmlPullParser.END_TAG &&
|
||||
StorageExporter.ACCOUNT_ELEMENT.equals(xpp.getName()))) {
|
||||
|
||||
if(eventType == XmlPullParser.START_TAG) {
|
||||
String element = xpp.getName();
|
||||
if (StorageExporter.NAME_ELEMENT.equals(element)) {
|
||||
account.name = getText(xpp);
|
||||
} else if (StorageExporter.SETTINGS_ELEMENT.equals(element)) {
|
||||
if (overview) {
|
||||
skipToEndTag(xpp, StorageExporter.SETTINGS_ELEMENT);
|
||||
} else {
|
||||
account.settings = parseSettings(xpp, StorageExporter.SETTINGS_ELEMENT);
|
||||
}
|
||||
} else if (StorageExporter.IDENTITIES_ELEMENT.equals(element)) {
|
||||
if (overview) {
|
||||
skipToEndTag(xpp, StorageExporter.IDENTITIES_ELEMENT);
|
||||
} else {
|
||||
account.identities = parseIdentities(xpp);
|
||||
}
|
||||
} else if (StorageExporter.FOLDERS_ELEMENT.equals(element)) {
|
||||
if (overview) {
|
||||
skipToEndTag(xpp, StorageExporter.FOLDERS_ELEMENT);
|
||||
} else {
|
||||
account.folders = parseFolders(xpp);
|
||||
}
|
||||
} else {
|
||||
Log.w(K9.LOG_TAG, "Unexpected start tag: " + xpp.getName());
|
||||
}
|
||||
}
|
||||
eventType = xpp.next();
|
||||
}
|
||||
} else {
|
||||
skipToEndTag(xpp, StorageExporter.ACCOUNT_ELEMENT);
|
||||
Log.i(K9.LOG_TAG, "Skipping account with UUID " + uuid);
|
||||
}
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
private static List<ImportedIdentity> parseIdentities(XmlPullParser xpp)
|
||||
throws XmlPullParserException, IOException {
|
||||
List<ImportedIdentity> identities = null;
|
||||
|
||||
int eventType = xpp.next();
|
||||
while (!(eventType == XmlPullParser.END_TAG &&
|
||||
StorageExporter.IDENTITIES_ELEMENT.equals(xpp.getName()))) {
|
||||
|
||||
if(eventType == XmlPullParser.START_TAG) {
|
||||
String element = xpp.getName();
|
||||
if (StorageExporter.IDENTITY_ELEMENT.equals(element)) {
|
||||
if (identities == null) {
|
||||
identities = new ArrayList<ImportedIdentity>();
|
||||
}
|
||||
|
||||
ImportedIdentity identity = parseIdentity(xpp);
|
||||
identities.add(identity);
|
||||
} else {
|
||||
Log.w(K9.LOG_TAG, "Unexpected start tag: " + xpp.getName());
|
||||
}
|
||||
}
|
||||
eventType = xpp.next();
|
||||
}
|
||||
|
||||
return identities;
|
||||
}
|
||||
|
||||
private static ImportedIdentity parseIdentity(XmlPullParser xpp)
|
||||
throws XmlPullParserException, IOException {
|
||||
ImportedIdentity identity = new ImportedIdentity();
|
||||
|
||||
int eventType = xpp.next();
|
||||
while (!(eventType == XmlPullParser.END_TAG &&
|
||||
StorageExporter.IDENTITY_ELEMENT.equals(xpp.getName()))) {
|
||||
|
||||
if(eventType == XmlPullParser.START_TAG) {
|
||||
String element = xpp.getName();
|
||||
if (StorageExporter.NAME_ELEMENT.equals(element)) {
|
||||
identity.name = getText(xpp);
|
||||
} else if (StorageExporter.EMAIL_ELEMENT.equals(element)) {
|
||||
identity.email = getText(xpp);
|
||||
} else if (StorageExporter.DESCRIPTION_ELEMENT.equals(element)) {
|
||||
identity.description = getText(xpp);
|
||||
} else if (StorageExporter.SETTINGS_ELEMENT.equals(element)) {
|
||||
identity.settings = parseSettings(xpp, StorageExporter.SETTINGS_ELEMENT);
|
||||
} else {
|
||||
Log.w(K9.LOG_TAG, "Unexpected start tag: " + xpp.getName());
|
||||
}
|
||||
}
|
||||
eventType = xpp.next();
|
||||
}
|
||||
|
||||
return identity;
|
||||
}
|
||||
|
||||
private static List<ImportedFolder> parseFolders(XmlPullParser xpp)
|
||||
throws XmlPullParserException, IOException {
|
||||
List<ImportedFolder> folders = null;
|
||||
|
||||
int eventType = xpp.next();
|
||||
while (!(eventType == XmlPullParser.END_TAG &&
|
||||
StorageExporter.FOLDERS_ELEMENT.equals(xpp.getName()))) {
|
||||
|
||||
if(eventType == XmlPullParser.START_TAG) {
|
||||
String element = xpp.getName();
|
||||
if (StorageExporter.FOLDER_ELEMENT.equals(element)) {
|
||||
if (folders == null) {
|
||||
folders = new ArrayList<ImportedFolder>();
|
||||
}
|
||||
|
||||
ImportedFolder folder = parseFolder(xpp);
|
||||
folders.add(folder);
|
||||
} else {
|
||||
Log.w(K9.LOG_TAG, "Unexpected start tag: " + xpp.getName());
|
||||
}
|
||||
}
|
||||
eventType = xpp.next();
|
||||
}
|
||||
|
||||
return folders;
|
||||
}
|
||||
|
||||
private static ImportedFolder parseFolder(XmlPullParser xpp)
|
||||
throws XmlPullParserException, IOException {
|
||||
ImportedFolder folder = new ImportedFolder();
|
||||
|
||||
String name = xpp.getAttributeValue(null, StorageExporter.NAME_ATTRIBUTE);
|
||||
folder.name = name;
|
||||
|
||||
folder.settings = parseSettings(xpp, StorageExporter.FOLDER_ELEMENT);
|
||||
|
||||
return folder;
|
||||
}
|
||||
|
||||
private static class Imported {
|
||||
public ImportedSettings globalSettings;
|
||||
public Map<String, ImportedAccount> accounts;
|
||||
}
|
||||
|
||||
private static class ImportedSettings {
|
||||
public Map<String, String> settings = new HashMap<String, String>();
|
||||
}
|
||||
|
||||
private static class ImportedAccount {
|
||||
public String uuid;
|
||||
public String name;
|
||||
public ImportedSettings settings;
|
||||
public List<ImportedIdentity> identities;
|
||||
public List<ImportedFolder> folders;
|
||||
}
|
||||
|
||||
private static class ImportedIdentity {
|
||||
public String name;
|
||||
public String email;
|
||||
public String description;
|
||||
public ImportedSettings settings;
|
||||
}
|
||||
|
||||
private static class ImportedFolder {
|
||||
public String name;
|
||||
public ImportedSettings settings;
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue