mirror of https://github.com/tuskyapp/Tusky.git
Browse Source
* basic implementation * improve LoginActivity * darken drawer background image * add current avatar in ComposeActivity * add account name to logout dialog * multi account support for notifications * multi account support for notifications * bugfixes & cleanup * fix bug where somethings notifications would open with the wrong user * correctly set active account in SFragment * small improvementspull/513/head
40 changed files with 1292 additions and 772 deletions
@ -1,406 +0,0 @@
|
||||
/* Copyright 2017 Andrew Dawson |
||||
* |
||||
* This file is a part of Tusky. |
||||
* |
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the |
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the |
||||
* License, or (at your option) any later version. |
||||
* |
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even |
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General |
||||
* Public License for more details. |
||||
* |
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not, |
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky; |
||||
|
||||
import android.app.AlertDialog; |
||||
import android.content.ActivityNotFoundException; |
||||
import android.content.Context; |
||||
import android.content.DialogInterface; |
||||
import android.content.Intent; |
||||
import android.content.SharedPreferences; |
||||
import android.net.Uri; |
||||
import android.os.Bundle; |
||||
import android.preference.PreferenceManager; |
||||
import android.support.annotation.NonNull; |
||||
import android.support.customtabs.CustomTabsIntent; |
||||
import android.support.v7.app.AppCompatActivity; |
||||
import android.text.method.LinkMovementMethod; |
||||
import android.util.Log; |
||||
import android.view.View; |
||||
import android.widget.Button; |
||||
import android.widget.EditText; |
||||
import android.widget.LinearLayout; |
||||
import android.widget.TextView; |
||||
|
||||
import com.keylesspalace.tusky.entity.AccessToken; |
||||
import com.keylesspalace.tusky.entity.AppCredentials; |
||||
import com.keylesspalace.tusky.network.MastodonApi; |
||||
import com.keylesspalace.tusky.util.CustomTabsHelper; |
||||
import com.keylesspalace.tusky.util.NotificationManager; |
||||
import com.keylesspalace.tusky.util.OkHttpUtils; |
||||
import com.keylesspalace.tusky.util.ResourcesUtils; |
||||
import com.keylesspalace.tusky.util.ThemeUtils; |
||||
|
||||
import java.util.HashMap; |
||||
import java.util.Map; |
||||
|
||||
import retrofit2.Call; |
||||
import retrofit2.Callback; |
||||
import retrofit2.Response; |
||||
import retrofit2.Retrofit; |
||||
import retrofit2.converter.gson.GsonConverterFactory; |
||||
|
||||
public class LoginActivity extends AppCompatActivity { |
||||
private static final String TAG = "LoginActivity"; // logging tag
|
||||
private static String OAUTH_SCOPES = "read write follow"; |
||||
|
||||
private LinearLayout input; |
||||
private LinearLayout loading; |
||||
private EditText editText; |
||||
private SharedPreferences preferences; |
||||
private String domain; |
||||
private String clientId; |
||||
private String clientSecret; |
||||
|
||||
@Override |
||||
protected void onCreate(Bundle savedInstanceState) { |
||||
super.onCreate(savedInstanceState); |
||||
|
||||
preferences = PreferenceManager.getDefaultSharedPreferences(this); |
||||
String[] themeFlavorPair = preferences.getString("appTheme", TuskyApplication.APP_THEME_DEFAULT).split(":"); |
||||
String appTheme = themeFlavorPair[0], themeFlavorPreference = themeFlavorPair[2]; |
||||
|
||||
setTheme(ResourcesUtils.getResourceIdentifier(this, "style", appTheme)); |
||||
|
||||
String flavor = preferences.getString("appThemeFlavor", ThemeUtils.THEME_FLAVOR_DEFAULT); |
||||
if (flavor.equals(ThemeUtils.THEME_FLAVOR_DEFAULT)) |
||||
flavor = themeFlavorPreference; |
||||
ThemeUtils.setAppNightMode(flavor); |
||||
|
||||
setContentView(R.layout.activity_login); |
||||
|
||||
input = findViewById(R.id.login_input); |
||||
loading = findViewById(R.id.login_loading); |
||||
editText = findViewById(R.id.edit_text_domain); |
||||
Button button = findViewById(R.id.button_login); |
||||
TextView whatsAnInstance = findViewById(R.id.whats_an_instance); |
||||
|
||||
if (savedInstanceState != null) { |
||||
domain = savedInstanceState.getString("domain"); |
||||
clientId = savedInstanceState.getString("clientId"); |
||||
clientSecret = savedInstanceState.getString("clientSecret"); |
||||
} else { |
||||
domain = null; |
||||
clientId = null; |
||||
clientSecret = null; |
||||
} |
||||
|
||||
preferences = getSharedPreferences( |
||||
getString(R.string.preferences_file_key), Context.MODE_PRIVATE); |
||||
|
||||
button.setOnClickListener(new View.OnClickListener() { |
||||
@Override |
||||
public void onClick(View v) { |
||||
onButtonClick(editText); |
||||
} |
||||
}); |
||||
|
||||
final Context context = this; |
||||
|
||||
whatsAnInstance.setOnClickListener(new View.OnClickListener() { |
||||
@Override |
||||
public void onClick(View v) { |
||||
AlertDialog dialog = new AlertDialog.Builder(context) |
||||
.setMessage(R.string.dialog_whats_an_instance) |
||||
.setPositiveButton(R.string.action_close, |
||||
new DialogInterface.OnClickListener() { |
||||
@Override |
||||
public void onClick(DialogInterface dialog, int which) { |
||||
dialog.dismiss(); |
||||
} |
||||
}) |
||||
.show(); |
||||
TextView textView = dialog.findViewById(android.R.id.message); |
||||
textView.setMovementMethod(LinkMovementMethod.getInstance()); |
||||
} |
||||
}); |
||||
} |
||||
|
||||
@Override |
||||
protected void onSaveInstanceState(Bundle outState) { |
||||
outState.putString("domain", domain); |
||||
outState.putString("clientId", clientId); |
||||
outState.putString("clientSecret", clientSecret); |
||||
super.onSaveInstanceState(outState); |
||||
} |
||||
|
||||
/** Make sure the user-entered text is just a fully-qualified domain name. */ |
||||
@NonNull |
||||
private static String validateDomain(String s) { |
||||
// Strip any schemes out.
|
||||
s = s.replaceFirst("http://", ""); |
||||
s = s.replaceFirst("https://", ""); |
||||
// If a username was included (e.g. username@example.com), just take what's after the '@'.
|
||||
int at = s.lastIndexOf('@'); |
||||
if (at != -1) { |
||||
s = s.substring(at + 1); |
||||
} |
||||
return s.trim(); |
||||
} |
||||
|
||||
private String getOauthRedirectUri() { |
||||
String scheme = getString(R.string.oauth_scheme); |
||||
String host = BuildConfig.APPLICATION_ID; |
||||
return scheme + "://" + host + "/"; |
||||
} |
||||
|
||||
private MastodonApi getApiFor(String domain) { |
||||
Retrofit retrofit = new Retrofit.Builder() |
||||
.baseUrl("https://" + domain) |
||||
.client(OkHttpUtils.getCompatibleClient(preferences)) |
||||
.addConverterFactory(GsonConverterFactory.create()) |
||||
.build(); |
||||
|
||||
return retrofit.create(MastodonApi.class); |
||||
} |
||||
|
||||
/** |
||||
* Obtain the oauth client credentials for this app. This is only necessary the first time the |
||||
* app is run on a given server instance. So, after the first authentication, they are |
||||
* saved in SharedPreferences and every subsequent run they are simply fetched from there. |
||||
*/ |
||||
private void onButtonClick(final EditText editText) { |
||||
domain = validateDomain(editText.getText().toString()); |
||||
/* Attempt to get client credentials from SharedPreferences, and if not present |
||||
* (such as in the case that the domain has never been accessed before) |
||||
* authenticate with the server and store the received credentials to use next |
||||
* time. */ |
||||
String prefClientId = preferences.getString(domain + "/client_id", null); |
||||
String prefClientSecret = preferences.getString(domain + "/client_secret", null); |
||||
|
||||
if (prefClientId != null && prefClientSecret != null) { |
||||
clientId = prefClientId; |
||||
clientSecret = prefClientSecret; |
||||
redirectUserToAuthorizeAndLogin(editText); |
||||
} else { |
||||
Callback<AppCredentials> callback = new Callback<AppCredentials>() { |
||||
@Override |
||||
public void onResponse(@NonNull Call<AppCredentials> call, |
||||
@NonNull Response<AppCredentials> response) { |
||||
if (!response.isSuccessful()) { |
||||
editText.setError(getString(R.string.error_failed_app_registration)); |
||||
Log.e(TAG, "App authentication failed. " + response.message()); |
||||
return; |
||||
} |
||||
AppCredentials credentials = response.body(); |
||||
clientId = credentials.clientId; |
||||
clientSecret = credentials.clientSecret; |
||||
preferences.edit() |
||||
.putString(domain + "/client_id", clientId) |
||||
.putString(domain + "/client_secret", clientSecret) |
||||
.apply(); |
||||
redirectUserToAuthorizeAndLogin(editText); |
||||
} |
||||
|
||||
@Override |
||||
public void onFailure(@NonNull Call<AppCredentials> call, @NonNull Throwable t) { |
||||
editText.setError(getString(R.string.error_failed_app_registration)); |
||||
Log.e(TAG, Log.getStackTraceString(t)); |
||||
} |
||||
}; |
||||
|
||||
try { |
||||
getApiFor(domain) |
||||
.authenticateApp(getString(R.string.app_name), getOauthRedirectUri(), |
||||
OAUTH_SCOPES, getString(R.string.app_website)) |
||||
.enqueue(callback); |
||||
} catch (IllegalArgumentException e) { |
||||
editText.setError(getString(R.string.error_invalid_domain)); |
||||
} |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Chain together the key-value pairs into a query string, for either appending to a URL or |
||||
* as the content of an HTTP request. |
||||
*/ |
||||
@NonNull |
||||
private static String toQueryString(Map<String, String> parameters) { |
||||
StringBuilder s = new StringBuilder(); |
||||
String between = ""; |
||||
for (Map.Entry<String, String> entry : parameters.entrySet()) { |
||||
s.append(between); |
||||
s.append(Uri.encode(entry.getKey())); |
||||
s.append("="); |
||||
s.append(Uri.encode(entry.getValue())); |
||||
between = "&"; |
||||
} |
||||
return s.toString(); |
||||
} |
||||
|
||||
private static boolean openInCustomTab(Uri uri, Context context) { |
||||
int toolbarColor = ThemeUtils.getColorById(context, "custom_tab_toolbar"); |
||||
|
||||
CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder(); |
||||
builder.setToolbarColor(toolbarColor); |
||||
CustomTabsIntent customTabsIntent = builder.build(); |
||||
try { |
||||
String packageName = CustomTabsHelper.getPackageNameToUse(context); |
||||
/* If we cant find a package name, it means theres no browser that supports |
||||
* Chrome Custom Tabs installed. So, we fallback to the webview */ |
||||
if (packageName == null) { |
||||
return false; |
||||
} else { |
||||
customTabsIntent.intent.setPackage(packageName); |
||||
customTabsIntent.launchUrl(context, uri); |
||||
} |
||||
} catch (ActivityNotFoundException e) { |
||||
Log.w("URLSpan", "Activity was not found for intent, " + customTabsIntent.toString()); |
||||
return false; |
||||
} |
||||
return true; |
||||
} |
||||
|
||||
private void redirectUserToAuthorizeAndLogin(EditText editText) { |
||||
/* To authorize this app and log in it's necessary to redirect to the domain given, |
||||
* activity_login there, and the server will redirect back to the app with its response. */ |
||||
String endpoint = MastodonApi.ENDPOINT_AUTHORIZE; |
||||
String redirectUri = getOauthRedirectUri(); |
||||
Map<String, String> parameters = new HashMap<>(); |
||||
parameters.put("client_id", clientId); |
||||
parameters.put("redirect_uri", redirectUri); |
||||
parameters.put("response_type", "code"); |
||||
parameters.put("scope", OAUTH_SCOPES); |
||||
String url = "https://" + domain + endpoint + "?" + toQueryString(parameters); |
||||
Uri uri = Uri.parse(url); |
||||
if (!openInCustomTab(uri, this)) { |
||||
Intent viewIntent = new Intent(Intent.ACTION_VIEW, uri); |
||||
if (viewIntent.resolveActivity(getPackageManager()) != null) { |
||||
startActivity(viewIntent); |
||||
} else { |
||||
editText.setError(getString(R.string.error_no_web_browser_found)); |
||||
} |
||||
} |
||||
} |
||||
|
||||
@Override |
||||
protected void onStop() { |
||||
super.onStop(); |
||||
if (domain != null) { |
||||
preferences.edit() |
||||
.putString("domain", domain) |
||||
.putString("clientId", clientId) |
||||
.putString("clientSecret", clientSecret) |
||||
.apply(); |
||||
} |
||||
} |
||||
|
||||
@Override |
||||
protected void onStart() { |
||||
super.onStart(); |
||||
/* Check if we are resuming during authorization by seeing if the intent contains the |
||||
* redirect that was given to the server. If so, its response is here! */ |
||||
Uri uri = getIntent().getData(); |
||||
String redirectUri = getOauthRedirectUri(); |
||||
|
||||
preferences = getSharedPreferences( |
||||
getString(R.string.preferences_file_key), Context.MODE_PRIVATE); |
||||
|
||||
if (preferences.getString("accessToken", null) != null |
||||
&& preferences.getString("domain", null) != null) { |
||||
// We are already logged in, go to MainActivity
|
||||
Intent intent = new Intent(this, MainActivity.class); |
||||
startActivity(intent); |
||||
finish(); |
||||
return; |
||||
} |
||||
|
||||
if (uri != null && uri.toString().startsWith(redirectUri)) { |
||||
// This should either have returned an authorization code or an error.
|
||||
String code = uri.getQueryParameter("code"); |
||||
String error = uri.getQueryParameter("error"); |
||||
|
||||
if (code != null) { |
||||
/* During the redirect roundtrip this Activity usually dies, which wipes out the |
||||
* instance variables, so they have to be recovered from where they were saved in |
||||
* SharedPreferences. */ |
||||
domain = preferences.getString("domain", null); |
||||
clientId = preferences.getString("clientId", null); |
||||
clientSecret = preferences.getString("clientSecret", null); |
||||
|
||||
setLoading(true); |
||||
/* Since authorization has succeeded, the final step to log in is to exchange |
||||
* the authorization code for an access token. */ |
||||
Callback<AccessToken> callback = new Callback<AccessToken>() { |
||||
@Override |
||||
public void onResponse(@NonNull Call<AccessToken> call, @NonNull Response<AccessToken> response) { |
||||
if (response.isSuccessful()) { |
||||
onLoginSuccess(response.body().accessToken); |
||||
} else { |
||||
setLoading(false); |
||||
|
||||
editText.setError(getString(R.string.error_retrieving_oauth_token)); |
||||
Log.e(TAG, String.format("%s %s", |
||||
getString(R.string.error_retrieving_oauth_token), |
||||
response.message())); |
||||
} |
||||
} |
||||
|
||||
@Override |
||||
public void onFailure(@NonNull Call<AccessToken> call, @NonNull Throwable t) { |
||||
setLoading(false); |
||||
editText.setError(getString(R.string.error_retrieving_oauth_token)); |
||||
Log.e(TAG, String.format("%s %s", |
||||
getString(R.string.error_retrieving_oauth_token), |
||||
t.getMessage())); |
||||
} |
||||
}; |
||||
|
||||
getApiFor(domain).fetchOAuthToken(clientId, clientSecret, redirectUri, code, |
||||
"authorization_code").enqueue(callback); |
||||
} else if (error != null) { |
||||
/* Authorization failed. Put the error response where the user can read it and they |
||||
* can try again. */ |
||||
setLoading(false); |
||||
editText.setError(getString(R.string.error_authorization_denied)); |
||||
Log.e(TAG, getString(R.string.error_authorization_denied) + error); |
||||
} else { |
||||
setLoading(false); |
||||
// This case means a junk response was received somehow.
|
||||
editText.setError(getString(R.string.error_authorization_unknown)); |
||||
} |
||||
} |
||||
} |
||||
|
||||
private void setLoading(boolean loadingState) { |
||||
if (loadingState) { |
||||
loading.setVisibility(View.VISIBLE); |
||||
input.setVisibility(View.GONE); |
||||
} else { |
||||
loading.setVisibility(View.GONE); |
||||
input.setVisibility(View.VISIBLE); |
||||
} |
||||
} |
||||
|
||||
private void onLoginSuccess(String accessToken) { |
||||
boolean committed = preferences.edit() |
||||
.putString("domain", domain) |
||||
.putString("accessToken", accessToken) |
||||
.commit(); |
||||
if (!committed) { |
||||
setLoading(false); |
||||
editText.setError(getString(R.string.error_retrieving_oauth_token)); |
||||
return; |
||||
} |
||||
|
||||
//create notification channels ahead of time so users can edit the settings
|
||||
NotificationManager.createNotificationChannels(this); |
||||
|
||||
Intent intent = new Intent(this, MainActivity.class); |
||||
startActivity(intent); |
||||
finish(); |
||||
} |
||||
} |
||||
@ -0,0 +1,377 @@
|
||||
/* Copyright 2017 Andrew Dawson |
||||
* |
||||
* This file is a part of Tusky. |
||||
* |
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the |
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the |
||||
* License, or (at your option) any later version. |
||||
* |
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even |
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General |
||||
* Public License for more details. |
||||
* |
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not, |
||||
* see <http://www.gnu.org/licenses>. */ |
||||
|
||||
package com.keylesspalace.tusky |
||||
|
||||
import android.app.AlertDialog |
||||
import android.content.ActivityNotFoundException |
||||
import android.content.Context |
||||
import android.content.Intent |
||||
import android.content.SharedPreferences |
||||
import android.net.Uri |
||||
import android.os.Bundle |
||||
import android.preference.PreferenceManager |
||||
import android.support.customtabs.CustomTabsIntent |
||||
import android.support.v7.app.AppCompatActivity |
||||
import android.text.method.LinkMovementMethod |
||||
import android.util.Log |
||||
import android.view.MenuItem |
||||
import android.view.View |
||||
import android.widget.EditText |
||||
import android.widget.TextView |
||||
import com.keylesspalace.tusky.entity.AccessToken |
||||
import com.keylesspalace.tusky.entity.AppCredentials |
||||
import com.keylesspalace.tusky.network.MastodonApi |
||||
import com.keylesspalace.tusky.util.CustomTabsHelper |
||||
import com.keylesspalace.tusky.util.OkHttpUtils |
||||
import com.keylesspalace.tusky.util.ResourcesUtils |
||||
import com.keylesspalace.tusky.util.ThemeUtils |
||||
import kotlinx.android.synthetic.main.activity_login.* |
||||
import retrofit2.Call |
||||
import retrofit2.Callback |
||||
import retrofit2.Response |
||||
import retrofit2.Retrofit |
||||
import retrofit2.converter.gson.GsonConverterFactory |
||||
|
||||
|
||||
class LoginActivity : AppCompatActivity() { |
||||
|
||||
private lateinit var preferences: SharedPreferences |
||||
private var domain: String = "" |
||||
private var clientId: String? = null |
||||
private var clientSecret: String? = null |
||||
|
||||
private val oauthRedirectUri: String |
||||
get() { |
||||
val scheme = getString(R.string.oauth_scheme) |
||||
val host = BuildConfig.APPLICATION_ID |
||||
return "$scheme://$host/" |
||||
} |
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) { |
||||
super.onCreate(savedInstanceState) |
||||
|
||||
preferences = PreferenceManager.getDefaultSharedPreferences(this) |
||||
val themeFlavorPair = preferences.getString("appTheme", TuskyApplication.APP_THEME_DEFAULT)!!.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() |
||||
val appTheme = themeFlavorPair[0] |
||||
val themeFlavorPreference = themeFlavorPair[2] |
||||
|
||||
setTheme(ResourcesUtils.getResourceIdentifier(this, "style", appTheme)) |
||||
|
||||
var flavor = preferences.getString("appThemeFlavor", ThemeUtils.THEME_FLAVOR_DEFAULT) |
||||
if (flavor == ThemeUtils.THEME_FLAVOR_DEFAULT) |
||||
flavor = themeFlavorPreference |
||||
ThemeUtils.setAppNightMode(flavor) |
||||
|
||||
setContentView(R.layout.activity_login) |
||||
|
||||
if (savedInstanceState != null) { |
||||
domain = savedInstanceState.getString(DOMAIN) |
||||
clientId = savedInstanceState.getString(CLIENT_ID) |
||||
clientSecret = savedInstanceState.getString(CLIENT_SECRET) |
||||
} |
||||
|
||||
preferences = getSharedPreferences( |
||||
getString(R.string.preferences_file_key), Context.MODE_PRIVATE) |
||||
|
||||
loginButton.setOnClickListener { onButtonClick() } |
||||
|
||||
whatsAnInstanceTextView.setOnClickListener { |
||||
val dialog = AlertDialog.Builder(this) |
||||
.setMessage(R.string.dialog_whats_an_instance) |
||||
.setPositiveButton(R.string.action_close, null) |
||||
.show() |
||||
val textView = dialog.findViewById<TextView>(android.R.id.message) |
||||
textView.movementMethod = LinkMovementMethod.getInstance() |
||||
} |
||||
|
||||
if(isAdditionalLogin()) { |
||||
setSupportActionBar(toolbar) |
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true) |
||||
supportActionBar?.setDisplayShowTitleEnabled(false) |
||||
} else { |
||||
toolbar.visibility = View.GONE |
||||
} |
||||
|
||||
} |
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean { |
||||
if(item.itemId == android.R.id.home) { |
||||
onBackPressed() |
||||
return true |
||||
} |
||||
return super.onOptionsItemSelected(item) |
||||
} |
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) { |
||||
outState.putString(DOMAIN, domain) |
||||
outState.putString(CLIENT_ID, clientId) |
||||
outState.putString(CLIENT_SECRET, clientSecret) |
||||
super.onSaveInstanceState(outState) |
||||
} |
||||
|
||||
private fun getApiFor(domain: String): MastodonApi { |
||||
val retrofit = Retrofit.Builder() |
||||
.baseUrl("https://" + domain) |
||||
.client(OkHttpUtils.getCompatibleClient(preferences)) |
||||
.addConverterFactory(GsonConverterFactory.create()) |
||||
.build() |
||||
|
||||
return retrofit.create(MastodonApi::class.java) |
||||
} |
||||
|
||||
/** |
||||
* Obtain the oauth client credentials for this app. This is only necessary the first time the |
||||
* app is run on a given server instance. So, after the first authentication, they are |
||||
* saved in SharedPreferences and every subsequent run they are simply fetched from there. |
||||
*/ |
||||
private fun onButtonClick() { |
||||
|
||||
loginButton.isEnabled = false |
||||
|
||||
domain = validateDomain(domainEditText.text.toString()) |
||||
|
||||
val callback = object : Callback<AppCredentials> { |
||||
override fun onResponse(call: Call<AppCredentials>, |
||||
response: Response<AppCredentials>) { |
||||
if (!response.isSuccessful) { |
||||
loginButton.isEnabled = true |
||||
domainEditText.error = getString(R.string.error_failed_app_registration) |
||||
Log.e(TAG, "App authentication failed. " + response.message()) |
||||
return |
||||
} |
||||
val credentials = response.body() |
||||
clientId = credentials!!.clientId |
||||
clientSecret = credentials.clientSecret |
||||
|
||||
redirectUserToAuthorizeAndLogin(domainEditText) |
||||
} |
||||
|
||||
override fun onFailure(call: Call<AppCredentials>, t: Throwable) { |
||||
loginButton.isEnabled = true |
||||
domainEditText.error = getString(R.string.error_failed_app_registration) |
||||
setLoading(false) |
||||
Log.e(TAG, Log.getStackTraceString(t)) |
||||
} |
||||
} |
||||
|
||||
try { |
||||
getApiFor(domain) |
||||
.authenticateApp(getString(R.string.app_name), oauthRedirectUri, |
||||
OAUTH_SCOPES, getString(R.string.app_website)) |
||||
.enqueue(callback) |
||||
setLoading(true) |
||||
} catch (e: IllegalArgumentException) { |
||||
setLoading(false) |
||||
domainEditText.error = getString(R.string.error_invalid_domain) |
||||
} |
||||
|
||||
} |
||||
|
||||
private fun redirectUserToAuthorizeAndLogin(editText: EditText) { |
||||
/* To authorize this app and log in it's necessary to redirect to the domain given, |
||||
* activity_login there, and the server will redirect back to the app with its response. */ |
||||
val endpoint = MastodonApi.ENDPOINT_AUTHORIZE |
||||
val redirectUri = oauthRedirectUri |
||||
val parameters = HashMap<String, String>() |
||||
parameters["client_id"] = clientId!! |
||||
parameters["redirect_uri"] = redirectUri |
||||
parameters["response_type"] = "code" |
||||
parameters["scope"] = OAUTH_SCOPES |
||||
val url = "https://" + domain + endpoint + "?" + toQueryString(parameters) |
||||
val uri = Uri.parse(url) |
||||
if (!openInCustomTab(uri, this)) { |
||||
val viewIntent = Intent(Intent.ACTION_VIEW, uri) |
||||
if (viewIntent.resolveActivity(packageManager) != null) { |
||||
startActivity(viewIntent) |
||||
} else { |
||||
editText.error = getString(R.string.error_no_web_browser_found) |
||||
setLoading(false) |
||||
} |
||||
} |
||||
} |
||||
|
||||
override fun onStop() { |
||||
super.onStop() |
||||
preferences.edit() |
||||
.putString("domain", domain) |
||||
.putString("clientId", clientId) |
||||
.putString("clientSecret", clientSecret) |
||||
.apply() |
||||
} |
||||
|
||||
override fun onStart() { |
||||
super.onStart() |
||||
/* Check if we are resuming during authorization by seeing if the intent contains the |
||||
* redirect that was given to the server. If so, its response is here! */ |
||||
val uri = intent.data |
||||
val redirectUri = oauthRedirectUri |
||||
|
||||
if (uri != null && uri.toString().startsWith(redirectUri)) { |
||||
// This should either have returned an authorization code or an error. |
||||
val code = uri.getQueryParameter("code") |
||||
val error = uri.getQueryParameter("error") |
||||
|
||||
if (code != null) { |
||||
/* During the redirect roundtrip this Activity usually dies, which wipes out the |
||||
* instance variables, so they have to be recovered from where they were saved in |
||||
* SharedPreferences. */ |
||||
domain = preferences.getString(DOMAIN, null) |
||||
clientId = preferences.getString(CLIENT_ID, null) |
||||
clientSecret = preferences.getString(CLIENT_SECRET, null) |
||||
|
||||
setLoading(true) |
||||
/* Since authorization has succeeded, the final step to log in is to exchange |
||||
* the authorization code for an access token. */ |
||||
val callback = object : Callback<AccessToken> { |
||||
override fun onResponse(call: Call<AccessToken>, response: Response<AccessToken>) { |
||||
if (response.isSuccessful) { |
||||
onLoginSuccess(response.body()!!.accessToken) |
||||
} else { |
||||
setLoading(false) |
||||
domainEditText.error = getString(R.string.error_retrieving_oauth_token) |
||||
Log.e(TAG, String.format("%s %s", |
||||
getString(R.string.error_retrieving_oauth_token), |
||||
response.message())) |
||||
} |
||||
} |
||||
|
||||
override fun onFailure(call: Call<AccessToken>, t: Throwable) { |
||||
setLoading(false) |
||||
domainEditText.error = getString(R.string.error_retrieving_oauth_token) |
||||
Log.e(TAG, String.format("%s %s", |
||||
getString(R.string.error_retrieving_oauth_token), |
||||
t.message)) |
||||
} |
||||
} |
||||
|
||||
getApiFor(domain).fetchOAuthToken(clientId, clientSecret, redirectUri, code, |
||||
"authorization_code").enqueue(callback) |
||||
} else if (error != null) { |
||||
/* Authorization failed. Put the error response where the user can read it and they |
||||
* can try again. */ |
||||
setLoading(false) |
||||
domainEditText.error = getString(R.string.error_authorization_denied) |
||||
Log.e(TAG, String.format("%s %s", |
||||
getString(R.string.error_authorization_denied), |
||||
error)) |
||||
} else { |
||||
// This case means a junk response was received somehow. |
||||
setLoading(false) |
||||
domainEditText.error = getString(R.string.error_authorization_unknown) |
||||
} |
||||
} else { |
||||
// first show or user cancelled login |
||||
setLoading(false) |
||||
} |
||||
} |
||||
|
||||
private fun setLoading(loadingState: Boolean) { |
||||
if (loadingState) { |
||||
loginLoadingLayout.visibility = View.VISIBLE |
||||
loginInputLayout.visibility = View.GONE |
||||
} else { |
||||
loginLoadingLayout.visibility = View.GONE |
||||
loginInputLayout.visibility = View.VISIBLE |
||||
loginButton.isEnabled = true |
||||
} |
||||
} |
||||
|
||||
private fun isAdditionalLogin() : Boolean { |
||||
return intent.getBooleanExtra(LOGIN_MODE, false) |
||||
} |
||||
|
||||
private fun onLoginSuccess(accessToken: String) { |
||||
|
||||
setLoading(true) |
||||
|
||||
TuskyApplication.getAccountManager().addAccount(accessToken, domain) |
||||
|
||||
val intent = Intent(this, MainActivity::class.java) |
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK |
||||
startActivity(intent) |
||||
finish() |
||||
} |
||||
|
||||
companion object { |
||||
private const val TAG = "LoginActivity" // logging tag |
||||
private const val OAUTH_SCOPES = "read write follow" |
||||
private const val LOGIN_MODE = "LOGIN_MODE" |
||||
private const val DOMAIN = "domain" |
||||
private const val CLIENT_ID = "clientId" |
||||
private const val CLIENT_SECRET = "clientSecret" |
||||
|
||||
@JvmStatic |
||||
fun getIntent(context: Context, mode: Boolean): Intent { |
||||
val loginIntent = Intent(context, LoginActivity::class.java) |
||||
loginIntent.putExtra(LOGIN_MODE, mode) |
||||
return loginIntent |
||||
} |
||||
|
||||
/** Make sure the user-entered text is just a fully-qualified domain name. */ |
||||
private fun validateDomain(domain: String): String { |
||||
// Strip any schemes out. |
||||
var s = domain.replaceFirst("http://", "") |
||||
s = s.replaceFirst("https://", "") |
||||
// If a username was included (e.g. username@example.com), just take what's after the '@'. |
||||
val at = s.lastIndexOf('@') |
||||
if (at != -1) { |
||||
s = s.substring(at + 1) |
||||
} |
||||
return s.trim { it <= ' ' } |
||||
} |
||||
|
||||
/** |
||||
* Chain together the key-value pairs into a query string, for either appending to a URL or |
||||
* as the content of an HTTP request. |
||||
*/ |
||||
private fun toQueryString(parameters: Map<String, String>): String { |
||||
val s = StringBuilder() |
||||
var between = "" |
||||
for ((key, value) in parameters) { |
||||
s.append(between) |
||||
s.append(Uri.encode(key)) |
||||
s.append("=") |
||||
s.append(Uri.encode(value)) |
||||
between = "&" |
||||
} |
||||
return s.toString() |
||||
} |
||||
|
||||
private fun openInCustomTab(uri: Uri, context: Context): Boolean { |
||||
|
||||
val toolbarColor = ThemeUtils.getColorById(context, "custom_tab_toolbar") |
||||
val builder = CustomTabsIntent.Builder() |
||||
builder.setToolbarColor(toolbarColor) |
||||
val customTabsIntent = builder.build() |
||||
try { |
||||
val packageName = CustomTabsHelper.getPackageNameToUse(context) |
||||
/* If we cant find a package name, it means theres no browser that supports |
||||
* Chrome Custom Tabs installed. So, we fallback to the webview */ |
||||
if (packageName == null) { |
||||
return false |
||||
} else { |
||||
customTabsIntent.intent.`package` = packageName |
||||
customTabsIntent.launchUrl(context, uri) |
||||
} |
||||
} catch (e: ActivityNotFoundException) { |
||||
Log.w(TAG, "Activity was not found for intent, " + customTabsIntent.toString()) |
||||
return false |
||||
} |
||||
|
||||
return true |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,67 @@
|
||||
/* Copyright 2018 Conny Duck |
||||
* |
||||
* This file is a part of Tusky. |
||||
* |
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the |
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the |
||||
* License, or (at your option) any later version. |
||||
* |
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even |
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General |
||||
* Public License for more details. |
||||
* |
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not, |
||||
* see <http://www.gnu.org/licenses>. */ |
||||
|
||||
package com.keylesspalace.tusky.db |
||||
|
||||
import android.arch.persistence.room.Entity |
||||
import android.arch.persistence.room.Index |
||||
import android.arch.persistence.room.PrimaryKey |
||||
|
||||
@Entity(indices = [Index(value = ["domain", "accountId"], |
||||
unique = true)]) |
||||
data class AccountEntity(@field:PrimaryKey(autoGenerate = true) var id: Long, |
||||
val domain: String, |
||||
var accessToken: String, |
||||
var isActive: Boolean, |
||||
var accountId: String = "", |
||||
var username: String = "", |
||||
var displayName: String = "", |
||||
var profilePictureUrl: String = "", |
||||
var notificationsEnabled: Boolean = true, |
||||
var notificationsMentioned: Boolean = true, |
||||
var notificationsFollowed: Boolean = true, |
||||
var notificationsReblogged: Boolean = true, |
||||
var notificationsFavorited: Boolean = true, |
||||
var notificationSound: Boolean = true, |
||||
var notificationVibration: Boolean = true, |
||||
var notificationLight: Boolean = true, |
||||
var lastNotificationId: String = "0", |
||||
var activeNotifications: String = "[]") { |
||||
|
||||
val identifier: String |
||||
get() = "$domain:$accountId" |
||||
|
||||
val fullName: String |
||||
get() = "@$username@$domain" |
||||
|
||||
override fun equals(other: Any?): Boolean { |
||||
if (this === other) return true |
||||
if (javaClass != other?.javaClass) return false |
||||
|
||||
other as AccountEntity |
||||
|
||||
if (id == other.id) return true |
||||
if (domain == other.domain && accountId == other.accountId) return true |
||||
|
||||
return false |
||||
} |
||||
|
||||
override fun hashCode(): Int { |
||||
var result = id.hashCode() |
||||
result = 31 * result + domain.hashCode() |
||||
result = 31 * result + accountId.hashCode() |
||||
return result |
||||
} |
||||
} |
||||
@ -0,0 +1,190 @@
|
||||
/* Copyright 2018 Conny Duck |
||||
* |
||||
* This file is a part of Tusky. |
||||
* |
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the |
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the |
||||
* License, or (at your option) any later version. |
||||
* |
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even |
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General |
||||
* Public License for more details. |
||||
* |
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not, |
||||
* see <http://www.gnu.org/licenses>. */ |
||||
|
||||
package com.keylesspalace.tusky.db |
||||
|
||||
import android.util.Log |
||||
import com.keylesspalace.tusky.TuskyApplication |
||||
import com.keylesspalace.tusky.entity.Account |
||||
|
||||
/** |
||||
* This class caches the account database and handles all account related operations |
||||
* @author ConnyDuck |
||||
*/ |
||||
|
||||
class AccountManager { |
||||
|
||||
@Volatile var activeAccount: AccountEntity? = null |
||||
|
||||
private var accounts: MutableList<AccountEntity> = mutableListOf() |
||||
private val accountDao: AccountDao = TuskyApplication.getDB().accountDao() |
||||
|
||||
init { |
||||
accounts = accountDao.loadAll().toMutableList() |
||||
|
||||
activeAccount = accounts.find { acc -> |
||||
acc.isActive |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Adds a new empty account and makes it the active account. |
||||
* More account information has to be added later with [updateActiveAccount] |
||||
* or the account wont be saved to the database. |
||||
* @param accessToken the access token for the new account |
||||
* @param domain the domain of the accounts Mastodon instance |
||||
*/ |
||||
fun addAccount(accessToken: String, domain: String) { |
||||
|
||||
activeAccount?.let{ |
||||
it.isActive = false |
||||
Log.d("AccountManager", "saving account with id "+it.id) |
||||
|
||||
accountDao.insertOrReplace(it) |
||||
} |
||||
|
||||
activeAccount = AccountEntity(id = 0, domain = domain, accessToken = accessToken, isActive = true) |
||||
|
||||
} |
||||
|
||||
/** |
||||
* Saves an already known account to the database. |
||||
* New accounts must be created with [addAccount] |
||||
* @param account the account to save |
||||
*/ |
||||
fun saveAccount(account: AccountEntity) { |
||||
if(account.id != 0L) { |
||||
Log.d("AccountManager", "saving account with id "+account.id) |
||||
val index = accounts.indexOf(account) |
||||
if (index != -1) { |
||||
accounts.removeAt(index) |
||||
accounts.add(account) |
||||
} |
||||
|
||||
accountDao.insertOrReplace(account) |
||||
} |
||||
|
||||
} |
||||
|
||||
/** |
||||
* Logs the current account out by deleting all data of the account. |
||||
* @return the new active account, or null if no other account was found |
||||
*/ |
||||
fun logActiveAccountOut() : AccountEntity? { |
||||
|
||||
if(activeAccount == null) { |
||||
return null |
||||
} else { |
||||
accounts.remove(activeAccount!!) |
||||
accountDao.delete(activeAccount!!) |
||||
|
||||
if(accounts.size > 0) { |
||||
accounts[0].isActive = true |
||||
activeAccount = accounts[0] |
||||
accountDao.insertOrReplace(accounts[0]) |
||||
} else { |
||||
activeAccount = null |
||||
} |
||||
return activeAccount |
||||
|
||||
} |
||||
|
||||
} |
||||
|
||||
/** |
||||
* updates the current account with new information from the mastodon api |
||||
* and saves it in the database |
||||
* @param account the [Account] object returned from the api |
||||
*/ |
||||
fun updateActiveAccount(account: Account) { |
||||
activeAccount?.let{ |
||||
it.accountId = account.id |
||||
it.username = account.username |
||||
it.displayName = account.getDisplayName() |
||||
it.profilePictureUrl = account.avatar |
||||
|
||||
Log.d("AccountManager", "id before save "+it.id) |
||||
it.id = accountDao.insertOrReplace(it) |
||||
Log.d("AccountManager", "id after save "+it.id) |
||||
|
||||
|
||||
val accountIndex = accounts.indexOf(it) |
||||
|
||||
if(accountIndex != -1) { |
||||
//in case the user was already logged in with this account, remove the old information |
||||
accounts.removeAt(accountIndex) |
||||
accounts.add(accountIndex, it) |
||||
} else { |
||||
accounts.add(it) |
||||
} |
||||
|
||||
} |
||||
} |
||||
|
||||
/** |
||||
* changes the active account |
||||
* @param accountId the database id of the new active account |
||||
*/ |
||||
fun setActiveAccount(accountId: Long) { |
||||
|
||||
activeAccount?.let{ |
||||
it.isActive = false |
||||
accountDao.insertOrReplace(it) |
||||
} |
||||
|
||||
activeAccount = accounts.find { acc -> |
||||
acc.id == accountId |
||||
} |
||||
|
||||
activeAccount?.let{ |
||||
it.isActive = true |
||||
accountDao.insertOrReplace(it) |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* @return an immutable list of all accounts in the database with the active account first |
||||
*/ |
||||
fun getAllAccountsOrderedByActive(): List<AccountEntity> { |
||||
accounts.sortWith (Comparator { l, r -> |
||||
when { |
||||
l.isActive && !r.isActive -> -1 |
||||
r.isActive && !l.isActive -> 1 |
||||
else -> 0 |
||||
} |
||||
}) |
||||
|
||||
return accounts.toList() |
||||
} |
||||
|
||||
/** |
||||
* @return true if at least one account has notifications enabled |
||||
*/ |
||||
fun notificationsEnabled(): Boolean { |
||||
return accounts.any { it.notificationsEnabled } |
||||
} |
||||
|
||||
/** |
||||
* Finds an account by its database id |
||||
* @param accountId the id of the account |
||||
* @return the requested account or null if it was not found |
||||
*/ |
||||
fun getAccountById(accountId: Long): AccountEntity? { |
||||
return accounts.find { acc -> |
||||
acc.id == accountId |
||||
} |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,38 @@
|
||||
/* Copyright 2018 Conny Duck |
||||
* |
||||
* This file is a part of Tusky. |
||||
* |
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the |
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the |
||||
* License, or (at your option) any later version. |
||||
* |
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even |
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General |
||||
* Public License for more details. |
||||
* |
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not, |
||||
* see <http://www.gnu.org/licenses>. */ |
||||
|
||||
package com.keylesspalace.tusky.receiver |
||||
|
||||
import android.content.BroadcastReceiver |
||||
import android.content.Context |
||||
import android.content.Intent |
||||
|
||||
import com.keylesspalace.tusky.TuskyApplication |
||||
import com.keylesspalace.tusky.util.NotificationManager |
||||
|
||||
class NotificationClearBroadcastReceiver : BroadcastReceiver() { |
||||
override fun onReceive(context: Context, intent: Intent) { |
||||
|
||||
val accountId = intent.getLongExtra(NotificationManager.ACCOUNT_ID, -1) |
||||
|
||||
val accountManager = TuskyApplication.getAccountManager() |
||||
val account = accountManager.getAccountById(accountId) |
||||
if (account != null) { |
||||
account.activeNotifications = "[]" |
||||
accountManager.saveAccount(account) |
||||
} |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?> |
||||
<set xmlns:android="http://schemas.android.com/apk/res/android" > |
||||
<scale |
||||
xmlns:android="http://schemas.android.com/apk/res/android" |
||||
android:duration="300" |
||||
android:fromXScale="0" |
||||
android:fromYScale="0" |
||||
android:pivotX="50%" |
||||
android:pivotY="50%" |
||||
android:toXScale="1" |
||||
android:toYScale="1" > |
||||
</scale> |
||||
</set> |
||||
|
After Width: | Height: | Size: 1.3 KiB |
@ -1,82 +1,99 @@
|
||||
<?xml version="1.0" encoding="utf-8"?> |
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" |
||||
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" |
||||
xmlns:app="http://schemas.android.com/apk/res-auto" |
||||
xmlns:tools="http://schemas.android.com/tools" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="match_parent" |
||||
android:fillViewport="true" |
||||
android:gravity="center" |
||||
android:orientation="vertical" |
||||
tools:context="com.keylesspalace.tusky.LoginActivity"> |
||||
|
||||
<LinearLayout |
||||
android:orientation="vertical" |
||||
<ScrollView |
||||
android:layout_width="match_parent" |
||||
android:padding="16dp" |
||||
android:gravity="center" |
||||
android:layout_height="wrap_content"> |
||||
|
||||
<ImageView |
||||
android:layout_width="147dp" |
||||
android:layout_height="160dp" |
||||
android:layout_marginBottom="50dp" |
||||
android:src="@drawable/elephant_friend" |
||||
android:contentDescription="@null" /> |
||||
android:layout_height="match_parent" |
||||
android:fillViewport="true" |
||||
app:layout_constraintBottom_toBottomOf="parent" |
||||
app:layout_constraintTop_toTopOf="parent"> |
||||
|
||||
<LinearLayout |
||||
android:id="@+id/login_input" |
||||
android:layout_width="wrap_content" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="wrap_content" |
||||
android:orientation="vertical"> |
||||
<android.support.design.widget.TextInputLayout |
||||
android:layout_height="wrap_content" |
||||
android:layout_width="250dp"> |
||||
<android.support.design.widget.TextInputEditText |
||||
android:layout_width="match_parent" |
||||
android:layout_height="wrap_content" |
||||
android:inputType="textUri" |
||||
android:hint="@string/hint_domain" |
||||
android:ems="10" |
||||
android:id="@+id/edit_text_domain" /> |
||||
</android.support.design.widget.TextInputLayout> |
||||
android:gravity="center" |
||||
android:orientation="vertical" |
||||
android:padding="16dp"> |
||||
|
||||
<Button |
||||
android:id="@+id/button_login" |
||||
android:layout_width="250dp" |
||||
android:layout_height="wrap_content" |
||||
android:layout_marginTop="4dp" |
||||
android:textColor="@android:color/white" |
||||
android:text="@string/action_login" /> |
||||
<ImageView |
||||
android:layout_width="147dp" |
||||
android:layout_height="160dp" |
||||
android:layout_marginBottom="50dp" |
||||
android:contentDescription="@null" |
||||
android:src="@drawable/elephant_friend" /> |
||||
|
||||
<TextView |
||||
android:layout_width="250dp" |
||||
<LinearLayout |
||||
android:id="@+id/loginInputLayout" |
||||
android:layout_width="wrap_content" |
||||
android:layout_height="wrap_content" |
||||
android:visibility="gone" |
||||
android:id="@+id/text_error" /> |
||||
android:orientation="vertical"> |
||||
|
||||
<TextView |
||||
android:layout_width="250dp" |
||||
android:layout_height="wrap_content" |
||||
android:paddingTop="5dp" |
||||
android:textAlignment="center" |
||||
android:id="@+id/whats_an_instance" |
||||
android:text="@string/link_whats_an_instance" /> |
||||
</LinearLayout> |
||||
<LinearLayout |
||||
android:id="@+id/login_loading" |
||||
android:visibility="gone" |
||||
android:orientation="vertical" |
||||
android:layout_width="wrap_content" |
||||
android:layout_height="wrap_content"> |
||||
<ProgressBar |
||||
android:layout_gravity="center" |
||||
<android.support.design.widget.TextInputLayout |
||||
android:layout_width="250dp" |
||||
android:layout_height="wrap_content"> |
||||
|
||||
<android.support.design.widget.TextInputEditText |
||||
android:id="@+id/domainEditText" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="wrap_content" |
||||
android:ems="10" |
||||
android:hint="@string/hint_domain" |
||||
android:inputType="textUri" /> |
||||
</android.support.design.widget.TextInputLayout> |
||||
|
||||
<Button |
||||
android:id="@+id/loginButton" |
||||
android:layout_width="250dp" |
||||
android:layout_height="wrap_content" |
||||
android:layout_marginTop="6dp" |
||||
android:text="@string/action_login" |
||||
android:textColor="@android:color/white" /> |
||||
|
||||
<TextView |
||||
android:id="@+id/whatsAnInstanceTextView" |
||||
android:layout_width="250dp" |
||||
android:layout_height="wrap_content" |
||||
android:paddingTop="6dp" |
||||
android:text="@string/link_whats_an_instance" |
||||
android:textAlignment="center" /> |
||||
</LinearLayout> |
||||
|
||||
<LinearLayout |
||||
android:id="@+id/loginLoadingLayout" |
||||
android:layout_width="wrap_content" |
||||
android:layout_height="wrap_content" /> |
||||
<TextView |
||||
android:paddingTop="10dp" |
||||
android:textAlignment="center" |
||||
android:layout_width="250dp" |
||||
android:layout_height="wrap_content" |
||||
android:text="@string/login_connection"/> |
||||
android:orientation="vertical" |
||||
android:visibility="gone"> |
||||
|
||||
<ProgressBar |
||||
android:layout_width="wrap_content" |
||||
android:layout_height="wrap_content" |
||||
android:layout_gravity="center" /> |
||||
|
||||
<TextView |
||||
android:layout_width="250dp" |
||||
android:layout_height="wrap_content" |
||||
android:paddingTop="10dp" |
||||
android:text="@string/login_connection" |
||||
android:textAlignment="center" /> |
||||
</LinearLayout> |
||||
|
||||
</LinearLayout> |
||||
</ScrollView> |
||||
|
||||
<android.support.v7.widget.Toolbar |
||||
android:id="@+id/toolbar" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="?attr/actionBarSize" |
||||
android:layout_alignParentTop="true" |
||||
android:background="@android:color/transparent" |
||||
app:layout_constraintTop_toTopOf="parent" /> |
||||
|
||||
</LinearLayout> |
||||
</ScrollView> |
||||
</android.support.constraint.ConstraintLayout> |
||||
Loading…
Reference in new issue