diff --git a/FAQ.md b/FAQ.md index fa83e93bb5..1481eea646 100644 --- a/FAQ.md +++ b/FAQ.md @@ -251,6 +251,8 @@ The following Android permissions are needed: * Optional: *read your contacts* (READ_CONTACTS): to autocomplete addresses and to show photos * Optional: *read the contents of your SD card* (READ_EXTERNAL_STORAGE): to accept files from other, outdated apps, see also [this FAQ](#user-content-faq49) * Optional: *use fingerprint hardware* (USE_FINGERPRINT) and use *biometric hardware* (USE_BIOMETRIC): to use biometric authentication +* Optional: *find accounts on the device* (GET_ACCOUNTS): to use [OAuth](https://en.wikipedia.org/wiki/OAuth) instead of passwords +* Android 5.1 Lollipop and before: *use accounts on the device* (USE_CREDENTIALS): needed to select accounts (not used/needed on later Android versions) The following permissions are needed to show the count of unread messages as a badge (see also [this FAQ](#user-content-faq106)): diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index daab975f8e..11b052ab47 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -12,6 +12,12 @@ + + + + 0 || !settings ? View.GONE : View.VISIBLE); tvDrafts.setVisibility(account.drafts || !settings ? View.GONE : View.VISIBLE); diff --git a/app/src/main/java/eu/faircode/email/AdapterIdentity.java b/app/src/main/java/eu/faircode/email/AdapterIdentity.java index fe5b86d06b..18ce3cd561 100644 --- a/app/src/main/java/eu/faircode/email/AdapterIdentity.java +++ b/app/src/main/java/eu/faircode/email/AdapterIdentity.java @@ -72,7 +72,6 @@ public class AdapterIdentity extends RecyclerView.Adapter RLAH_COUNTRY_CODES = Collections.unmodifiableList(Arrays.asList( diff --git a/app/src/main/java/eu/faircode/email/DaoAccount.java b/app/src/main/java/eu/faircode/email/DaoAccount.java index 7921be8b73..4107cc56f0 100644 --- a/app/src/main/java/eu/faircode/email/DaoAccount.java +++ b/app/src/main/java/eu/faircode/email/DaoAccount.java @@ -105,6 +105,9 @@ public interface DaoAccount { @Update void updateAccount(EntityAccount account); + @Query("UPDATE account SET password = :password WHERE password = :old") + int updateAccountPassword(String old, String password); + @Query("UPDATE account SET separator = :separator WHERE id = :id") int setFolderSeparator(long id, Character separator); @@ -117,9 +120,6 @@ public interface DaoAccount { @Query("UPDATE account SET last_connected = :last_connected WHERE id = :id") int setAccountConnected(long id, long last_connected); - @Query("UPDATE account SET password = :password WHERE id = :id") - int setAccountPassword(long id, String password); - @Query("UPDATE account SET `order` = :order WHERE id = :id") int setAccountOrder(long id, Integer order); @@ -129,9 +129,6 @@ public interface DaoAccount { @Query("UPDATE account SET error = :error WHERE id = :id") int setAccountError(long id, String error); - @Query("UPDATE account SET poll_interval = :poll_interval WHERE id = :id") - int setAccountPollInterval(long id, int poll_interval); - @Query("UPDATE account SET `primary` = 0") void resetPrimary(); diff --git a/app/src/main/java/eu/faircode/email/DaoIdentity.java b/app/src/main/java/eu/faircode/email/DaoIdentity.java index e59d6b8112..32f6fbc6cf 100644 --- a/app/src/main/java/eu/faircode/email/DaoIdentity.java +++ b/app/src/main/java/eu/faircode/email/DaoIdentity.java @@ -67,6 +67,9 @@ public interface DaoIdentity { @Update void updateIdentity(EntityIdentity identity); + @Query("UPDATE identity SET password = :password WHERE password = :old") + int updateIdentityPassword(String old, String password); + @Query("UPDATE identity SET synchronize = :synchronize WHERE id = :id") int setIdentitySynchronize(long id, boolean synchronize); diff --git a/app/src/main/java/eu/faircode/email/EntityAccount.java b/app/src/main/java/eu/faircode/email/EntityAccount.java index 5b14043c45..3bbdcd45f4 100644 --- a/app/src/main/java/eu/faircode/email/EntityAccount.java +++ b/app/src/main/java/eu/faircode/email/EntityAccount.java @@ -54,8 +54,6 @@ public class EntityAccount extends EntityOrder implements Serializable { @PrimaryKey(autoGenerate = true) public Long id; - @NonNull - public Integer auth_type; @NonNull public Boolean pop = false; // obsolete @NonNull @@ -63,10 +61,12 @@ public class EntityAccount extends EntityOrder implements Serializable { @NonNull public Boolean starttls; @NonNull - public Boolean insecure; + public Boolean insecure = false; @NonNull public Integer port; @NonNull + public Integer auth_type; + @NonNull public String user; @NonNull public String password; @@ -83,14 +83,14 @@ public class EntityAccount extends EntityOrder implements Serializable { @NonNull public Boolean primary; @NonNull - public Boolean notify; + public Boolean notify = false; @NonNull public Boolean browse = true; public Character separator; public Long swipe_left; public Long swipe_right; @NonNull - public Integer poll_interval; // keep-alive interval + public Integer poll_interval = DEFAULT_KEEP_ALIVE_INTERVAL; // keep-alive interval @NonNull public Boolean partial_fetch = true; public String prefix; // namespace, obsolete @@ -146,11 +146,11 @@ public class EntityAccount extends EntityOrder implements Serializable { JSONObject json = new JSONObject(); json.put("id", id); json.put("order", order); - json.put("auth_type", auth_type); json.put("host", host); json.put("starttls", starttls); json.put("insecure", insecure); json.put("port", port); + json.put("auth_type", auth_type); json.put("user", user); json.put("password", password); json.put("realm", realm); @@ -184,11 +184,11 @@ public class EntityAccount extends EntityOrder implements Serializable { if (json.has("order")) account.order = json.getInt("order"); - account.auth_type = json.getInt("auth_type"); account.host = json.getString("host"); account.starttls = (json.has("starttls") && json.getBoolean("starttls")); account.insecure = (json.has("insecure") && json.getBoolean("insecure")); account.port = json.getInt("port"); + account.auth_type = json.getInt("auth_type"); account.user = json.getString("user"); account.password = json.getString("password"); if (json.has("realm")) @@ -227,11 +227,11 @@ public class EntityAccount extends EntityOrder implements Serializable { public boolean equals(Object obj) { if (obj instanceof EntityAccount) { EntityAccount other = (EntityAccount) obj; - return (this.auth_type.equals(other.auth_type) && - this.host.equals(other.host) && + return (this.host.equals(other.host) && this.starttls == other.starttls && this.insecure == other.insecure && this.port.equals(other.port) && + this.auth_type.equals(other.auth_type) && this.user.equals(other.user) && this.password.equals(other.password) && Objects.equals(this.realm, other.realm) && diff --git a/app/src/main/java/eu/faircode/email/EntityIdentity.java b/app/src/main/java/eu/faircode/email/EntityIdentity.java index 595340ae2c..7d55c6494c 100644 --- a/app/src/main/java/eu/faircode/email/EntityIdentity.java +++ b/app/src/main/java/eu/faircode/email/EntityIdentity.java @@ -57,16 +57,16 @@ public class EntityIdentity { public Integer color; public String signature; @NonNull - public Integer auth_type; - @NonNull public String host; // SMTP @NonNull public Boolean starttls; @NonNull - public Boolean insecure; + public Boolean insecure = false; @NonNull public Integer port; @NonNull + public Integer auth_type; + @NonNull public String user; @NonNull public String password; @@ -113,11 +113,11 @@ public class EntityIdentity { json.put("signature", signature); // not account - json.put("auth_type", auth_type); json.put("host", host); json.put("starttls", starttls); json.put("insecure", insecure); json.put("port", port); + json.put("auth_type", auth_type); json.put("user", user); json.put("password", password); json.put("realm", realm); @@ -151,11 +151,11 @@ public class EntityIdentity { if (json.has("signature") && !json.isNull("signature")) identity.signature = json.getString("signature"); - identity.auth_type = json.getInt("auth_type"); identity.host = json.getString("host"); identity.starttls = json.getBoolean("starttls"); identity.insecure = (json.has("insecure") && json.getBoolean("insecure")); identity.port = json.getInt("port"); + identity.auth_type = json.getInt("auth_type"); identity.user = json.getString("user"); identity.password = json.getString("password"); if (json.has("realm") && !json.isNull("realm")) @@ -196,11 +196,11 @@ public class EntityIdentity { Objects.equals(this.display, other.display) && Objects.equals(this.color, other.color) && Objects.equals(this.signature, other.signature) && - this.auth_type.equals(other.auth_type) && this.host.equals(other.host) && this.starttls.equals(other.starttls) && this.insecure.equals(other.insecure) && this.port.equals(other.port) && + this.auth_type.equals(other.auth_type) && this.user.equals(other.user) && this.password.equals(other.password) && Objects.equals(this.realm, other.realm) && diff --git a/app/src/main/java/eu/faircode/email/FragmentAccount.java b/app/src/main/java/eu/faircode/email/FragmentAccount.java index 4ca223c6d1..7dcb840f77 100644 --- a/app/src/main/java/eu/faircode/email/FragmentAccount.java +++ b/app/src/main/java/eu/faircode/email/FragmentAccount.java @@ -142,6 +142,7 @@ public class FragmentAccount extends FragmentBase { private long id = -1; private long copy = -1; + private int auth = MailService.AUTH_TYPE_PASSWORD; private boolean saving = false; private int color = Color.TRANSPARENT; @@ -516,6 +517,7 @@ public class FragmentAccount extends FragmentBase { args.putBoolean("starttls", rgEncryption.getCheckedRadioButtonId() == R.id.radio_starttls); args.putBoolean("insecure", cbInsecure.isChecked()); args.putString("port", etPort.getText().toString()); + args.putInt("auth", auth); args.putString("user", etUser.getText().toString()); args.putString("password", tilPassword.getEditText().getText().toString()); args.putString("realm", etRealm.getText().toString()); @@ -551,6 +553,7 @@ public class FragmentAccount extends FragmentBase { boolean starttls = args.getBoolean("starttls"); boolean insecure = args.getBoolean("insecure"); String port = args.getString("port"); + int auth = args.getInt("auth"); String user = args.getString("user"); String password = args.getString("password"); String realm = args.getString("realm"); @@ -581,7 +584,7 @@ public class FragmentAccount extends FragmentBase { // Check IMAP server / get folders String protocol = "imap" + (starttls ? "" : "s"); try (MailService iservice = new MailService(context, protocol, realm, insecure, true)) { - iservice.connect(host, Integer.parseInt(port), user, password); + iservice.connect(host, Integer.parseInt(port), auth, user, password); result.idle = iservice.getStore().hasCapability("IDLE"); @@ -709,6 +712,7 @@ public class FragmentAccount extends FragmentBase { args.putBoolean("starttls", rgEncryption.getCheckedRadioButtonId() == R.id.radio_starttls); args.putBoolean("insecure", cbInsecure.isChecked()); args.putString("port", etPort.getText().toString()); + args.putInt("auth", auth); args.putString("user", etUser.getText().toString()); args.putString("password", tilPassword.getEditText().getText().toString()); args.putString("realm", etRealm.getText().toString()); @@ -762,6 +766,7 @@ public class FragmentAccount extends FragmentBase { boolean starttls = args.getBoolean("starttls"); boolean insecure = args.getBoolean("insecure"); String port = args.getString("port"); + int auth = args.getInt("auth"); String user = args.getString("user").trim(); String password = args.getString("password"); String realm = args.getString("realm"); @@ -829,6 +834,8 @@ public class FragmentAccount extends FragmentBase { return true; if (!Objects.equals(account.port, Integer.parseInt(port))) return true; + if (account.auth_type != auth) + return true; if (!Objects.equals(account.user, user)) return true; if (!Objects.equals(account.password, password)) @@ -907,7 +914,7 @@ public class FragmentAccount extends FragmentBase { if (check) { String protocol = "imap" + (starttls ? "" : "s"); try (MailService iservice = new MailService(context, protocol, realm, insecure, true)) { - iservice.connect(host, Integer.parseInt(port), user, password); + iservice.connect(host, Integer.parseInt(port), auth, user, password); for (Folder ifolder : iservice.getStore().getDefaultFolder().list("*")) { // Check folder attributes @@ -948,13 +955,15 @@ public class FragmentAccount extends FragmentBase { if (account == null) account = new EntityAccount(); - account.auth_type = ConnectionHelper.AUTH_TYPE_PASSWORD; account.host = host; account.starttls = starttls; account.insecure = insecure; account.port = Integer.parseInt(port); - account.user = user; - account.password = password; + account.auth_type = auth; + if (auth == MailService.AUTH_TYPE_PASSWORD) { + account.user = user; + account.password = password; + } account.realm = realm; account.name = name; @@ -1139,6 +1148,7 @@ public class FragmentAccount extends FragmentBase { outState.putInt("fair:provider", spProvider.getSelectedItemPosition()); outState.putString("fair:password", tilPassword.getEditText().getText().toString()); outState.putInt("fair:advanced", grpAdvanced.getVisibility()); + outState.putInt("fair:auth", auth); outState.putInt("fair:color", color); super.onSaveInstanceState(outState); } @@ -1210,6 +1220,7 @@ public class FragmentAccount extends FragmentBase { etInterval.setText(account == null ? "" : Long.toString(account.poll_interval)); cbPartialFetch.setChecked(account == null ? true : account.partial_fetch); + auth = (account == null ? MailService.AUTH_TYPE_PASSWORD : account.auth_type); color = (account == null || account.color == null ? Color.TRANSPARENT : account.color); new SimpleTask() { @@ -1236,11 +1247,17 @@ public class FragmentAccount extends FragmentBase { tilPassword.getEditText().setText(savedInstanceState.getString("fair:password")); grpAdvanced.setVisibility(savedInstanceState.getInt("fair:advanced")); + auth = savedInstanceState.getInt("fair:auth"); color = savedInstanceState.getInt("fair:color"); } Helper.setViewsEnabled(view, true); + if (auth != MailService.AUTH_TYPE_PASSWORD) { + etUser.setEnabled(false); + tilPassword.setEnabled(false); + } + setColor(color); cbPrimary.setEnabled(cbSynchronize.isChecked()); diff --git a/app/src/main/java/eu/faircode/email/FragmentIdentity.java b/app/src/main/java/eu/faircode/email/FragmentIdentity.java index d2386019bf..94813eb1a6 100644 --- a/app/src/main/java/eu/faircode/email/FragmentIdentity.java +++ b/app/src/main/java/eu/faircode/email/FragmentIdentity.java @@ -130,6 +130,7 @@ public class FragmentIdentity extends FragmentBase { private long id = -1; private long copy = -1; + private int auth = MailService.AUTH_TYPE_PASSWORD; private boolean saving = false; private int color = Color.TRANSPARENT; @@ -537,6 +538,7 @@ public class FragmentIdentity extends FragmentBase { args.putBoolean("starttls", rgEncryption.getCheckedRadioButtonId() == R.id.radio_starttls); args.putBoolean("insecure", cbInsecure.isChecked()); args.putString("port", etPort.getText().toString()); + args.putInt("auth", auth); args.putString("user", etUser.getText().toString()); args.putString("password", tilPassword.getEditText().getText().toString()); args.putString("realm", etRealm.getText().toString()); @@ -584,6 +586,7 @@ public class FragmentIdentity extends FragmentBase { boolean starttls = args.getBoolean("starttls"); boolean insecure = args.getBoolean("insecure"); String port = args.getString("port"); + int auth = args.getInt("auth"); String user = args.getString("user").trim(); String password = args.getString("password"); String realm = args.getString("realm"); @@ -680,6 +683,8 @@ public class FragmentIdentity extends FragmentBase { return true; if (!Objects.equals(identity.port, Integer.parseInt(port))) return true; + if (identity.auth_type != auth) + return true; if (!Objects.equals(identity.user, user)) return true; if (!Objects.equals(identity.password, password)) @@ -731,7 +736,7 @@ public class FragmentIdentity extends FragmentBase { String protocol = (starttls ? "smtp" : "smtps"); try (MailService iservice = new MailService(context, protocol, realm, insecure, true)) { iservice.setUseIp(use_ip); - iservice.connect(host, Integer.parseInt(port), user, password); + iservice.connect(host, Integer.parseInt(port), auth, user, password); } } @@ -748,13 +753,15 @@ public class FragmentIdentity extends FragmentBase { identity.color = color; identity.signature = signature; - identity.auth_type = ConnectionHelper.AUTH_TYPE_PASSWORD; identity.host = host; identity.starttls = starttls; identity.insecure = insecure; identity.port = Integer.parseInt(port); - identity.user = user; - identity.password = password; + identity.auth_type = auth; + if (auth == MailService.AUTH_TYPE_PASSWORD) { + identity.user = user; + identity.password = password; + } identity.realm = realm; identity.use_ip = use_ip; identity.synchronize = synchronize; @@ -853,6 +860,7 @@ public class FragmentIdentity extends FragmentBase { outState.putInt("fair:provider", spProvider.getSelectedItemPosition()); outState.putString("fair:password", tilPassword.getEditText().getText().toString()); outState.putInt("fair:advanced", grpAdvanced.getVisibility()); + outState.putInt("fair:auth", auth); outState.putInt("fair:color", color); outState.putString("fair:html", (String) etSignature.getTag()); super.onSaveInstanceState(outState); @@ -902,6 +910,7 @@ public class FragmentIdentity extends FragmentBase { cbDeliveryReceipt.setChecked(identity == null ? false : identity.delivery_receipt); cbReadReceipt.setChecked(identity == null ? false : identity.read_receipt); + auth = (identity == null ? MailService.AUTH_TYPE_PASSWORD : identity.auth_type); color = (identity == null || identity.color == null ? Color.TRANSPARENT : identity.color); if (identity == null || copy > 0) @@ -924,12 +933,18 @@ public class FragmentIdentity extends FragmentBase { } else { tilPassword.getEditText().setText(savedInstanceState.getString("fair:password")); grpAdvanced.setVisibility(savedInstanceState.getInt("fair:advanced")); + auth = savedInstanceState.getInt("fair:auth"); color = savedInstanceState.getInt("fair:color"); etSignature.setTag(savedInstanceState.getString("fair:html")); } Helper.setViewsEnabled(view, true); + if (auth != MailService.AUTH_TYPE_PASSWORD) { + etUser.setEnabled(false); + tilPassword.setEnabled(false); + } + setColor(color); cbPrimary.setEnabled(cbSynchronize.isChecked()); @@ -949,7 +964,6 @@ public class FragmentIdentity extends FragmentBase { EntityAccount unselected = new EntityAccount(); unselected.id = -1L; - unselected.auth_type = ConnectionHelper.AUTH_TYPE_PASSWORD; unselected.name = getString(R.string.title_select); unselected.primary = false; accounts.add(0, unselected); diff --git a/app/src/main/java/eu/faircode/email/FragmentQuickSetup.java b/app/src/main/java/eu/faircode/email/FragmentQuickSetup.java index 9dca020ad8..6473ac5355 100644 --- a/app/src/main/java/eu/faircode/email/FragmentQuickSetup.java +++ b/app/src/main/java/eu/faircode/email/FragmentQuickSetup.java @@ -49,15 +49,12 @@ import androidx.appcompat.app.AlertDialog; import androidx.constraintlayout.widget.Group; import com.google.android.material.textfield.TextInputLayout; -import com.sun.mail.imap.IMAPFolder; import java.net.UnknownHostException; -import java.util.ArrayList; import java.util.Date; import java.util.List; import javax.mail.AuthenticationFailedException; -import javax.mail.Folder; import static android.app.Activity.RESULT_OK; @@ -256,79 +253,32 @@ public class FragmentQuickSetup extends FragmentBase { String user = (provider.user == EmailProvider.UserType.EMAIL ? email : dparts[0]); Log.i("User type=" + provider.user + " name=" + user); - List folders = new ArrayList<>(); - long now = new Date().getTime(); - - { - String protocol = provider.imap.starttls ? "imap" : "imaps"; - try (MailService iservice = new MailService(context, protocol, null, false, true)) { - try { - iservice.connect(provider.imap.host, provider.imap.port, user, password); - } catch (AuthenticationFailedException ex) { - if (user.contains("@")) { - Log.w(ex); - user = dparts[0]; - Log.i("Retry with user=" + user); - iservice.connect(provider.imap.host, provider.imap.port, user, password); - } else - throw ex; - } - - List guesses = new ArrayList<>(); - - for (Folder ifolder : iservice.getStore().getDefaultFolder().list("*")) { - String fullName = ifolder.getFullName(); - String[] attrs = ((IMAPFolder) ifolder).getAttributes(); - String type = EntityFolder.getType(attrs, fullName, true); - Log.i(fullName + " attrs=" + TextUtils.join(" ", attrs) + " type=" + type); - - if (type != null) { - EntityFolder folder = new EntityFolder(fullName, type); - folders.add(folder); - - if (EntityFolder.USER.equals(type)) { - String guess = EntityFolder.guessType(fullName); - if (guess != null) - guesses.add(folder); - } - } - } - - for (EntityFolder guess : guesses) { - boolean has = false; - String gtype = EntityFolder.guessType(guess.name); - for (EntityFolder folder : folders) - if (folder.type.equals(gtype)) { - has = true; - break; - } - if (!has) { - guess.type = gtype; - Log.i(guess.name + " guessed type=" + gtype); - } - } - - boolean inbox = false; - boolean drafts = false; - for (EntityFolder folder : folders) - if (EntityFolder.INBOX.equals(folder.type)) - inbox = true; - else if (EntityFolder.DRAFTS.equals(folder.type)) - drafts = true; + List folders; + + String aprotocol = provider.imap.starttls ? "imap" : "imaps"; + try (MailService iservice = new MailService(context, aprotocol, null, false, true)) { + try { + iservice.connect(provider.imap.host, provider.imap.port, MailService.AUTH_TYPE_PASSWORD, user, password); + } catch (AuthenticationFailedException ex) { + if (user.contains("@")) { + Log.w(ex); + user = dparts[0]; + Log.i("Retry with user=" + user); + iservice.connect(provider.imap.host, provider.imap.port, MailService.AUTH_TYPE_PASSWORD, user, password); + } else + throw ex; + } - Log.i("Quick inbox=" + inbox + " drafts=" + drafts); + folders = iservice.getFolders(); - if (!inbox || !drafts) - throw new IllegalArgumentException( - context.getString(R.string.title_setup_no_settings, dparts[1])); - } + if (folders == null) + throw new IllegalArgumentException( + context.getString(R.string.title_setup_no_settings, dparts[1])); } - { - String protocol = provider.smtp.starttls ? "smtp" : "smtps"; - try (MailService iservice = new MailService(context, protocol, null, false, true)) { - iservice.connect(provider.smtp.host, provider.smtp.port, user, password); - } + String iprotocol = provider.smtp.starttls ? "smtp" : "smtps"; + try (MailService iservice = new MailService(context, iprotocol, null, false, true)) { + iservice.connect(provider.smtp.host, provider.smtp.port, MailService.AUTH_TYPE_PASSWORD, user, password); } if (check) @@ -337,31 +287,26 @@ public class FragmentQuickSetup extends FragmentBase { DB db = DB.getInstance(context); try { db.beginTransaction(); + EntityAccount primary = db.account().getPrimaryAccount(); // Create account EntityAccount account = new EntityAccount(); - account.auth_type = ConnectionHelper.AUTH_TYPE_PASSWORD; account.host = provider.imap.host; account.starttls = provider.imap.starttls; - account.insecure = false; account.port = provider.imap.port; + account.auth_type = MailService.AUTH_TYPE_PASSWORD; account.user = user; account.password = password; account.name = provider.name; - account.color = null; account.synchronize = true; account.primary = (primary == null); - account.notify = false; - account.browse = true; - account.poll_interval = EntityAccount.DEFAULT_KEEP_ALIVE_INTERVAL; - account.created = now; - account.error = null; - account.last_connected = now; + account.created = new Date().getTime(); + account.last_connected = account.created; account.id = db.account().insertAccount(account); EntityLog.log(context, "Quick added account=" + account.name); @@ -388,27 +333,15 @@ public class FragmentQuickSetup extends FragmentBase { identity.email = email; identity.account = account.id; - identity.display = null; - identity.color = null; - - identity.signature = null; - - identity.auth_type = ConnectionHelper.AUTH_TYPE_PASSWORD; identity.host = provider.smtp.host; identity.starttls = provider.smtp.starttls; - identity.insecure = false; identity.port = provider.smtp.port; + identity.auth_type = MailService.AUTH_TYPE_PASSWORD; identity.user = user; identity.password = password; identity.synchronize = true; identity.primary = true; - identity.replyto = null; - identity.bcc = null; - identity.delivery_receipt = false; - identity.read_receipt = false; - identity.error = null; - identity.id = db.identity().insertIdentity(identity); EntityLog.log(context, "Quick added identity=" + identity.name + " email=" + identity.email); diff --git a/app/src/main/java/eu/faircode/email/FragmentSetup.java b/app/src/main/java/eu/faircode/email/FragmentSetup.java index 6cd9bf25cf..6129378f25 100644 --- a/app/src/main/java/eu/faircode/email/FragmentSetup.java +++ b/app/src/main/java/eu/faircode/email/FragmentSetup.java @@ -20,6 +20,12 @@ package eu.faircode.email; */ import android.Manifest; +import android.accounts.Account; +import android.accounts.AccountManager; +import android.accounts.AccountManagerCallback; +import android.accounts.AccountManagerFuture; +import android.accounts.AccountsException; +import android.app.Activity; import android.app.Dialog; import android.content.ComponentName; import android.content.Context; @@ -27,12 +33,14 @@ import android.content.DialogInterface; import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.PackageManager; +import android.database.Cursor; import android.graphics.drawable.Drawable; import android.net.ConnectivityManager; import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.os.PowerManager; +import android.provider.ContactsContract; import android.provider.Settings; import android.view.LayoutInflater; import android.view.View; @@ -45,13 +53,20 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; import androidx.constraintlayout.widget.Group; -import androidx.core.content.ContextCompat; +import androidx.lifecycle.Lifecycle; import androidx.lifecycle.Observer; import androidx.localbroadcastmanager.content.LocalBroadcastManager; import androidx.preference.PreferenceManager; +import com.google.android.material.snackbar.Snackbar; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Date; import java.util.List; +import static android.accounts.AccountManager.newChooseAccountIntent; + public class FragmentSetup extends FragmentBase { private ViewGroup view; @@ -60,6 +75,7 @@ public class FragmentSetup extends FragmentBase { private Button btnHelp; private Button btnQuick; + private Button btnGmail; private TextView tvAccountDone; private Button btnAccount; @@ -87,10 +103,6 @@ public class FragmentSetup extends FragmentBase { private int colorWarning; private Drawable check; - private static final String[] permissions = new String[]{ - Manifest.permission.READ_CONTACTS - }; - @Override @Nullable public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { @@ -108,6 +120,7 @@ public class FragmentSetup extends FragmentBase { btnHelp = view.findViewById(R.id.btnHelp); btnQuick = view.findViewById(R.id.btnQuick); + btnGmail = view.findViewById(R.id.btnGmail); tvAccountDone = view.findViewById(R.id.tvAccountDone); btnAccount = view.findViewById(R.id.btnAccount); @@ -166,6 +179,28 @@ public class FragmentSetup extends FragmentBase { } }); + btnGmail.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + List permissions = new ArrayList<>(); + permissions.add(Manifest.permission.READ_CONTACTS); // profile + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) + permissions.add(Manifest.permission.GET_ACCOUNTS); + + boolean granted = true; + for (String permission : permissions) + if (!hasPermission(permission)) { + granted = false; + break; + } + + if (granted) + selectAccount(); + else + requestPermissions(permissions.toArray(new String[0]), ActivitySetup.REQUEST_CHOOSE_ACCOUNT); + } + }); + btnAccount.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { @@ -186,7 +221,8 @@ public class FragmentSetup extends FragmentBase { @Override public void onClick(View view) { btnPermissions.setEnabled(false); - requestPermissions(permissions, ActivitySetup.REQUEST_PERMISSION); + String permission = Manifest.permission.READ_CONTACTS; + requestPermissions(new String[]{permission}, ActivitySetup.REQUEST_PERMISSION); } }); @@ -226,6 +262,8 @@ public class FragmentSetup extends FragmentBase { }); // Initialize + btnGmail.setVisibility(Helper.hasValidFingerprint(getContext()) ? View.VISIBLE : View.GONE); + tvAccountDone.setText(null); tvAccountDone.setCompoundDrawables(null, null, null, null); tvNoPrimaryDrafts.setVisibility(View.GONE); @@ -248,11 +286,7 @@ public class FragmentSetup extends FragmentBase { grpWelcome.setVisibility(welcome ? View.VISIBLE : View.GONE); grpDataSaver.setVisibility(View.GONE); - int[] grantResults = new int[permissions.length]; - for (int i = 0; i < permissions.length; i++) - grantResults[i] = ContextCompat.checkSelfPermission(getActivity(), permissions[i]); - - checkPermissions(permissions, grantResults, true); + setContactsPermission(hasPermission(Manifest.permission.READ_CONTACTS)); // Create outbox new SimpleTask() { @@ -392,25 +426,227 @@ public class FragmentSetup extends FragmentBase { @Override public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { - if (requestCode == ActivitySetup.REQUEST_PERMISSION) - checkPermissions(permissions, grantResults, false); + boolean granted = true; + for (int i = 0; i < permissions.length; i++) + if (grantResults[i] == PackageManager.PERMISSION_GRANTED) { + if (Manifest.permission.READ_CONTACTS.equals(permissions[i])) + setContactsPermission(true); + } else + granted = false; + + if (requestCode == ActivitySetup.REQUEST_CHOOSE_ACCOUNT) + if (granted) + selectAccount(); + } + + private void setContactsPermission(boolean granted) { + if (granted) + ContactInfo.init(getContext()); + + tvPermissionsDone.setText(granted ? R.string.title_setup_done : R.string.title_setup_to_do); + tvPermissionsDone.setTextColor(granted ? textColorPrimary : colorWarning); + tvPermissionsDone.setCompoundDrawablesWithIntrinsicBounds(granted ? check : null, null, null, null); + btnPermissions.setEnabled(!granted); + } + + private void selectAccount() { + Log.i("Select account"); + startActivityForResult( + Helper.getChooser(getContext(), newChooseAccountIntent( + null, + null, + new String[]{"com.google"}, + false, + null, + null, + null, + null)), + ActivitySetup.REQUEST_CHOOSE_ACCOUNT); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + switch (requestCode) { + case ActivitySetup.REQUEST_CHOOSE_ACCOUNT: + if (resultCode == Activity.RESULT_OK && data != null) + onAccountSelected(data); + break; + } } - private void checkPermissions(String[] permissions, @NonNull int[] grantResults, boolean init) { - boolean has = (grantResults.length > 0); - for (int result : grantResults) - if (result != PackageManager.PERMISSION_GRANTED) { - has = false; + private void onAccountSelected(Intent data) { + Log.i("Selected " + data); + Log.logExtras(data); + + String name = data.getStringExtra(AccountManager.KEY_ACCOUNT_NAME); + String type = data.getStringExtra(AccountManager.KEY_ACCOUNT_TYPE); + + AccountManager am = AccountManager.get(getContext()); + Account[] accounts = am.getAccountsByType(type); + Log.i("Accounts=" + accounts.length); + for (final Account account : accounts) + if (name.equals(account.name)) { + Snackbar.make(view, R.string.title_authorizing, Snackbar.LENGTH_LONG).show(); + + am.getAuthToken( + account, + MailService.getAuthTokenType(type), + new Bundle(), + getActivity(), + new AccountManagerCallback() { + @Override + public void run(AccountManagerFuture future) { + try { + Bundle bundle = future.getResult(); + String token = bundle.getString(AccountManager.KEY_AUTHTOKEN); + Log.i("Got token=" + token); + onAuthorized(name, token); + } catch (Throwable ex) { + if (ex instanceof AccountsException || ex instanceof IOException) { + if (getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.RESUMED)) + Snackbar.make(view, Helper.formatThrowable(ex), Snackbar.LENGTH_LONG).show(); + } else { + Log.e(ex); + Helper.unexpectedError(getFragmentManager(), ex); + } + } + } + }, + null); break; } + } - if (has) - ContactInfo.init(getContext()); + private void onAuthorized(String user, String password) { + Bundle args = new Bundle(); + args.putString("user", user); + args.putString("password", password); + + new SimpleTask() { + @Override + protected Void onExecute(Context context, Bundle args) throws Throwable { + String user = args.getString("user"); + String password = args.getString("password"); + + if (!user.contains("@")) + throw new IllegalArgumentException(user); + + String domain = user.split("@")[1]; + EmailProvider provider = EmailProvider.fromDomain(context, domain, EmailProvider.Discover.ALL); + if (provider == null) + throw new IllegalArgumentException(user); + + List folders; + + String aprotocol = provider.imap.starttls ? "imap" : "imaps"; + try (MailService iservice = new MailService(context, aprotocol, null, false, true)) { + iservice.connect(provider.imap.host, provider.imap.port, MailService.AUTH_TYPE_GMAIL, user, password); + + folders = iservice.getFolders(); + + if (folders == null) + throw new IllegalArgumentException(domain); + } - tvPermissionsDone.setText(has ? R.string.title_setup_done : R.string.title_setup_to_do); - tvPermissionsDone.setTextColor(has ? textColorPrimary : colorWarning); - tvPermissionsDone.setCompoundDrawablesWithIntrinsicBounds(has ? check : null, null, null, null); - btnPermissions.setEnabled(!has); + String iprotocol = provider.smtp.starttls ? "smtp" : "smtps"; + try (MailService iservice = new MailService(context, iprotocol, null, false, true)) { + iservice.connect(provider.smtp.host, provider.smtp.port, MailService.AUTH_TYPE_GMAIL, user, password); + } + + DB db = DB.getInstance(context); + try { + db.beginTransaction(); + + EntityAccount primary = db.account().getPrimaryAccount(); + + // Create account + EntityAccount account = new EntityAccount(); + + account.host = provider.imap.host; + account.starttls = provider.imap.starttls; + account.port = provider.imap.port; + account.auth_type = MailService.AUTH_TYPE_GMAIL; + account.user = user; + account.password = password; + + account.name = provider.name; + + account.synchronize = true; + account.primary = (primary == null); + + account.created = new Date().getTime(); + account.last_connected = account.created; + + account.id = db.account().insertAccount(account); + EntityLog.log(context, "Gmail account=" + account.name); + + // Create folders + for (EntityFolder folder : folders) { + folder.account = account.id; + folder.id = db.folder().insertFolder(folder); + EntityLog.log(context, "Gmail folder=" + folder.name + " type=" + folder.type); + } + + // Set swipe left/right folder + for (EntityFolder folder : folders) + if (EntityFolder.TRASH.equals(folder.type)) + account.swipe_left = folder.id; + else if (EntityFolder.ARCHIVE.equals(folder.type)) + account.swipe_right = folder.id; + + db.account().updateAccount(account); + + String name = user.split("@")[0]; + try (Cursor cursor = context.getContentResolver().query( + ContactsContract.Profile.CONTENT_URI, + new String[]{ContactsContract.Profile.DISPLAY_NAME}, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + int colDisplay = cursor.getColumnIndex(ContactsContract.Profile.DISPLAY_NAME); + name = cursor.getString(colDisplay); + } + } catch (Throwable ex) { + Log.e(ex); + } + + // Create identity + EntityIdentity identity = new EntityIdentity(); + identity.name = name; + identity.email = user; + identity.account = account.id; + + identity.host = provider.smtp.host; + identity.starttls = provider.smtp.starttls; + identity.port = provider.smtp.port; + identity.auth_type = MailService.AUTH_TYPE_GMAIL; + identity.user = user; + identity.password = password; + identity.synchronize = true; + identity.primary = true; + + identity.id = db.identity().insertIdentity(identity); + EntityLog.log(context, "Gmail identity=" + identity.name + " email=" + identity.email); + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + ServiceSynchronize.reload(getContext(), "Gmail"); + + return null; + } + + @Override + protected void onExecuted(Bundle args, Void data) { + FragmentQuickSetup.FragmentDialogDone fragment = new FragmentQuickSetup.FragmentDialogDone(); + fragment.show(getFragmentManager(), "gmail:done"); + } + + @Override + protected void onException(Bundle args, Throwable ex) { + Helper.unexpectedError(getFragmentManager(), ex); + } + }.execute(this, args, "setup:gmail"); } public static class FragmentDialogDoze extends FragmentDialogBase { diff --git a/app/src/main/java/eu/faircode/email/MailService.java b/app/src/main/java/eu/faircode/email/MailService.java index 67930b86c2..7a07ef78fc 100644 --- a/app/src/main/java/eu/faircode/email/MailService.java +++ b/app/src/main/java/eu/faircode/email/MailService.java @@ -1,7 +1,11 @@ package eu.faircode.email; +import android.accounts.Account; +import android.accounts.AccountManager; import android.content.Context; +import android.text.TextUtils; +import com.sun.mail.imap.IMAPFolder; import com.sun.mail.imap.IMAPStore; import com.sun.mail.smtp.SMTPTransport; import com.sun.mail.util.MailConnectException; @@ -10,13 +14,17 @@ import java.net.Inet4Address; import java.net.Inet6Address; import java.net.InetAddress; import java.net.UnknownHostException; +import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Properties; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import javax.mail.AuthenticationFailedException; +import javax.mail.Folder; import javax.mail.MessagingException; import javax.mail.NoSuchProviderException; import javax.mail.Service; @@ -33,6 +41,9 @@ public class MailService implements AutoCloseable { private ExecutorService executor = Executors.newCachedThreadPool(Helper.backgroundThreadFactory); + static final int AUTH_TYPE_PASSWORD = 1; + static final int AUTH_TYPE_GMAIL = 2; + private final static int CONNECT_TIMEOUT = 20 * 1000; // milliseconds private final static int WRITE_TIMEOUT = 60 * 1000; // milliseconds private final static int READ_TIMEOUT = 60 * 1000; // milliseconds @@ -127,18 +138,56 @@ public class MailService implements AutoCloseable { } public void connect(EntityAccount account) throws MessagingException { - connect(account.host, account.port, account.user, account.password); + connect(account.host, account.port, account.auth_type, account.user, account.password); } public void connect(EntityIdentity identity) throws MessagingException { - connect(identity.host, identity.port, identity.user, identity.password); + connect(identity.host, identity.port, identity.auth_type, identity.user, identity.password); } - public void connect(String host, int port, String user, String password) throws MessagingException { + public void connect(String host, int port, int auth, String user, String password) throws MessagingException { try { + if (auth == AUTH_TYPE_GMAIL) + properties.put("mail." + protocol + ".auth.mechanisms", "XOAUTH2"); + //if (BuildConfig.DEBUG) // throw new MailConnectException(new SocketConnectException("Debug", new Exception(), host, port, 0)); + _connect(context, host, port, user, password); + } catch (AuthenticationFailedException ex) { + // Refresh token + if (auth == AUTH_TYPE_GMAIL) + try { + String type = "com.google"; + AccountManager am = AccountManager.get(context); + Account[] accounts = am.getAccountsByType(type); + for (Account account : accounts) + if (user.equals(account.name)) { + Log.i("Refreshing token user=" + user); + am.invalidateAuthToken(type, password); + String refreshed = am.blockingGetAuthToken(account, getAuthTokenType(type), true); + if (refreshed == null) + throw new IllegalStateException("no token"); + + int count = 0; + DB db = DB.getInstance(context); + if ("imap".equals(protocol) || "imaps".equals(protocol)) + count = db.account().updateAccountPassword(password, refreshed); + else if ("smtp".equals(protocol) || "smtps".equals(protocol)) + count = db.identity().updateIdentityPassword(password, refreshed); + + if (count != 1) + throw new IllegalStateException(protocol + "=" + count); + + _connect(context, host, port, user, refreshed); + break; + } + } catch (Throwable ex1) { + Log.e(ex1); + throw ex; + } + else + throw ex; } catch (MailConnectException ex) { try { // Some devices resolve IPv6 addresses while not having IPv6 connectivity @@ -213,6 +262,63 @@ public class MailService implements AutoCloseable { throw new NoSuchProviderException(protocol); } + static String getAuthTokenType(String type) { + // https://developers.google.com/gmail/imap/xoauth2-protocol + if ("com.google".equals(type)) + return "oauth2:https://mail.google.com/"; + return null; + } + + List getFolders() throws MessagingException { + List folders = new ArrayList<>(); + List guesses = new ArrayList<>(); + + for (Folder ifolder : getStore().getDefaultFolder().list("*")) { + String fullName = ifolder.getFullName(); + String[] attrs = ((IMAPFolder) ifolder).getAttributes(); + String type = EntityFolder.getType(attrs, fullName, true); + Log.i(fullName + " attrs=" + TextUtils.join(" ", attrs) + " type=" + type); + + if (type != null) { + EntityFolder folder = new EntityFolder(fullName, type); + folders.add(folder); + + if (EntityFolder.USER.equals(type)) { + String guess = EntityFolder.guessType(fullName); + if (guess != null) + guesses.add(folder); + } + } + } + + for (EntityFolder guess : guesses) { + boolean has = false; + String gtype = EntityFolder.guessType(guess.name); + for (EntityFolder folder : folders) + if (folder.type.equals(gtype)) { + has = true; + break; + } + if (!has) { + guess.type = gtype; + Log.i(guess.name + " guessed type=" + gtype); + } + } + + boolean inbox = false; + boolean drafts = false; + for (EntityFolder folder : folders) + if (EntityFolder.INBOX.equals(folder.type)) + inbox = true; + else if (EntityFolder.DRAFTS.equals(folder.type)) + drafts = true; + + if (!inbox || !drafts) + return null; + + return folders; + } + IMAPStore getStore() { return (IMAPStore) iservice; } diff --git a/app/src/main/res/layout/fragment_setup.xml b/app/src/main/res/layout/fragment_setup.xml index 3aba026c05..67d678da4a 100644 --- a/app/src/main/res/layout/fragment_setup.xml +++ b/app/src/main/res/layout/fragment_setup.xml @@ -105,6 +105,17 @@ app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@id/tvQuick" /> +