Cloud sync foundation

This commit is contained in:
M66B 2023-01-12 17:33:16 +01:00
parent 47d0d7c6ad
commit da6fe6f555
5 changed files with 388 additions and 21 deletions

View File

@ -168,6 +168,7 @@ android {
buildConfigField "String", "BITBUCKET_DOWNLOADS_API", "\"https://api.bitbucket.org/2.0/repositories/M66B/fairemail-test/downloads\""
buildConfigField "String", "BITBUCKET_DOWNLOADS_URI", "\"https://bitbucket.org/M66B/fairemail-test/downloads/\""
buildConfigField "String", "ANNOUNCEMENT_URI", "\"https://gist.githubusercontent.com/M66B/d544192ca56224839d6ba0f2f6314c1f/raw/\""
buildConfigField "String", "CLOUD_URI", "\"https://api.fairemail.net/sync\""
buildConfigField "String", "TX_URI", localProperties.getProperty("paypal.uri", "\"\"")
buildConfigField "String", "GPA_URI", localProperties.getProperty("gpa.uri", "\"\"")
buildConfigField "String", "INFO_URI", localProperties.getProperty("info.uri", "\"\"")
@ -187,6 +188,7 @@ android {
buildConfigField "String", "BITBUCKET_DOWNLOADS_API", "\"https://api.bitbucket.org/2.0/repositories/M66B/fairemail-test/downloads\""
buildConfigField "String", "BITBUCKET_DOWNLOADS_URI", "\"https://bitbucket.org/M66B/fairemail-test/downloads/\""
buildConfigField "String", "ANNOUNCEMENT_URI", "\"https://gist.githubusercontent.com/M66B/d544192ca56224839d6ba0f2f6314c1f/raw/\""
buildConfigField "String", "CLOUD_URI", "\"https://api.fairemail.net/sync\""
buildConfigField "String", "TX_URI", "\"\""
buildConfigField "String", "GPA_URI", "\"\""
buildConfigField "String", "INFO_URI", "\"\""
@ -207,6 +209,7 @@ android {
buildConfigField "String", "BITBUCKET_DOWNLOADS_API", "\"\""
buildConfigField "String", "BITBUCKET_DOWNLOADS_URI", "\"\""
buildConfigField "String", "ANNOUNCEMENT_URI", "\"\""
buildConfigField "String", "CLOUD_URI", "\"\""
buildConfigField "String", "TX_URI", "\"\""
buildConfigField "String", "GPA_URI", "\"\""
buildConfigField "String", "INFO_URI", "\"\""
@ -227,6 +230,7 @@ android {
buildConfigField "String", "BITBUCKET_DOWNLOADS_API", "\"\""
buildConfigField "String", "BITBUCKET_DOWNLOADS_URI", "\"\""
buildConfigField "String", "ANNOUNCEMENT_URI", "\"\""
buildConfigField "String", "CLOUD_URI", "\"\""
buildConfigField "String", "TX_URI", "\"\""
buildConfigField "String", "GPA_URI", "\"\""
buildConfigField "String", "INFO_URI", "\"\""

View File

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

View File

@ -2242,7 +2242,11 @@ public class Log {
Object value = settings.get(key);
if ("wipe_mnemonic".equals(key) && value != null)
value = "[redacted]";
if (key != null && key.startsWith("oauth."))
else if ("cloud_user".equals(key) && value != null)
value = "[redacted]";
else if ("cloud_password".equals(key) && value != null)
value = "[redacted]";
else if (key != null && key.startsWith("oauth."))
value = "[redacted]";
size += write(os, key + "=" + value + "\r\n");
}

View File

@ -168,6 +168,32 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/tvCloudInfo"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:layout_marginEnd="12dp"
android:drawableEnd="@drawable/twotone_info_24"
android:drawablePadding="6dp"
android:text="@string/title_advanced_cloud_security"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
android:textStyle="italic"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tvCloud" />
<TextView
android:id="@+id/tvCloudPro"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/title_pro_feature"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
android:textColor="?android:attr/textColorLink"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tvCloudInfo" />
<TextView
android:id="@+id/tvUser"
android:layout_width="wrap_content"
@ -177,7 +203,7 @@
android:text="@string/title_user"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tvCloud" />
app:layout_constraintTop_toBottomOf="@id/tvCloudPro" />
<eu.faircode.email.EditTextPlain
android:id="@+id/etUser"
@ -223,12 +249,122 @@
style="?android:attr/buttonStyleSmall"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:layout_marginTop="24dp"
android:drawableEnd="@drawable/twotone_login_24"
android:drawablePadding="6dp"
android:text="@string/title_advanced_login"
android:tag="disable"
android:text="@string/title_advanced_cloud_login"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tilPassword" />
<TextView
android:id="@+id/tvRegister"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/title_advanced_cloud_register"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
android:textStyle="italic"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/btnLogin" />
<TextView
android:id="@+id/tvLogin"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="username"
android:textAppearance="@style/TextAppearance.AppCompat.Medium"
android:textStyle="bold"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tvRegister" />
<CheckBox
android:id="@+id/cbAccounts"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/title_advanced_cloud_accounts"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tvLogin" />
<CheckBox
android:id="@+id/cbBlockedSenders"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/title_advanced_cloud_blocked_senders"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/cbAccounts" />
<CheckBox
android:id="@+id/cbFilterRules"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/title_advanced_cloud_filter_rules"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/cbBlockedSenders" />
<ImageButton
android:id="@+id/ibSync"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/cbFilterRules"
app:srcCompat="@drawable/twotone_compare_arrows_24" />
<TextView
android:id="@+id/tvLastSync"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/title_advanced_cloud_last_sync"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/ibSync" />
<Button
android:id="@+id/btnLogout"
style="?android:attr/buttonStyleSmall"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:drawableEnd="@drawable/twotone_logout_24"
android:drawablePadding="6dp"
android:tag="disable"
android:text="@string/title_advanced_cloud_logout"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tvLastSync" />
<CheckBox
android:id="@+id/cbDelete"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:drawableEnd="@drawable/twotone_warning_24"
android:drawablePadding="6dp"
android:tag="disable"
android:text="@string/title_advanced_cloud_wipe"
app:drawableTint="?attr/colorWarning"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/btnLogout" />
<androidx.constraintlayout.widget.Group
android:id="@+id/grpLogin"
android:layout_width="0dp"
android:layout_height="0dp"
app:constraint_referenced_ids="
tvUser,etUser,tvPassword,tilPassword,btnLogin,tvRegister" />
<androidx.constraintlayout.widget.Group
android:id="@+id/grpLogout"
android:layout_width="0dp"
android:layout_height="0dp"
app:constraint_referenced_ids="
tvLogin,cbAccounts,cbBlockedSenders,cbFilterRules,
ibSync,tvLastSync,
btnLogout,cbDelete" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -972,7 +972,17 @@
<string name="title_advanced_cleanup_hint">This will delete all temporary files</string>
<string name="title_advanced_keywords_hint" translatable="false">Space separated</string>
<string name="title_advanced_login" translatable="false">Login</string>
<string name="title_advanced_cloud_security" translatable="false">All data is encrypted end-to-end and the cloud server will never see the username, password and data</string>
<string name="title_advanced_cloud_login" translatable="false">Login</string>
<string name="title_advanced_cloud_register" translatable="false">Logging in for the first time will automatically create an account</string>
<string name="title_advanced_cloud_invalid" translatable="false">Invalid username or password</string>
<string name="title_advanced_cloud_accounts" translatable="false">Sync accounts</string>
<string name="title_advanced_cloud_blocked_senders" translatable="false">Sync blocked senders</string>
<string name="title_advanced_cloud_filter_rules" translatable="false">Sync filter rules</string>
<string name="title_advanced_cloud_last_sync" translatable="false">Last sync: %1$s</string>
<string name="title_advanced_cloud_update" translatable="false">Update settings</string>
<string name="title_advanced_cloud_logout" translatable="false">Logout</string>
<string name="title_advanced_cloud_wipe" translatable="false">Wipe cloud data on logout</string>
<string name="title_advanced_never_favorite">Never favorite</string>
<string name="title_advanced_edit_name">Edit name</string>