LoginActivity.java 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  1. /* Copyright 2017 Andrew Dawson
  2. *
  3. * This file is a part of Tusky.
  4. *
  5. * This program is free software; you can redistribute it and/or modify it under the terms of the
  6. * GNU General Public License as published by the Free Software Foundation; either version 3 of the
  7. * License, or (at your option) any later version.
  8. *
  9. * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
  10. * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
  11. * Public License for more details.
  12. *
  13. * You should have received a copy of the GNU General Public License along with Tusky; if not,
  14. * see <http://www.gnu.org/licenses>. */
  15. package com.keylesspalace.tusky;
  16. import android.app.AlertDialog;
  17. import android.content.Context;
  18. import android.content.DialogInterface;
  19. import android.content.Intent;
  20. import android.content.SharedPreferences;
  21. import android.content.pm.PackageManager;
  22. import android.net.Uri;
  23. import android.os.Bundle;
  24. import android.preference.PreferenceManager;
  25. import android.support.v7.app.AppCompatActivity;
  26. import android.text.method.LinkMovementMethod;
  27. import android.view.View;
  28. import android.widget.Button;
  29. import android.widget.EditText;
  30. import android.widget.TextView;
  31. import com.keylesspalace.tusky.entity.AccessToken;
  32. import com.keylesspalace.tusky.entity.AppCredentials;
  33. import java.util.HashMap;
  34. import java.util.Map;
  35. import butterknife.BindView;
  36. import butterknife.ButterKnife;
  37. import retrofit2.Call;
  38. import retrofit2.Callback;
  39. import retrofit2.Response;
  40. import retrofit2.Retrofit;
  41. import retrofit2.converter.gson.GsonConverterFactory;
  42. public class LoginActivity extends AppCompatActivity {
  43. private static final String TAG = "LoginActivity"; // logging tag
  44. private static String OAUTH_SCOPES = "read write follow";
  45. private SharedPreferences preferences;
  46. private String domain;
  47. private String clientId;
  48. private String clientSecret;
  49. @BindView(R.id.edit_text_domain) EditText editText;
  50. @BindView(R.id.button_login) Button button;
  51. @BindView(R.id.whats_an_instance) TextView whatsAnInstance;
  52. /**
  53. * Chain together the key-value pairs into a query string, for either appending to a URL or
  54. * as the content of an HTTP request.
  55. */
  56. private static String toQueryString(Map<String, String> parameters) {
  57. StringBuilder s = new StringBuilder();
  58. String between = "";
  59. for (Map.Entry<String, String> entry : parameters.entrySet()) {
  60. s.append(between);
  61. s.append(Uri.encode(entry.getKey()));
  62. s.append("=");
  63. s.append(Uri.encode(entry.getValue()));
  64. between = "&";
  65. }
  66. return s.toString();
  67. }
  68. /** Make sure the user-entered text is just a fully-qualified domain name. */
  69. private static String validateDomain(String s) {
  70. // Strip any schemes out.
  71. s = s.replaceFirst("http://", "");
  72. s = s.replaceFirst("https://", "");
  73. // If a username was included (e.g. username@example.com), just take what's after the '@'.
  74. int at = s.indexOf('@');
  75. if (at != -1) {
  76. s = s.substring(at + 1);
  77. }
  78. return s.trim();
  79. }
  80. private String getOauthRedirectUri() {
  81. String scheme = getString(R.string.oauth_scheme);
  82. String host = getString(R.string.oauth_redirect_host);
  83. return scheme + "://" + host + "/";
  84. }
  85. private void redirectUserToAuthorizeAndLogin(EditText editText) {
  86. /* To authorize this app and log in it's necessary to redirect to the domain given,
  87. * activity_login there, and the server will redirect back to the app with its response. */
  88. String endpoint = MastodonAPI.ENDPOINT_AUTHORIZE;
  89. String redirectUri = getOauthRedirectUri();
  90. Map<String, String> parameters = new HashMap<>();
  91. parameters.put("client_id", clientId);
  92. parameters.put("redirect_uri", redirectUri);
  93. parameters.put("response_type", "code");
  94. parameters.put("scope", OAUTH_SCOPES);
  95. String url = "https://" + domain + endpoint + "?" + toQueryString(parameters);
  96. Intent viewIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
  97. if (viewIntent.resolveActivity(getPackageManager()) != null) {
  98. startActivity(viewIntent);
  99. } else {
  100. editText.setError(getString(R.string.error_no_web_browser_found));
  101. }
  102. }
  103. private MastodonAPI getApiFor(String domain) {
  104. Retrofit retrofit = new Retrofit.Builder()
  105. .baseUrl("https://" + domain)
  106. .addConverterFactory(GsonConverterFactory.create())
  107. .build();
  108. return retrofit.create(MastodonAPI.class);
  109. }
  110. /**
  111. * Obtain the oauth client credentials for this app. This is only necessary the first time the
  112. * app is run on a given server instance. So, after the first authentication, they are
  113. * saved in SharedPreferences and every subsequent run they are simply fetched from there.
  114. */
  115. private void onButtonClick(final EditText editText) {
  116. domain = validateDomain(editText.getText().toString());
  117. /* Attempt to get client credentials from SharedPreferences, and if not present
  118. * (such as in the case that the domain has never been accessed before)
  119. * authenticate with the server and store the received credentials to use next
  120. * time. */
  121. String prefClientId = preferences.getString(domain + "/client_id", null);
  122. String prefClientSecret = preferences.getString(domain + "/client_secret", null);
  123. if (prefClientId != null && prefClientSecret != null) {
  124. clientId = prefClientId;
  125. clientSecret = prefClientSecret;
  126. redirectUserToAuthorizeAndLogin(editText);
  127. } else {
  128. Callback<AppCredentials> callback = new Callback<AppCredentials>() {
  129. @Override
  130. public void onResponse(Call<AppCredentials> call, Response<AppCredentials> response) {
  131. if (!response.isSuccessful()) {
  132. editText.setError(getString(R.string.error_failed_app_registration));
  133. Log.e(TAG, "App authentication failed. " + response.message());
  134. return;
  135. }
  136. AppCredentials credentials = response.body();
  137. clientId = credentials.clientId;
  138. clientSecret = credentials.clientSecret;
  139. SharedPreferences.Editor editor = preferences.edit();
  140. editor.putString(domain + "/client_id", clientId);
  141. editor.putString(domain + "/client_secret", clientSecret);
  142. editor.apply();
  143. redirectUserToAuthorizeAndLogin(editText);
  144. }
  145. @Override
  146. public void onFailure(Call<AppCredentials> call, Throwable t) {
  147. editText.setError(getString(R.string.error_failed_app_registration));
  148. t.printStackTrace();
  149. }
  150. };
  151. try {
  152. getApiFor(domain).authenticateApp(getString(R.string.app_name), getOauthRedirectUri(), OAUTH_SCOPES,
  153. getString(R.string.app_website)).enqueue(callback);
  154. } catch (IllegalArgumentException e) {
  155. editText.setError(getString(R.string.error_invalid_domain));
  156. }
  157. }
  158. }
  159. @Override
  160. protected void onCreate(Bundle savedInstanceState) {
  161. super.onCreate(savedInstanceState);
  162. if (PreferenceManager.getDefaultSharedPreferences(this).getBoolean("lightTheme", false)) {
  163. setTheme(R.style.AppTheme_Light);
  164. }
  165. setContentView(R.layout.activity_login);
  166. ButterKnife.bind(this);
  167. if (savedInstanceState != null) {
  168. domain = savedInstanceState.getString("domain");
  169. clientId = savedInstanceState.getString("clientId");
  170. clientSecret = savedInstanceState.getString("clientSecret");
  171. } else {
  172. domain = null;
  173. clientId = null;
  174. clientSecret = null;
  175. }
  176. preferences = getSharedPreferences(
  177. getString(R.string.preferences_file_key), Context.MODE_PRIVATE);
  178. button.setOnClickListener(new View.OnClickListener() {
  179. @Override
  180. public void onClick(View v) {
  181. onButtonClick(editText);
  182. }
  183. });
  184. final Context context = this;
  185. whatsAnInstance.setOnClickListener(new View.OnClickListener() {
  186. @Override
  187. public void onClick(View v) {
  188. AlertDialog dialog = new AlertDialog.Builder(context)
  189. .setMessage(R.string.dialog_whats_an_instance)
  190. .setPositiveButton(R.string.action_close,
  191. new DialogInterface.OnClickListener() {
  192. @Override
  193. public void onClick(DialogInterface dialog, int which) {
  194. dialog.dismiss();
  195. }
  196. })
  197. .show();
  198. TextView textView = (TextView) dialog.findViewById(android.R.id.message);
  199. textView.setMovementMethod(LinkMovementMethod.getInstance());
  200. }
  201. });
  202. // Apply any updates needed.
  203. int versionCode = 1;
  204. try {
  205. versionCode = getPackageManager().getPackageInfo(getPackageName(), 0).versionCode;
  206. } catch (PackageManager.NameNotFoundException e) {
  207. Log.e(TAG, "The app version was not found. " + e.getMessage());
  208. }
  209. if (preferences.getInt("lastUpdate", 0) != versionCode) {
  210. SharedPreferences.Editor editor = preferences.edit();
  211. if (versionCode == 14) {
  212. /* This version switches the order of scheme and host in the OAuth redirect URI.
  213. * But to fix it requires forcing the app to re-authenticate with servers. So, clear
  214. * out the stored client id/secret pairs. The only other things that are lost are
  215. * "rememberedVisibility", "loggedInUsername", and "loggedInAccountId". */
  216. editor.clear();
  217. }
  218. editor.putInt("lastUpdate", versionCode);
  219. editor.apply();
  220. }
  221. }
  222. @Override
  223. protected void onSaveInstanceState(Bundle outState) {
  224. outState.putString("domain", domain);
  225. outState.putString("clientId", clientId);
  226. outState.putString("clientSecret", clientSecret);
  227. super.onSaveInstanceState(outState);
  228. }
  229. private void onLoginSuccess(String accessToken) {
  230. SharedPreferences.Editor editor = preferences.edit();
  231. editor.putString("domain", domain);
  232. editor.putString("accessToken", accessToken);
  233. editor.commit();
  234. Intent intent = new Intent(this, MainActivity.class);
  235. startActivity(intent);
  236. finish();
  237. }
  238. @Override
  239. protected void onStop() {
  240. super.onStop();
  241. if (domain != null) {
  242. SharedPreferences.Editor editor = preferences.edit();
  243. editor.putString("domain", domain);
  244. editor.putString("clientId", clientId);
  245. editor.putString("clientSecret", clientSecret);
  246. editor.apply();
  247. }
  248. }
  249. @Override
  250. protected void onStart() {
  251. super.onStart();
  252. /* Check if we are resuming during authorization by seeing if the intent contains the
  253. * redirect that was given to the server. If so, its response is here! */
  254. Uri uri = getIntent().getData();
  255. String redirectUri = getOauthRedirectUri();
  256. preferences = getSharedPreferences(
  257. getString(R.string.preferences_file_key), Context.MODE_PRIVATE);
  258. if (preferences.getString("accessToken", null) != null
  259. && preferences.getString("domain", null) != null) {
  260. // We are already logged in, go to MainActivity
  261. Intent intent = new Intent(this, MainActivity.class);
  262. startActivity(intent);
  263. finish();
  264. return;
  265. }
  266. if (uri != null && uri.toString().startsWith(redirectUri)) {
  267. // This should either have returned an authorization code or an error.
  268. String code = uri.getQueryParameter("code");
  269. String error = uri.getQueryParameter("error");
  270. if (code != null) {
  271. /* During the redirect roundtrip this Activity usually dies, which wipes out the
  272. * instance variables, so they have to be recovered from where they were saved in
  273. * SharedPreferences. */
  274. domain = preferences.getString("domain", null);
  275. clientId = preferences.getString("clientId", null);
  276. clientSecret = preferences.getString("clientSecret", null);
  277. /* Since authorization has succeeded, the final step to log in is to exchange
  278. * the authorization code for an access token. */
  279. Callback<AccessToken> callback = new Callback<AccessToken>() {
  280. @Override
  281. public void onResponse(Call<AccessToken> call, Response<AccessToken> response) {
  282. if (response.isSuccessful()) {
  283. onLoginSuccess(response.body().accessToken);
  284. } else {
  285. editText.setError(getString(R.string.error_retrieving_oauth_token));
  286. Log.e(TAG, String.format("%s %s",
  287. getString(R.string.error_retrieving_oauth_token),
  288. response.message()));
  289. }
  290. }
  291. @Override
  292. public void onFailure(Call<AccessToken> call, Throwable t) {
  293. editText.setError(getString(R.string.error_retrieving_oauth_token));
  294. Log.e(TAG, String.format("%s %s",
  295. getString(R.string.error_retrieving_oauth_token),
  296. t.getMessage()));
  297. }
  298. };
  299. getApiFor(domain).fetchOAuthToken(clientId, clientSecret, redirectUri, code,
  300. "authorization_code").enqueue(callback);
  301. } else if (error != null) {
  302. /* Authorization failed. Put the error response where the user can read it and they
  303. * can try again. */
  304. editText.setError(getString(R.string.error_authorization_denied));
  305. Log.e(TAG, getString(R.string.error_authorization_denied) + error);
  306. } else {
  307. // This case means a junk response was received somehow.
  308. editText.setError(getString(R.string.error_authorization_unknown));
  309. }
  310. }
  311. }
  312. }