diff --git a/app/src/main/java/eu/faircode/email/ActivitySetup.java b/app/src/main/java/eu/faircode/email/ActivitySetup.java index f08adfd22d..7a12de137a 100644 --- a/app/src/main/java/eu/faircode/email/ActivitySetup.java +++ b/app/src/main/java/eu/faircode/email/ActivitySetup.java @@ -92,6 +92,8 @@ public class ActivitySetup extends ActivityBase implements FragmentManager.OnBac static final int REQUEST_CHANGE_PASSWORD = 10; static final int REQUEST_DELETE_ACCOUNT = 11; static final int REQUEST_IMPORT_PROVIDERS = 12; + static final int REQUEST_GRAPH_CONTACTS = 13; + static final int REQUEST_GRAPH_CONTACTS_OAUTH = 14; static final int PI_CONNECTION = 1; static final int PI_MISC = 2; diff --git a/app/src/main/java/eu/faircode/email/FragmentDialogSelectAccount.java b/app/src/main/java/eu/faircode/email/FragmentDialogSelectAccount.java index d43f940a40..931657a3a0 100644 --- a/app/src/main/java/eu/faircode/email/FragmentDialogSelectAccount.java +++ b/app/src/main/java/eu/faircode/email/FragmentDialogSelectAccount.java @@ -35,6 +35,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; +import java.util.ArrayList; import java.util.List; public class FragmentDialogSelectAccount extends FragmentDialogBase { @@ -81,6 +82,10 @@ public class FragmentDialogSelectAccount extends FragmentDialogBase { @Override protected void onExecuted(Bundle args, List accounts) { + if ("outlook".equals(args.getString("filter"))) + for (EntityAccount account : new ArrayList<>(accounts)) + if (!account.isOutlook()) + accounts.remove(account); adapter.addAll(accounts); } @@ -101,6 +106,7 @@ public class FragmentDialogSelectAccount extends FragmentDialogBase { args.putLong("account", account.id); args.putInt("protocol", account.protocol); args.putString("name", account.name); + args.putString("user", account.user); sendResult(RESULT_OK); } }) diff --git a/app/src/main/java/eu/faircode/email/FragmentSetup.java b/app/src/main/java/eu/faircode/email/FragmentSetup.java index a613e3b10f..e9a48d0098 100644 --- a/app/src/main/java/eu/faircode/email/FragmentSetup.java +++ b/app/src/main/java/eu/faircode/email/FragmentSetup.java @@ -54,6 +54,7 @@ import android.widget.CompoundButton; import android.widget.ImageButton; import android.widget.ScrollView; import android.widget.TextView; +import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -70,7 +71,31 @@ import androidx.lifecycle.Observer; import androidx.localbroadcastmanager.content.LocalBroadcastManager; import androidx.preference.PreferenceManager; +import net.openid.appauth.AppAuthConfiguration; +import net.openid.appauth.AuthState; +import net.openid.appauth.AuthorizationException; +import net.openid.appauth.AuthorizationRequest; +import net.openid.appauth.AuthorizationResponse; +import net.openid.appauth.AuthorizationService; +import net.openid.appauth.AuthorizationServiceConfiguration; +import net.openid.appauth.ClientAuthentication; +import net.openid.appauth.ClientSecretPost; +import net.openid.appauth.GrantTypeValues; +import net.openid.appauth.NoClientAuthentication; +import net.openid.appauth.ResponseTypeValues; +import net.openid.appauth.TokenRequest; +import net.openid.appauth.TokenResponse; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; import java.util.List; public class FragmentSetup extends FragmentBase implements SharedPreferences.OnSharedPreferenceChangeListener { @@ -107,6 +132,7 @@ public class FragmentSetup extends FragmentBase implements SharedPreferences.OnS private Button btnPermissions; private TextView tvPermissionsWhy; private TextView tvImportContacts; + private Button btnGraphContacts; private TextView tvDozeDone; private Button btnDoze; @@ -146,6 +172,8 @@ public class FragmentSetup extends FragmentBase implements SharedPreferences.OnS private boolean manual = false; + private static final String GRAPH_SCOPE_READ_CONTACTS = "https://graph.microsoft.com/Contacts.Read"; + @Override @Nullable public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { @@ -195,6 +223,7 @@ public class FragmentSetup extends FragmentBase implements SharedPreferences.OnS btnPermissions = view.findViewById(R.id.btnPermissions); tvPermissionsWhy = view.findViewById(R.id.tvPermissionsWhy); tvImportContacts = view.findViewById(R.id.tvImportContacts); + btnGraphContacts = view.findViewById(R.id.btnGraphContacts); tvDozeDone = view.findViewById(R.id.tvDozeDone); btnDoze = view.findViewById(R.id.btnDoze); @@ -564,6 +593,20 @@ public class FragmentSetup extends FragmentBase implements SharedPreferences.OnS } }); + btnGraphContacts.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + Bundle args = new Bundle(); + args.putInt("type", EntityAccount.TYPE_IMAP); + args.putString("filter", "outlook"); + + FragmentDialogSelectAccount fragment = new FragmentDialogSelectAccount(); + fragment.setArguments(args); + fragment.setTargetFragment(FragmentSetup.this, ActivitySetup.REQUEST_GRAPH_CONTACTS); + fragment.show(getParentFragmentManager(), "account:contacts"); + } + }); + btnDoze.setOnClickListener(new View.OnClickListener() { @Override @SuppressLint("BatteryLife") @@ -1073,6 +1116,14 @@ public class FragmentSetup extends FragmentBase implements SharedPreferences.OnS if (resultCode == RESULT_OK && data != null) onDeleteAccount(data.getBundleExtra("args")); break; + case ActivitySetup.REQUEST_GRAPH_CONTACTS: + if (resultCode == RESULT_OK && data != null) + handleImportGraphContacts(data.getBundleExtra("args")); + break; + case ActivitySetup.REQUEST_GRAPH_CONTACTS_OAUTH: + if (resultCode == RESULT_OK && data != null) + onHandleGraphContactsOAuth(data); + break; } } catch (Throwable ex) { Log.e(ex); @@ -1213,6 +1264,132 @@ public class FragmentSetup extends FragmentBase implements SharedPreferences.OnS .show(); } + private void handleImportGraphContacts(Bundle args) { + try { + final Context context = getContext(); + long account = args.getLong("account"); + String user = args.getString("user"); + EmailProvider provider = EmailProvider.getProvider(context, "outlookgraph"); + + AppAuthConfiguration appAuthConfig = new AppAuthConfiguration.Builder() + .build(); + AuthorizationService authService = new AuthorizationService(context, appAuthConfig); + + AuthorizationServiceConfiguration serviceConfig = new AuthorizationServiceConfiguration( + Uri.parse(provider.graph.authorizationEndpoint), + Uri.parse(provider.graph.tokenEndpoint)); + + AuthorizationRequest.Builder authRequestBuilder = + new AuthorizationRequest.Builder( + serviceConfig, + provider.graph.clientId, + ResponseTypeValues.CODE, + Uri.parse(provider.graph.redirectUri)) + .setScopes(GRAPH_SCOPE_READ_CONTACTS) + .setState(provider.id + ":" + account) + .setLoginHint(user); + + if (!TextUtils.isEmpty(provider.graph.prompt)) + authRequestBuilder.setPrompt(provider.graph.prompt); + + Intent authIntent = authService.getAuthorizationRequestIntent(authRequestBuilder.build()); + Log.i("Graph/contacts intent=" + authIntent); + startActivityForResult(authIntent, ActivitySetup.REQUEST_GRAPH_CONTACTS_OAUTH); + } catch (Throwable ex) { + Log.unexpectedError(getParentFragmentManager(), ex); + } + } + + private void onHandleGraphContactsOAuth(@NonNull Intent data) { + try { + Log.i("Graph/contacts authorized"); + + AuthorizationResponse auth = AuthorizationResponse.fromIntent(data); + if (auth == null) { + AuthorizationException ex = AuthorizationException.fromIntent(data); + if (ex == null) + throw new IllegalArgumentException("No response data"); + else + throw ex; + } + + final Context context = getContext(); + final EmailProvider provider = EmailProvider.getProvider(context, "outlookgraph"); + + AuthorizationService authService = new AuthorizationService(context); + + ClientAuthentication clientAuth; + if (provider.graph.clientSecret == null) + clientAuth = NoClientAuthentication.INSTANCE; + else + clientAuth = new ClientSecretPost(provider.graph.clientSecret); + + TokenRequest.Builder builder = new TokenRequest.Builder( + auth.request.configuration, + auth.request.clientId) + .setGrantType(GrantTypeValues.AUTHORIZATION_CODE) + .setRedirectUri(auth.request.redirectUri) + .setCodeVerifier(auth.request.codeVerifier) + .setAuthorizationCode(auth.authorizationCode) + .setNonce(auth.request.nonce); + + if (provider.graph.tokenScopes) + builder.setScope(GRAPH_SCOPE_READ_CONTACTS); + + authService.performTokenRequest( + builder.build(), + clientAuth, + new AuthorizationService.TokenResponseCallback() { + @Override + public void onTokenRequestCompleted(TokenResponse access, AuthorizationException error) { + try { + if (error != null) + throw error; + + if (access == null || access.accessToken == null) + throw new IllegalStateException("No access token"); + + Log.i("Graph/contacts got token"); + + int semi = auth.request.state.lastIndexOf(':'); + long account = Long.parseLong(auth.request.state.substring(semi + 1)); + + Bundle args = new Bundle(); + args.putLong("account", account); + args.putString("accessToken", access.accessToken); + + new SimpleTask() { + @Override + protected Void onExecute(Context context, Bundle args) throws Throwable { + long account = args.getLong("account"); + String accessToken = args.getString("accessToken"); + + MicrosoftGraph.downloadContacts(context, account, accessToken); + return null; + } + + @Override + protected void onExecuted(Bundle args, Void data) { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + prefs.edit().putBoolean("suggest_sent", true).apply(); + ToastEx.makeText(context, R.string.title_completed, Toast.LENGTH_LONG).show(); + } + + @Override + protected void onException(Bundle args, Throwable ex) { + Log.unexpectedError(getParentFragmentManager(), ex); + } + }.execute(FragmentSetup.this, args, "graph:contacts"); + } catch (Throwable ex) { + Log.unexpectedError(getParentFragmentManager(), ex); + } + } + }); + } catch (Throwable ex) { + Log.unexpectedError(getParentFragmentManager(), ex); + } + } + private ConnectivityManager.NetworkCallback networkCallback = new ConnectivityManager.NetworkCallback() { @Override public void onAvailable(Network network) { diff --git a/app/src/main/java/eu/faircode/email/MicrosoftGraph.java b/app/src/main/java/eu/faircode/email/MicrosoftGraph.java index 93c5e56dc8..b0d3631960 100644 --- a/app/src/main/java/eu/faircode/email/MicrosoftGraph.java +++ b/app/src/main/java/eu/faircode/email/MicrosoftGraph.java @@ -21,6 +21,7 @@ package eu.faircode.email; import android.content.Context; import android.content.SharedPreferences; +import android.text.TextUtils; import android.util.Base64; import android.util.Base64OutputStream; @@ -28,7 +29,9 @@ import androidx.preference.PreferenceManager; import net.openid.appauth.AuthState; +import org.json.JSONArray; import org.json.JSONException; +import org.json.JSONObject; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -120,4 +123,69 @@ public class MicrosoftGraph { db.identity().setIdentityState(ident.id, null); } } + + static void downloadContacts(Context context, long account, String accessToken) throws IOException, JSONException { + DB db = DB.getInstance(context); + + // https://learn.microsoft.com/en-us/graph/api/user-list-contacts?view=graph-rest-1.0&tabs=http + URL url = new URL(MicrosoftGraph.GRAPH_ENDPOINT + "contacts"); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + connection.setReadTimeout(GRAPH_TIMEOUT * 1000); + connection.setConnectTimeout(GRAPH_TIMEOUT * 1000); + ConnectionHelper.setUserAgent(context, connection); + connection.setRequestProperty("Authorization", "Bearer " + accessToken); + connection.setRequestProperty("Content-Type", "text/plain"); + connection.connect(); + + try { + int status = connection.getResponseCode(); + if (status == HttpURLConnection.HTTP_OK) { + String response = Helper.readStream(connection.getInputStream()); + JSONObject jroot = new JSONObject(response); + JSONArray jvalue = jroot.getJSONArray("value"); + for (int i = 0; i < jvalue.length(); i++) { + JSONObject jcontact = jvalue.getJSONObject(i); + String displayName = jcontact.optString("displayName"); + if (TextUtils.isEmpty(displayName)) + displayName = null; + if (jcontact.has("emailAddresses")) { + JSONArray jemailAddresses = jcontact.getJSONArray("emailAddresses"); + for (int j = 0; j < jemailAddresses.length(); j++) { + JSONObject jemail = jemailAddresses.getJSONObject(j); + String email = jemail.optString("address"); + if (!TextUtils.isEmpty(email)) { + EntityContact contact = db.contact().getContact(account, EntityContact.TYPE_TO, email); + EntityLog.log(context, displayName + " <" + email + ">" + + " account=" + account + " exists=" + (contact != null)); + if (contact == null) { + contact = new EntityContact(); + contact.account = account; + contact.type = EntityContact.TYPE_TO; + contact.email = email; + contact.name = displayName; + contact.times_contacted = 0; + contact.first_contacted = new Date().getTime(); + contact.last_contacted = contact.first_contacted; + db.contact().insertContact(contact); + } + } + } + } + } + } else { + String error = "Error " + status + ": " + connection.getResponseMessage(); + try { + InputStream is = connection.getErrorStream(); + if (is != null) + error += "\n" + Helper.readStream(is); + } catch (Throwable ex) { + Log.w(ex); + } + throw new IOException(error); + } + } finally { + connection.disconnect(); + } + } } diff --git a/app/src/main/res/layout/fragment_setup.xml b/app/src/main/res/layout/fragment_setup.xml index cd1ec30fa5..a2410cdd9e 100644 --- a/app/src/main/res/layout/fragment_setup.xml +++ b/app/src/main/res/layout/fragment_setup.xml @@ -676,6 +676,18 @@ app:drawableTint="?android:attr/textColorLink" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/tvPermissionsWhy" /> + +