Multi account feature (#490)

* 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 improvements
main
Konrad Pozniak 7 years ago committed by GitHub
parent 79519a071d
commit 40cda2ced3
  1. 1
      README.md
  2. 5
      app/build.gradle
  3. 1
      app/src/main/AndroidManifest.xml
  4. 26
      app/src/main/java/com/keylesspalace/tusky/BaseActivity.java
  5. 19
      app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java
  6. 406
      app/src/main/java/com/keylesspalace/tusky/LoginActivity.java
  7. 377
      app/src/main/java/com/keylesspalace/tusky/LoginActivity.kt
  8. 225
      app/src/main/java/com/keylesspalace/tusky/MainActivity.java
  9. 100
      app/src/main/java/com/keylesspalace/tusky/NotificationPullJobCreator.java
  10. 17
      app/src/main/java/com/keylesspalace/tusky/SplashActivity.java
  11. 11
      app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java
  12. 28
      app/src/main/java/com/keylesspalace/tusky/db/AccountDao.kt
  13. 67
      app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt
  14. 190
      app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt
  15. 3
      app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java
  16. 40
      app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java
  17. 102
      app/src/main/java/com/keylesspalace/tusky/fragment/PreferencesFragment.java
  18. 11
      app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java
  19. 33
      app/src/main/java/com/keylesspalace/tusky/network/AuthInterceptor.java
  20. 4
      app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java
  21. 38
      app/src/main/java/com/keylesspalace/tusky/receiver/NotificationClearBroadcastReceiver.kt
  22. 162
      app/src/main/java/com/keylesspalace/tusky/util/NotificationManager.java
  23. 13
      app/src/main/res/anim/explode.xml
  24. BIN
      app/src/main/res/drawable-xxhdpi/ic_reblog_dark_.png
  25. 11
      app/src/main/res/layout/activity_compose.xml
  26. 143
      app/src/main/res/layout/activity_login.xml
  27. 2
      app/src/main/res/layout/item_follow.xml
  28. 2
      app/src/main/res/layout/item_status_notification.xml
  29. 1
      app/src/main/res/values-ar/strings.xml
  30. 1
      app/src/main/res/values-ca/strings.xml
  31. 1
      app/src/main/res/values-de/strings.xml
  32. 1
      app/src/main/res/values-fr/strings.xml
  33. 1
      app/src/main/res/values-hu/strings.xml
  34. 2
      app/src/main/res/values-ja/strings.xml
  35. 1
      app/src/main/res/values-nl/strings.xml
  36. 3
      app/src/main/res/values-pl/strings.xml
  37. 1
      app/src/main/res/values-pt-rBR/strings.xml
  38. 1
      app/src/main/res/values-ru/strings.xml
  39. 2
      app/src/main/res/values/colors.xml
  40. 12
      app/src/main/res/values/strings.xml

@ -12,6 +12,7 @@ Tusky is a beautiful Android client for [Mastodon](https://github.com/tootsuite/
- Material Design
- Most Mastodon APIs implemented
- Muti-Account support
- completely Open-source - no non-free dependencies like Google services
#### Head of development

@ -1,6 +1,7 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-kapt'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
android {
compileSdkVersion 27
@ -49,6 +50,7 @@ dependencies {
implementation('com.mikepenz:materialdrawer:6.0.4@aar') {
transitive = true
}
debugCompile 'im.dino:dbinspector:3.4.1@aar'
implementation "com.android.support:appcompat-v7:$supportLibraryVersion"
implementation "com.android.support:customtabs:$supportLibraryVersion"
implementation "com.android.support:recyclerview-v7:$supportLibraryVersion"
@ -72,6 +74,9 @@ dependencies {
implementation 'android.arch.persistence.room:runtime:1.0.0'
kapt 'android.arch.persistence.room:compiler:1.0.0'
testImplementation 'junit:junit:4.12'
debugImplementation 'im.dino:dbinspector:3.4.1@aar'
androidTestImplementation('com.android.support.test.espresso:espresso-core:2.2.2', {
exclude group: 'com.android.support', module: 'support-annotations'
})

@ -9,7 +9,6 @@
<uses-permission android:name="android.permission.VIBRATE" /> <!--For notifications-->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <!-- for day/night mode -->
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"

@ -33,6 +33,7 @@ import com.evernote.android.job.JobManager;
import com.evernote.android.job.JobRequest;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.keylesspalace.tusky.db.AccountEntity;
import com.keylesspalace.tusky.json.SpannedTypeAdapter;
import com.keylesspalace.tusky.network.AuthInterceptor;
import com.keylesspalace.tusky.network.MastodonApi;
@ -123,19 +124,13 @@ public abstract class BaseActivity extends AppCompatActivity {
return getSharedPreferences(getString(R.string.preferences_file_key), Context.MODE_PRIVATE);
}
protected String getAccessToken() {
SharedPreferences preferences = getPrivatePreferences();
return preferences.getString("accessToken", null);
}
protected boolean arePushNotificationsEnabled() {
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this);
return preferences.getBoolean("notificationsEnabled", true);
}
protected String getBaseUrl() {
SharedPreferences preferences = getPrivatePreferences();
return "https://" + preferences.getString("domain", null);
AccountEntity account = TuskyApplication.getAccountManager().getActiveAccount();
if(account != null) {
return "https://" + account.getDomain();
} else {
return "";
}
}
protected void createMastodonApi() {
@ -149,7 +144,7 @@ public abstract class BaseActivity extends AppCompatActivity {
OkHttpClient.Builder okBuilder =
OkHttpUtils.getCompatibleClientBuilder(preferences)
.addInterceptor(new AuthInterceptor(this))
.addInterceptor(new AuthInterceptor())
.dispatcher(mastodonApiDispatcher);
if (BuildConfig.DEBUG) {
@ -166,10 +161,7 @@ public abstract class BaseActivity extends AppCompatActivity {
}
protected void redirectIfNotLoggedIn() {
SharedPreferences preferences = getPrivatePreferences();
String domain = preferences.getString("domain", null);
String accessToken = preferences.getString("accessToken", null);
if (domain == null || accessToken == null) {
if (TuskyApplication.getAccountManager().getActiveAccount() == null) {
Intent intent = new Intent(this, LoginActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);

@ -78,6 +78,7 @@ import android.widget.Toast;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import com.keylesspalace.tusky.adapter.MentionAutoCompleteAdapter;
import com.keylesspalace.tusky.db.AccountEntity;
import com.keylesspalace.tusky.db.TootDao;
import com.keylesspalace.tusky.db.TootEntity;
import com.keylesspalace.tusky.entity.Account;
@ -96,6 +97,7 @@ import com.keylesspalace.tusky.util.StringUtils;
import com.keylesspalace.tusky.util.ThemeUtils;
import com.keylesspalace.tusky.view.EditTextTyped;
import com.keylesspalace.tusky.view.ProgressImageView;
import com.keylesspalace.tusky.view.RoundedTransformation;
import com.squareup.picasso.Picasso;
import com.varunest.sparkbutton.helpers.Utils;
@ -448,6 +450,23 @@ public final class ComposeActivity extends BaseActivity
}
}
AccountEntity activeAccount = TuskyApplication.getAccountManager().getActiveAccount();
if(activeAccount != null) {
ImageView composeAvatar = findViewById(R.id.composeAvatar);
Picasso.with(this).load(activeAccount.getProfilePictureUrl())
.transform(new RoundedTransformation(7, 0))
.error(R.drawable.avatar_default)
.placeholder(R.drawable.avatar_default)
.into(composeAvatar);
composeAvatar.setContentDescription(
getString(R.string.compose_active_account_description,
activeAccount.getFullName()));
}
}

@ -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
}
}
}

@ -33,10 +33,11 @@ import android.support.v4.view.ViewPager;
import android.support.v7.app.AlertDialog;
import android.util.Log;
import android.view.KeyEvent;
import android.view.View;
import android.widget.ImageButton;
import android.widget.ImageView;
import com.keylesspalace.tusky.db.AccountEntity;
import com.keylesspalace.tusky.db.AccountManager;
import com.keylesspalace.tusky.entity.Account;
import com.keylesspalace.tusky.interfaces.ActionButtonActivity;
import com.keylesspalace.tusky.pager.TimelinePagerAdapter;
@ -51,6 +52,7 @@ import com.mikepenz.materialdrawer.DrawerBuilder;
import com.mikepenz.materialdrawer.model.DividerDrawerItem;
import com.mikepenz.materialdrawer.model.PrimaryDrawerItem;
import com.mikepenz.materialdrawer.model.ProfileDrawerItem;
import com.mikepenz.materialdrawer.model.ProfileSettingDrawerItem;
import com.mikepenz.materialdrawer.model.SecondaryDrawerItem;
import com.mikepenz.materialdrawer.model.interfaces.IDrawerItem;
import com.mikepenz.materialdrawer.model.interfaces.IProfile;
@ -67,6 +69,7 @@ import retrofit2.Response;
public class MainActivity extends BaseActivity implements ActionButtonActivity {
private static final String TAG = "MainActivity"; // logging tag
private static final long DRAWER_ITEM_ADD_ACCOUNT = -13;
private static final long DRAWER_ITEM_EDIT_PROFILE = 0;
private static final long DRAWER_ITEM_FAVOURITES = 1;
private static final long DRAWER_ITEM_MUTED_USERS = 2;
@ -82,14 +85,32 @@ public class MainActivity extends BaseActivity implements ActionButtonActivity {
private static int COMPOSE_RESULT = 1;
private FloatingActionButton composeButton;
private String loggedInAccountId;
private String loggedInAccountUsername;
private AccountHeader headerResult;
private Drawer drawer;
private ViewPager viewPager;
@Override
protected void onCreate(Bundle savedInstanceState) {
// account switching has to be done before MastodonApi is created in super.onCreate
Intent intent = getIntent();
int tabPosition = 0;
if (intent != null) {
long accountId = intent.getLongExtra(NotificationManager.ACCOUNT_ID, -1);
if(accountId != -1) {
// user clicked a notification, show notification tab and switch user if necessary
tabPosition = 1;
AccountEntity account = TuskyApplication.getAccountManager().getActiveAccount();
if (account == null || accountId != account.getId()) {
TuskyApplication.getAccountManager().setActiveAccount(accountId);
}
}
}
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
@ -99,8 +120,8 @@ public class MainActivity extends BaseActivity implements ActionButtonActivity {
viewPager = findViewById(R.id.pager);
floatingBtn.setOnClickListener(v -> {
Intent intent = new Intent(getApplicationContext(), ComposeActivity.class);
startActivityForResult(intent, COMPOSE_RESULT);
Intent composeIntent = new Intent(getApplicationContext(), ComposeActivity.class);
startActivityForResult(composeIntent, COMPOSE_RESULT);
});
setupDrawer();
@ -109,7 +130,7 @@ public class MainActivity extends BaseActivity implements ActionButtonActivity {
ThemeUtils.setDrawableTint(this, drawerToggle.getDrawable(), R.attr.toolbar_icon_tint);
drawerToggle.setOnClickListener(v -> drawer.openDrawer());
/* Fetch user info while we're doing other things. This has to be after setting up the
/* Fetch user info while we're doing other things. This has to be done after setting up the
* drawer, though, because its callback touches the header in the drawer. */
fetchUserInfo();
@ -143,6 +164,15 @@ public class MainActivity extends BaseActivity implements ActionButtonActivity {
tab.setContentDescription(pageTitles[i]);
}
if (tabPosition != 0) {
TabLayout.Tab tab = tabLayout.getTabAt(tabPosition);
if (tab != null) {
tab.select();
} else {
tabPosition = 0;
}
}
tabLayout.addOnTabSelectedListener(new TabLayout.OnTabSelectedListener() {
@Override
public void onTabSelected(TabLayout.Tab tab) {
@ -151,7 +181,7 @@ public class MainActivity extends BaseActivity implements ActionButtonActivity {
tintTab(tab, true);
if(tab.getPosition() == 1) {
NotificationManager.clearNotifications(MainActivity.this);
NotificationManager.clearNotificationsForActiveAccount(MainActivity.this);
}
}
@ -161,29 +191,15 @@ public class MainActivity extends BaseActivity implements ActionButtonActivity {
}
@Override
public void onTabReselected(TabLayout.Tab tab) {
}
public void onTabReselected(TabLayout.Tab tab) { }
});
Intent intent = getIntent();
int tabSelected = 0;
if (intent != null) {
int tabPosition = intent.getIntExtra("tab_position", 0);
if (tabPosition != 0) {
TabLayout.Tab tab = tabLayout.getTabAt(tabPosition);
if (tab != null) {
tab.select();
tabSelected = tabPosition;
}
}
}
for (int i = 0; i < 4; i++) {
tintTab(tabLayout.getTabAt(i), i == tabSelected);
tintTab(tabLayout.getTabAt(i), i == tabPosition);
}
// Setup push notifications
if (arePushNotificationsEnabled()) {
if (TuskyApplication.getAccountManager().notificationsEnabled()) {
enablePushNotifications();
} else {
disablePushNotifications();
@ -196,7 +212,7 @@ public class MainActivity extends BaseActivity implements ActionButtonActivity {
protected void onResume() {
super.onResume();
NotificationManager.clearNotifications(this);
NotificationManager.clearNotificationsForActiveAccount(this);
/* After editing a profile, the profile header in the navigation drawer needs to be
* refreshed */
@ -208,9 +224,6 @@ public class MainActivity extends BaseActivity implements ActionButtonActivity {
.apply();
}
if(viewPager.getCurrentItem() == 1) {
NotificationManager.clearNotifications(this);
}
}
@Override
@ -267,28 +280,18 @@ public class MainActivity extends BaseActivity implements ActionButtonActivity {
private void setupDrawer() {
headerResult = new AccountHeaderBuilder()
.withActivity(this)
.withSelectionListEnabledForSingleProfile(false)
.withDividerBelowHeader(false)
.withHeaderBackgroundScaleType(ImageView.ScaleType.CENTER_CROP)
.withOnAccountHeaderProfileImageListener(new AccountHeader.OnAccountHeaderProfileImageListener() {
@Override
public boolean onProfileImageClick(View view, IProfile profile, boolean current) {
if (current && loggedInAccountId != null) {
Intent intent = new Intent(MainActivity.this, AccountActivity.class);
intent.putExtra("id", loggedInAccountId);
startActivity(intent);
return true;
}
return false;
}
@Override
public boolean onProfileImageLongClick(View view, IProfile profile, boolean current) {
return false;
}
})
.withCompactStyle(true)
.withCurrentProfileHiddenInList(true)
.withOnAccountHeaderListener((view, profile, current) -> handleProfileClick(profile, current))
.addProfiles(
new ProfileSettingDrawerItem()
.withIdentifier(DRAWER_ITEM_ADD_ACCOUNT)
.withName(R.string.add_account_name)
.withDescription(R.string.add_account_description)
.withIcon(GoogleMaterial.Icon.gmd_add))
.build();
headerResult.getView()
.findViewById(R.id.material_drawer_account_header_current)
.setContentDescription(getString(R.string.action_view_profile));
@ -371,6 +374,7 @@ public class MainActivity extends BaseActivity implements ActionButtonActivity {
} else if (drawerItemIdentifier == DRAWER_ITEM_LISTS) {
startActivity(ListsActivity.newIntent(this));
}
}
return false;
@ -388,43 +392,78 @@ public class MainActivity extends BaseActivity implements ActionButtonActivity {
}
}
private void logout() {
new AlertDialog.Builder(this)
.setTitle(R.string.action_logout)
.setMessage(R.string.action_logout_confirm)
.setPositiveButton(android.R.string.yes, (dialog, which) -> {
if (arePushNotificationsEnabled()) disablePushNotifications();
getPrivatePreferences().edit()
.remove("domain")
.remove("accessToken")
.remove("appAccountId")
.apply();
Intent intent = new Intent(MainActivity.this, LoginActivity.class);
startActivity(intent);
finish();
})
.setNegativeButton(android.R.string.no, null)
.show();
private boolean handleProfileClick(IProfile profile, boolean current) {
AccountEntity activeAccount = TuskyApplication.getAccountManager().getActiveAccount();
//open profile when active image was clicked
if (current && activeAccount != null) {
Intent intent = new Intent(MainActivity.this, AccountActivity.class);
intent.putExtra("id", activeAccount.getAccountId());
startActivity(intent);
return true;
}
//open LoginActivity to add new account
if(profile.getIdentifier() == DRAWER_ITEM_ADD_ACCOUNT ) {
startActivity(LoginActivity.getIntent(this, true));
return true;
}
//change Account
changeAccount(profile.getIdentifier());
return false;
}
private void fetchUserInfo() {
SharedPreferences preferences = getPrivatePreferences();
final String domain = preferences.getString("domain", null);
String id = preferences.getString("loggedInAccountId", null);
String username = preferences.getString("loggedInAccountUsername", null);
if (id != null && username != null) {
loggedInAccountId = id;
loggedInAccountUsername = username;
private void changeAccount(long newSelectedId) {
TuskyApplication.getAccountManager().setActiveAccount(newSelectedId);
Intent intent = new Intent(this, MainActivity.class);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
startActivity(intent);
finish();
overridePendingTransition(R.anim.explode, R.anim.explode);
}
private void logout() {
AccountEntity activeAccount = TuskyApplication.getAccountManager().getActiveAccount();
if(activeAccount != null) {
new AlertDialog.Builder(this)
.setTitle(R.string.action_logout)
.setMessage(getString(R.string.action_logout_confirm, activeAccount.getFullName()))
.setPositiveButton(android.R.string.yes, (dialog, which) -> {
AccountManager accountManager = TuskyApplication.getAccountManager();
NotificationManager.deleteNotificationChannelsForAccount(accountManager.getActiveAccount(), MainActivity.this);
AccountEntity newAccount = accountManager.logActiveAccountOut();
if (!accountManager.notificationsEnabled()) disablePushNotifications();
Intent intent;
if (newAccount == null) {
intent = LoginActivity.getIntent(MainActivity.this, false);
} else {
intent = new Intent(MainActivity.this, MainActivity.class);
}
startActivity(intent);
finish();
})
.setNegativeButton(android.R.string.no, null)
.show();
}
}
private void fetchUserInfo() {
mastodonApi.accountVerifyCredentials().enqueue(new Callback<Account>() {
@Override
public void onResponse(@NonNull Call<Account> call, @NonNull Response<Account> response) {
if (response.isSuccessful()) {
onFetchUserInfoSuccess(response.body(), domain);
onFetchUserInfoSuccess(response.body());
} else {
onFetchUserInfoFailure(new Exception(response.message()));
}
@ -437,22 +476,34 @@ public class MainActivity extends BaseActivity implements ActionButtonActivity {
});
}
private void onFetchUserInfoSuccess(Account me, String domain) {
private void onFetchUserInfoSuccess(Account me) {
// Add the header image and avatar from the account, into the navigation drawer header.
ImageView background = headerResult.getHeaderBackgroundView();
background.setColorFilter(ContextCompat.getColor(this, R.color.header_background_filter));
background.setBackgroundColor(ContextCompat.getColor(this, R.color.window_background_dark));
Picasso.with(MainActivity.this)
.load(me.header)
.placeholder(R.drawable.account_header_default)
.into(background);
headerResult.clear();
headerResult.addProfiles(
new ProfileDrawerItem()
.withName(me.getDisplayName())
.withEmail(String.format("%s@%s", me.username, domain))
.withIcon(me.avatar)
);
AccountManager am = TuskyApplication.getAccountManager();
am.updateActiveAccount(me);
NotificationManager.createNotificationChannelsForAccount(am.getActiveAccount(), this);
List<AccountEntity> allAccounts = am.getAllAccountsOrderedByActive();
for(AccountEntity acc: allAccounts) {
headerResult.addProfiles(