mirror of
https://github.com/M66B/FairEmail.git
synced 2026-03-27 11:25:13 +01:00
Cloud sync foundation
This commit is contained in:
@@ -62,6 +62,7 @@ import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.cardview.widget.CardView;
|
||||
import androidx.constraintlayout.widget.Group;
|
||||
import androidx.documentfile.provider.DocumentFile;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
import androidx.preference.PreferenceManager;
|
||||
@@ -81,12 +82,14 @@ import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStream;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.security.spec.InvalidKeySpecException;
|
||||
import java.security.spec.KeySpec;
|
||||
import java.text.DateFormat;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
@@ -106,22 +109,45 @@ import javax.crypto.SecretKey;
|
||||
import javax.crypto.SecretKeyFactory;
|
||||
import javax.crypto.spec.IvParameterSpec;
|
||||
import javax.crypto.spec.PBEKeySpec;
|
||||
import javax.net.ssl.HttpsURLConnection;
|
||||
|
||||
public class FragmentOptionsBackup extends FragmentBase {
|
||||
public class FragmentOptionsBackup extends FragmentBase implements SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
private View view;
|
||||
private ImageButton ibHelp;
|
||||
private Button btnExport;
|
||||
private Button btnImport;
|
||||
private CardView cardCloud;
|
||||
private TextView tvCloudInfo;
|
||||
private TextView tvCloudPro;
|
||||
private EditText etUser;
|
||||
private TextInputLayout tilPassword;
|
||||
private Button btnLogin;
|
||||
private TextView tvLogin;
|
||||
private CheckBox cbAccounts;
|
||||
private CheckBox cbBlockedSenders;
|
||||
private CheckBox cbFilterRules;
|
||||
private ImageButton ibSync;
|
||||
private TextView tvLastSync;
|
||||
private Button btnLogout;
|
||||
private CheckBox cbDelete;
|
||||
private Group grpLogin;
|
||||
private Group grpLogout;
|
||||
|
||||
private DateFormat DTF;
|
||||
|
||||
private static final int REQUEST_EXPORT_SELECT = 1;
|
||||
private static final int REQUEST_IMPORT_SELECT = 2;
|
||||
private static final int REQUEST_EXPORT_HANDLE = 3;
|
||||
private static final int REQUEST_IMPORT_HANDLE = 4;
|
||||
|
||||
private static final int CLOUD_TIMEOUT = 10 * 1000; // timeout
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
DTF = Helper.getDateTimeInstance(getContext());
|
||||
}
|
||||
|
||||
@Override
|
||||
@Nullable
|
||||
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
|
||||
@@ -135,9 +161,21 @@ public class FragmentOptionsBackup extends FragmentBase {
|
||||
btnExport = view.findViewById(R.id.btnExport);
|
||||
btnImport = view.findViewById(R.id.btnImport);
|
||||
cardCloud = view.findViewById(R.id.cardCloud);
|
||||
tvCloudInfo = view.findViewById(R.id.tvCloudInfo);
|
||||
tvCloudPro = view.findViewById(R.id.tvCloudPro);
|
||||
etUser = view.findViewById(R.id.etUser);
|
||||
tilPassword = view.findViewById(R.id.tilPassword);
|
||||
btnLogin = view.findViewById(R.id.btnLogin);
|
||||
tvLogin = view.findViewById(R.id.tvLogin);
|
||||
cbAccounts = view.findViewById(R.id.cbAccounts);
|
||||
cbBlockedSenders = view.findViewById(R.id.cbBlockedSenders);
|
||||
cbFilterRules = view.findViewById(R.id.cbFilterRules);
|
||||
ibSync = view.findViewById(R.id.ibSync);
|
||||
tvLastSync = view.findViewById(R.id.tvLastSync);
|
||||
btnLogout = view.findViewById(R.id.btnLogout);
|
||||
cbDelete = view.findViewById(R.id.cbDelete);
|
||||
grpLogin = view.findViewById(R.id.grpLogin);
|
||||
grpLogout = view.findViewById(R.id.grpLogout);
|
||||
|
||||
// Wire controls
|
||||
|
||||
@@ -150,6 +188,13 @@ public class FragmentOptionsBackup extends FragmentBase {
|
||||
}
|
||||
});
|
||||
|
||||
tvCloudInfo.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
Helper.viewFAQ(v.getContext(), 999);
|
||||
}
|
||||
});
|
||||
|
||||
btnExport.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
@@ -171,14 +216,86 @@ public class FragmentOptionsBackup extends FragmentBase {
|
||||
}
|
||||
});
|
||||
|
||||
cbAccounts.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
|
||||
@Override
|
||||
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
|
||||
prefs.edit().putBoolean("cloud_sync_accounts", isChecked).apply();
|
||||
}
|
||||
});
|
||||
|
||||
cbBlockedSenders.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
|
||||
@Override
|
||||
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
|
||||
prefs.edit().putBoolean("cloud_sync_blocked_senders", isChecked).apply();
|
||||
}
|
||||
});
|
||||
|
||||
cbFilterRules.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
|
||||
@Override
|
||||
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
|
||||
prefs.edit().putBoolean("cloud_sync_filter_rules", isChecked).apply();
|
||||
}
|
||||
});
|
||||
|
||||
ibSync.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
// TODO
|
||||
}
|
||||
});
|
||||
|
||||
btnLogout.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
onLogout();
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize
|
||||
FragmentDialogTheme.setBackground(getContext(), view, false);
|
||||
cardCloud.setVisibility(BuildConfig.DEBUG && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
||||
? View.VISIBLE : View.GONE);
|
||||
cardCloud.setVisibility(
|
||||
BuildConfig.DEBUG &&
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
|
||||
!TextUtils.isEmpty(BuildConfig.CLOUD_URI)
|
||||
? View.VISIBLE : View.GONE);
|
||||
Helper.linkPro(tvCloudPro);
|
||||
|
||||
prefs.registerOnSharedPreferenceChangeListener(this);
|
||||
cbAccounts.setChecked(prefs.getBoolean("cloud_sync_accounts", true));
|
||||
cbBlockedSenders.setChecked(prefs.getBoolean("cloud_sync_blocked_senders", true));
|
||||
cbFilterRules.setChecked(prefs.getBoolean("cloud_sync_filter_rules", true));
|
||||
onSharedPreferenceChanged(prefs, null);
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
PreferenceManager.getDefaultSharedPreferences(getContext()).unregisterOnSharedPreferenceChangeListener(this);
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSharedPreferenceChanged(SharedPreferences prefs, String key) {
|
||||
if (key == null ||
|
||||
"cloud_user".equals(key) ||
|
||||
"cloud_password".equals(key)) {
|
||||
String user = prefs.getString("cloud_user", null);
|
||||
String password = prefs.getString("cloud_password", null);
|
||||
boolean auth = !(TextUtils.isEmpty(user) || TextUtils.isEmpty(password));
|
||||
long last_sync = prefs.getLong("cloud_last_sync", 0);
|
||||
|
||||
etUser.setText(user);
|
||||
tilPassword.getEditText().setText(password);
|
||||
tvLogin.setText(user);
|
||||
tvLastSync.setText(getString(R.string.title_advanced_cloud_last_sync,
|
||||
last_sync == 0 ? "-" : DTF.format(last_sync)));
|
||||
cbDelete.setChecked(false);
|
||||
grpLogin.setVisibility(auth ? View.GONE : View.VISIBLE);
|
||||
grpLogout.setVisibility(auth ? View.VISIBLE : View.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||
super.onActivityResult(requestCode, resultCode, data);
|
||||
@@ -1362,46 +1479,142 @@ public class FragmentOptionsBackup extends FragmentBase {
|
||||
}
|
||||
|
||||
private void onLogin() {
|
||||
String username = etUser.getText().toString();
|
||||
String password = tilPassword.getEditText().getText().toString();
|
||||
|
||||
if (TextUtils.isEmpty(username.trim())) {
|
||||
etUser.requestFocus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (TextUtils.isEmpty(password)) {
|
||||
tilPassword.getEditText().requestFocus();
|
||||
return;
|
||||
}
|
||||
|
||||
Bundle args = new Bundle();
|
||||
args.putString("user", etUser.getText().toString());
|
||||
cloud(args);
|
||||
}
|
||||
|
||||
private void onLogout() {
|
||||
Bundle args = new Bundle();
|
||||
args.putBoolean("logout", true);
|
||||
args.putBoolean("wipe", cbDelete.isChecked());
|
||||
cloud(args);
|
||||
}
|
||||
|
||||
private void cloud(Bundle args) {
|
||||
args.putString("user", etUser.getText().toString().trim());
|
||||
args.putString("password", tilPassword.getEditText().getText().toString());
|
||||
|
||||
new SimpleTask<Void>() {
|
||||
new SimpleTask<String>() {
|
||||
@Override
|
||||
protected void onPreExecute(Bundle args) {
|
||||
btnLogin.setEnabled(false);
|
||||
Helper.setViewsEnabled(cardCloud, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPostExecute(Bundle args) {
|
||||
btnLogin.setEnabled(true);
|
||||
Helper.setViewsEnabled(cardCloud, true);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Void onExecute(Context context, Bundle args) throws Throwable {
|
||||
protected String onExecute(Context context, Bundle args) throws Throwable {
|
||||
String user = args.getString("user");
|
||||
String password = args.getString("password");
|
||||
boolean wipe = args.getBoolean("wipe");
|
||||
|
||||
Pair<byte[], byte[]> key = getKeyPair(user, password);
|
||||
byte[] salt = MessageDigest.getInstance("SHA256").digest(user.getBytes());
|
||||
String cloudUser = Helper.hex(MessageDigest.getInstance("SHA256").digest(salt));
|
||||
|
||||
return null;
|
||||
Pair<byte[], byte[]> key = getKeyPair(salt, password);
|
||||
String cloudPassword = Helper.hex(key.first);
|
||||
|
||||
JSONObject jroot = new JSONObject();
|
||||
jroot.put("username", cloudUser);
|
||||
jroot.put("password", cloudPassword);
|
||||
jroot.put("wipe", wipe);
|
||||
jroot.put("debug", BuildConfig.DEBUG);
|
||||
String request = jroot.toString();
|
||||
Log.i("Cloud request=" + request);
|
||||
|
||||
URL url = new URL(BuildConfig.CLOUD_URI);
|
||||
HttpsURLConnection connection = (HttpsURLConnection) url.openConnection();
|
||||
connection.setRequestMethod("POST");
|
||||
connection.setDoInput(true);
|
||||
connection.setDoOutput(true);
|
||||
connection.setReadTimeout(CLOUD_TIMEOUT);
|
||||
connection.setConnectTimeout(CLOUD_TIMEOUT);
|
||||
ConnectionHelper.setUserAgent(context, connection);
|
||||
connection.setRequestProperty("Accept", "application/json");
|
||||
connection.setRequestProperty("Content-Length", Integer.toString(request.length()));
|
||||
connection.setRequestProperty("Content-Type", "application/json");
|
||||
connection.connect();
|
||||
|
||||
try {
|
||||
connection.getOutputStream().write(request.getBytes());
|
||||
|
||||
int status = connection.getResponseCode();
|
||||
if (status != HttpsURLConnection.HTTP_OK) {
|
||||
String error = "Error " + status + ": " + connection.getResponseMessage();
|
||||
String detail = Helper.readStream(connection.getErrorStream());
|
||||
JSONObject jerror = new JSONObject(detail);
|
||||
if (status == HttpsURLConnection.HTTP_FORBIDDEN)
|
||||
throw new SecurityException(jerror.optString("error"));
|
||||
else
|
||||
throw new IOException(error + " " + jerror);
|
||||
}
|
||||
|
||||
String response = Helper.readStream(connection.getInputStream());
|
||||
Log.i("Cloud response=" + response);
|
||||
JSONObject jresponse = new JSONObject(response);
|
||||
return jresponse.optString("status");
|
||||
} finally {
|
||||
connection.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onExecuted(Bundle args, Void data) {
|
||||
protected void onExecuted(Bundle args, String status) {
|
||||
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(getContext());
|
||||
if ("ok".equals(status) && !args.getBoolean("logout"))
|
||||
prefs.edit()
|
||||
.putString("cloud_user", args.getString("user"))
|
||||
.putString("cloud_password", args.getString("password"))
|
||||
.apply();
|
||||
else
|
||||
prefs.edit()
|
||||
.remove("cloud_user")
|
||||
.remove("cloud_password")
|
||||
.apply();
|
||||
|
||||
view.post(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
view.scrollTo(0, cardCloud.getTop());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onException(Bundle args, Throwable ex) {
|
||||
Log.unexpectedError(getParentFragmentManager(), ex);
|
||||
if (ex instanceof SecurityException) {
|
||||
AlertDialog.Builder builder = new AlertDialog.Builder(getContext())
|
||||
.setIcon(R.drawable.twotone_warning_24)
|
||||
.setTitle(getString(R.string.title_advanced_cloud_invalid))
|
||||
.setNegativeButton(android.R.string.cancel, null);
|
||||
String message = ex.getMessage();
|
||||
if (!TextUtils.isEmpty(message))
|
||||
builder.setMessage(message);
|
||||
builder.show();
|
||||
} else
|
||||
Log.unexpectedError(getParentFragmentManager(), ex);
|
||||
}
|
||||
}.execute(FragmentOptionsBackup.this, args, "cloud:login");
|
||||
}.execute(FragmentOptionsBackup.this, args, "cloud");
|
||||
}
|
||||
|
||||
private static Pair<byte[], byte[]> getKeyPair(String user, String password)
|
||||
private static Pair<byte[], byte[]> getKeyPair(byte[] salt, String password)
|
||||
throws NoSuchAlgorithmException, InvalidKeySpecException {
|
||||
byte[] salt = MessageDigest.getInstance("SHA256").digest(user.getBytes());
|
||||
|
||||
// https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
|
||||
SecretKeyFactory keyFactory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
|
||||
KeySpec keySpec = new PBEKeySpec(password.toCharArray(), salt, 310000, 2 * 256);
|
||||
|
||||
Reference in New Issue
Block a user