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
This commit is contained in:
parent
c9004f1d54
commit
92ae463b38
40 changed files with 1293 additions and 773 deletions
|
@ -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();
|
||||
}
|
||||
}
|
377
app/src/main/java/com/keylesspalace/tusky/LoginActivity.kt
Normal file
377
app/src/main/java/com/keylesspalace/tusky/LoginActivity.kt
Normal file
|
@ -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 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 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(R.string.action_logout_confirm)
|
||||
.setMessage(getString(R.string.action_logout_confirm, activeAccount.getFullName()))
|
||||
.setPositiveButton(android.R.string.yes, (dialog, which) -> {
|
||||
if (arePushNotificationsEnabled()) disablePushNotifications();
|
||||
|
||||
getPrivatePreferences().edit()
|
||||
.remove("domain")
|
||||
.remove("accessToken")
|
||||
.remove("appAccountId")
|
||||
.apply();
|
||||
AccountManager accountManager = TuskyApplication.getAccountManager();
|
||||
|
||||
Intent intent = new Intent(MainActivity.this, LoginActivity.class);
|
||||
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() {
|
||||
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;
|
||||
}
|
||||
|
||||
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();
|
||||
AccountManager am = TuskyApplication.getAccountManager();
|
||||
|
||||
am.updateActiveAccount(me);
|
||||
|
||||
NotificationManager.createNotificationChannelsForAccount(am.getActiveAccount(), this);
|
||||
|
||||
List<AccountEntity> allAccounts = am.getAllAccountsOrderedByActive();
|
||||
|
||||
for(AccountEntity acc: allAccounts) {
|
||||
headerResult.addProfiles(
|
||||
new ProfileDrawerItem()
|
||||
.withName(me.getDisplayName())
|
||||
.withEmail(String.format("%s@%s", me.username, domain))
|
||||
.withIcon(me.avatar)
|
||||
);
|
||||
.withName(acc.getDisplayName())
|
||||
.withIcon(acc.getProfilePictureUrl())
|
||||
.withNameShown(true)
|
||||
.withIdentifier(acc.getId())
|
||||
.withEmail(acc.getFullName()));
|
||||
|
||||
}
|
||||
|
||||
// Show follow requests in the menu, if this is a locked account.
|
||||
if (me.locked) {
|
||||
|
@ -464,14 +515,6 @@ public class MainActivity extends BaseActivity implements ActionButtonActivity {
|
|||
drawer.addItemAtPosition(followRequestsItem, 3);
|
||||
}
|
||||
|
||||
// Update the current login information.
|
||||
loggedInAccountId = me.id;
|
||||
loggedInAccountUsername = me.username;
|
||||
getPrivatePreferences().edit()
|
||||
.putString("loggedInAccountId", loggedInAccountId)
|
||||
.putString("loggedInAccountUsername", loggedInAccountUsername)
|
||||
.putBoolean("loggedInAccountLocked", me.locked)
|
||||
.apply();
|
||||
}
|
||||
|
||||
private void onFetchUserInfoFailure(Exception exception) {
|
||||
|
|
|
@ -20,23 +20,23 @@ import android.content.SharedPreferences;
|
|||
import android.support.annotation.NonNull;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.text.Spanned;
|
||||
import android.util.Log;
|
||||
|
||||
import com.evernote.android.job.Job;
|
||||
import com.evernote.android.job.JobCreator;
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.keylesspalace.tusky.db.AccountEntity;
|
||||
import com.keylesspalace.tusky.entity.Notification;
|
||||
import com.keylesspalace.tusky.json.SpannedTypeAdapter;
|
||||
import com.keylesspalace.tusky.network.AuthInterceptor;
|
||||
import com.keylesspalace.tusky.network.MastodonApi;
|
||||
import com.keylesspalace.tusky.util.NotificationManager;
|
||||
import com.keylesspalace.tusky.util.OkHttpUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.math.BigInteger;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import okhttp3.OkHttpClient;
|
||||
import retrofit2.Response;
|
||||
|
@ -49,7 +49,8 @@ import retrofit2.converter.gson.GsonConverterFactory;
|
|||
|
||||
public final class NotificationPullJobCreator implements JobCreator {
|
||||
|
||||
public static final int NOTIFY_ID = 6; // chosen by fair dice roll, guaranteed to be random
|
||||
private static final String TAG = "NotificationPJC";
|
||||
|
||||
static final String NOTIFICATIONS_JOB_TAG = "notifications_job_tag";
|
||||
|
||||
private Context context;
|
||||
|
@ -62,15 +63,7 @@ public final class NotificationPullJobCreator implements JobCreator {
|
|||
@Override
|
||||
public Job create(@NonNull String tag) {
|
||||
if (tag.equals(NOTIFICATIONS_JOB_TAG)) {
|
||||
SharedPreferences preferences = context.getSharedPreferences(
|
||||
context.getString(R.string.preferences_file_key), Context.MODE_PRIVATE);
|
||||
final String domain = preferences.getString("domain", null);
|
||||
|
||||
if(domain == null) {
|
||||
return null;
|
||||
} else {
|
||||
return new NotificationPullJob(domain, context);
|
||||
}
|
||||
return new NotificationPullJob(context);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
@ -80,7 +73,6 @@ public final class NotificationPullJobCreator implements JobCreator {
|
|||
context.getString(R.string.preferences_file_key), Context.MODE_PRIVATE);
|
||||
|
||||
OkHttpClient okHttpClient = OkHttpUtils.getCompatibleClientBuilder(preferences)
|
||||
.addInterceptor(new AuthInterceptor(context))
|
||||
.build();
|
||||
|
||||
Gson gson = new GsonBuilder()
|
||||
|
@ -98,48 +90,72 @@ public final class NotificationPullJobCreator implements JobCreator {
|
|||
|
||||
private final static class NotificationPullJob extends Job {
|
||||
|
||||
@NonNull private MastodonApi mastodonApi;
|
||||
private Context context;
|
||||
|
||||
NotificationPullJob(String domain, Context context) {
|
||||
this.mastodonApi = createMastodonApi(domain, context);
|
||||
NotificationPullJob(Context context) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
protected Result onRunJob(Params params) {
|
||||
|
||||
List<AccountEntity> accountList = new ArrayList<>(TuskyApplication.getAccountManager().getAllAccountsOrderedByActive());
|
||||
|
||||
for(AccountEntity account: accountList) {
|
||||
|
||||
if(account.getNotificationsEnabled()) {
|
||||
MastodonApi api = createMastodonApi(account.getDomain(), context);
|
||||
try {
|
||||
Log.d(TAG, "getting Notifications for "+account.getFullName());
|
||||
Response<List<Notification>> notifications =
|
||||
mastodonApi.notifications(null, null, null).execute();
|
||||
api.notificationsWithAuth(String.format("Bearer %s", account.getAccessToken())).execute();
|
||||
if (notifications.isSuccessful()) {
|
||||
onNotificationsReceived(notifications.body());
|
||||
onNotificationsReceived(account, notifications.body());
|
||||
} else {
|
||||
return Result.FAILURE;
|
||||
Log.w(TAG, "error receiving notificationsEnabled");
|
||||
}
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
return Result.FAILURE;
|
||||
Log.w(TAG, "error receiving notificationsEnabled", e);
|
||||
}
|
||||
return Result.SUCCESS;
|
||||
}
|
||||
|
||||
private void onNotificationsReceived(List<Notification> notificationList) {
|
||||
SharedPreferences notificationsPreferences = context.getSharedPreferences(
|
||||
"Notifications", Context.MODE_PRIVATE);
|
||||
//make a copy of the string set, the returned instance should not be modified
|
||||
Set<String> currentIds = new HashSet<>(notificationsPreferences.getStringSet(
|
||||
"current_ids", Collections.emptySet()));
|
||||
for (Notification notification : notificationList) {
|
||||
String id = notification.id;
|
||||
if (!currentIds.contains(id)) {
|
||||
currentIds.add(id);
|
||||
NotificationManager.make(context, NOTIFY_ID, notification);
|
||||
}
|
||||
|
||||
return Result.SUCCESS;
|
||||
|
||||
|
||||
}
|
||||
|
||||
private void onNotificationsReceived(AccountEntity account, List<Notification> notificationList) {
|
||||
|
||||
BigInteger newId = new BigInteger(account.getLastNotificationId());
|
||||
|
||||
BigInteger newestId = BigInteger.ZERO;
|
||||
|
||||
for(Notification notification: notificationList){
|
||||
|
||||
BigInteger currentId = new BigInteger(notification.id);
|
||||
|
||||
if(isBiggerThan(currentId, newestId)) {
|
||||
newestId = currentId;
|
||||
}
|
||||
|
||||
if (isBiggerThan(currentId, newId)) {
|
||||
account.setLastNotificationId(notification.id);
|
||||
|
||||
NotificationManager.make(context, notification, account);
|
||||
}
|
||||
}
|
||||
notificationsPreferences.edit()
|
||||
.putStringSet("current_ids", currentIds)
|
||||
.apply();
|
||||
|
||||
account.setLastNotificationId(newestId.toString());
|
||||
TuskyApplication.getAccountManager().saveAccount(account);
|
||||
|
||||
}
|
||||
|
||||
private boolean isBiggerThan(BigInteger newId, BigInteger lastShownNotificationId) {
|
||||
|
||||
return lastShownNotificationId.compareTo(newId) == - 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,12 +15,13 @@
|
|||
|
||||
package com.keylesspalace.tusky;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.os.Bundle;
|
||||
import android.support.v7.app.AppCompatActivity;
|
||||
|
||||
import com.keylesspalace.tusky.db.AccountEntity;
|
||||
import com.keylesspalace.tusky.util.NotificationManager;
|
||||
|
||||
public class SplashActivity extends AppCompatActivity {
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
|
@ -28,16 +29,16 @@ public class SplashActivity extends AppCompatActivity {
|
|||
|
||||
/* Determine whether the user is currently logged in, and if so go ahead and load the
|
||||
* timeline. Otherwise, start the activity_login screen. */
|
||||
SharedPreferences preferences = getSharedPreferences(
|
||||
getString(R.string.preferences_file_key), Context.MODE_PRIVATE);
|
||||
String domain = preferences.getString("domain", null);
|
||||
String accessToken = preferences.getString("accessToken", null);
|
||||
|
||||
NotificationManager.deleteLegacyNotificationChannels(this);
|
||||
|
||||
AccountEntity activeAccount = TuskyApplication.getAccountManager().getActiveAccount();
|
||||
|
||||
Intent intent;
|
||||
if (domain != null && accessToken != null) {
|
||||
if (activeAccount != null) {
|
||||
intent = new Intent(this, MainActivity.class);
|
||||
} else {
|
||||
intent = new Intent(this, LoginActivity.class);
|
||||
intent = LoginActivity.getIntent(this, false);
|
||||
}
|
||||
startActivity(intent);
|
||||
finish();
|
||||
|
|
|
@ -25,6 +25,7 @@ import android.support.v7.app.AppCompatDelegate;
|
|||
|
||||
import com.evernote.android.job.JobManager;
|
||||
import com.jakewharton.picasso.OkHttp3Downloader;
|
||||
import com.keylesspalace.tusky.db.AccountManager;
|
||||
import com.keylesspalace.tusky.db.AppDatabase;
|
||||
import com.keylesspalace.tusky.util.OkHttpUtils;
|
||||
import com.squareup.picasso.Picasso;
|
||||
|
@ -33,6 +34,7 @@ public class TuskyApplication extends Application {
|
|||
public static final String APP_THEME_DEFAULT = "AppTheme:prefer:night";
|
||||
|
||||
private static AppDatabase db;
|
||||
private static AccountManager accountManager;
|
||||
|
||||
public static AppDatabase getDB() {
|
||||
return db;
|
||||
|
@ -71,5 +73,12 @@ public class TuskyApplication extends Application {
|
|||
|
||||
//necessary for Android < APi 21
|
||||
AppCompatDelegate.setCompatVectorFromResourcesEnabled(true);
|
||||
|
||||
accountManager = new AccountManager();
|
||||
}
|
||||
|
||||
public static AccountManager getAccountManager() {
|
||||
return accountManager;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
/* Copyright 2017 Andrew Dawson
|
||||
/* Copyright 2018 Conny Duck
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
|
@ -13,19 +13,19 @@
|
|||
* 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;
|
||||
package com.keylesspalace.tusky.db
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.arch.persistence.room.*
|
||||
|
||||
@Dao
|
||||
interface AccountDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun insertOrReplace(account: AccountEntity): Long
|
||||
|
||||
@Delete
|
||||
fun delete(account: AccountEntity)
|
||||
|
||||
@Query("SELECT * FROM AccountEntity ORDER BY id ASC")
|
||||
fun loadAll(): List<AccountEntity>
|
||||
|
||||
public class NotificationClearBroadcastReceiver extends BroadcastReceiver {
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
SharedPreferences notificationPreferences = context.getSharedPreferences("Notifications", Context.MODE_PRIVATE);
|
||||
SharedPreferences.Editor editor = notificationPreferences.edit();
|
||||
editor.putString("current", "[]");
|
||||
editor.apply();
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
190
app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt
Normal file
190
app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt
Normal file
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -25,10 +25,11 @@ import android.support.annotation.NonNull;
|
|||
* DB version & declare DAO
|
||||
*/
|
||||
|
||||
@Database(entities = {TootEntity.class}, version = 4, exportSchema = false)
|
||||
@Database(entities = {TootEntity.class, AccountEntity.class}, version = 4, exportSchema = false)
|
||||
public abstract class AppDatabase extends RoomDatabase {
|
||||
|
||||
public abstract TootDao tootDao();
|
||||
public abstract AccountDao accountDao();
|
||||
|
||||
public static final Migration MIGRATION_2_3 = new Migration(2, 3) {
|
||||
@Override
|
||||
|
|
|
@ -37,9 +37,12 @@ import android.view.View;
|
|||
import android.view.ViewGroup;
|
||||
|
||||
import com.keylesspalace.tusky.MainActivity;
|
||||
import com.keylesspalace.tusky.TuskyApplication;
|
||||
import com.keylesspalace.tusky.adapter.FooterViewHolder;
|
||||
import com.keylesspalace.tusky.adapter.NotificationsAdapter;
|
||||
import com.keylesspalace.tusky.R;
|
||||
import com.keylesspalace.tusky.db.AccountEntity;
|
||||
import com.keylesspalace.tusky.db.AccountManager;
|
||||
import com.keylesspalace.tusky.entity.Attachment;
|
||||
import com.keylesspalace.tusky.entity.Notification;
|
||||
import com.keylesspalace.tusky.entity.Status;
|
||||
|
@ -57,6 +60,7 @@ import com.keylesspalace.tusky.view.EndlessOnScrollListener;
|
|||
import com.keylesspalace.tusky.viewdata.NotificationViewData;
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
|
||||
|
@ -79,7 +83,7 @@ public class NotificationsFragment extends SFragment implements
|
|||
}
|
||||
|
||||
/**
|
||||
* Placeholder for the notifications. Consider moving to the separate class to hide constructor
|
||||
* Placeholder for the notificationsEnabled. Consider moving to the separate class to hide constructor
|
||||
* and reuse in different places as needed.
|
||||
*/
|
||||
private static final class Placeholder {
|
||||
|
@ -200,7 +204,7 @@ public class NotificationsFragment extends SFragment implements
|
|||
|
||||
/* This is delayed until onActivityCreated solely because MainActivity.composeButton isn't
|
||||
* guaranteed to be set until then.
|
||||
* Use a modified scroll listener that both loads more notifications as it goes, and hides
|
||||
* Use a modified scroll listener that both loads more notificationsEnabled as it goes, and hides
|
||||
* the compose button on down-scroll. */
|
||||
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(activity);
|
||||
preferences.registerOnSharedPreferenceChangeListener(this);
|
||||
|
@ -552,14 +556,13 @@ public class NotificationsFragment extends SFragment implements
|
|||
}
|
||||
update(notifications, fromId, uptoId);
|
||||
}
|
||||
/* Set last update id for pull notifications so that we don't get notified
|
||||
* about things we already loaded here */
|
||||
getPrivatePreferences().edit()
|
||||
.putString("lastUpdateId", fromId)
|
||||
.apply();
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
saveNewestNotificationId(notifications);
|
||||
|
||||
fulfillAnyQueuedFetches(fetchEnd);
|
||||
if (notifications.size() == 0 && adapter.getItemCount() == 1) {
|
||||
adapter.setFooterState(FooterViewHolder.State.EMPTY);
|
||||
|
@ -581,6 +584,29 @@ public class NotificationsFragment extends SFragment implements
|
|||
fulfillAnyQueuedFetches(fetchEnd);
|
||||
}
|
||||
|
||||
private void saveNewestNotificationId(List<Notification> notifications) {
|
||||
AccountManager accountManager = TuskyApplication.getAccountManager();
|
||||
AccountEntity account = accountManager.getActiveAccount();
|
||||
BigInteger lastNoti = new BigInteger(account.getLastNotificationId());
|
||||
|
||||
for (Notification noti: notifications) {
|
||||
BigInteger a = new BigInteger(noti.id);
|
||||
if(isBiggerThan(a, lastNoti)) {
|
||||
lastNoti = a;
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(TAG, "saving newest noti id: " + lastNoti);
|
||||
|
||||
account.setLastNotificationId(lastNoti.toString());
|
||||
accountManager.saveAccount(account);
|
||||
}
|
||||
|
||||
private boolean isBiggerThan(BigInteger newId, BigInteger lastShownNotificationId) {
|
||||
|
||||
return lastShownNotificationId.compareTo(newId) == - 1;
|
||||
}
|
||||
|
||||
private void update(@Nullable List<Notification> newNotifications, @Nullable String fromId,
|
||||
@Nullable String uptoId) {
|
||||
if (ListUtils.isEmpty(newNotifications)) {
|
||||
|
|
|
@ -19,6 +19,7 @@ import android.content.Intent;
|
|||
import android.content.SharedPreferences;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.preference.CheckBoxPreference;
|
||||
import android.preference.EditTextPreference;
|
||||
import android.preference.Preference;
|
||||
import android.preference.PreferenceFragment;
|
||||
|
@ -27,7 +28,8 @@ import android.support.annotation.XmlRes;
|
|||
import com.keylesspalace.tusky.BuildConfig;
|
||||
import com.keylesspalace.tusky.PreferencesActivity;
|
||||
import com.keylesspalace.tusky.R;
|
||||
import com.keylesspalace.tusky.util.NotificationManager;
|
||||
import com.keylesspalace.tusky.TuskyApplication;
|
||||
import com.keylesspalace.tusky.db.AccountEntity;
|
||||
|
||||
public class PreferencesFragment extends PreferenceFragment implements SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
SharedPreferences sharedPreferences;
|
||||
|
@ -58,11 +60,16 @@ public class PreferencesFragment extends PreferenceFragment implements SharedPre
|
|||
|
||||
if(notificationPreferences != null) {
|
||||
|
||||
AccountEntity activeAccount = TuskyApplication.getAccountManager().getActiveAccount();
|
||||
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O && activeAccount != null) {
|
||||
notificationPreferences.setSummary(getString(R.string.pref_summary_notifications, activeAccount.getFullName()));
|
||||
}
|
||||
|
||||
|
||||
//on Android O and newer, launch the system notification settings instead of the app settings
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
|
||||
NotificationManager.createNotificationChannels(getContext());
|
||||
|
||||
notificationPreferences.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() {
|
||||
@Override
|
||||
public boolean onPreferenceClick(Preference preference) {
|
||||
|
@ -122,6 +129,38 @@ public class PreferencesFragment extends PreferenceFragment implements SharedPre
|
|||
});
|
||||
}
|
||||
|
||||
if(preference == R.xml.notification_preferences) {
|
||||
|
||||
AccountEntity activeAccount = TuskyApplication.getAccountManager().getActiveAccount();
|
||||
|
||||
if(activeAccount != null) {
|
||||
|
||||
CheckBoxPreference notificationPref = (CheckBoxPreference) findPreference("notificationsEnabled");
|
||||
notificationPref.setChecked(activeAccount.getNotificationsEnabled());
|
||||
|
||||
CheckBoxPreference mentionedPref = (CheckBoxPreference) findPreference("notificationFilterMentions");
|
||||
mentionedPref.setChecked(activeAccount.getNotificationsMentioned());
|
||||
|
||||
CheckBoxPreference followedPref = (CheckBoxPreference) findPreference("notificationFilterFollows");
|
||||
followedPref.setChecked(activeAccount.getNotificationsFollowed());
|
||||
|
||||
CheckBoxPreference boostedPref = (CheckBoxPreference) findPreference("notificationFilterReblogs");
|
||||
boostedPref.setChecked(activeAccount.getNotificationsReblogged());
|
||||
|
||||
CheckBoxPreference favoritedPref = (CheckBoxPreference) findPreference("notificationFilterFavourites");
|
||||
favoritedPref.setChecked(activeAccount.getNotificationsFavorited());
|
||||
|
||||
CheckBoxPreference soundPref = (CheckBoxPreference) findPreference("notificationAlertSound");
|
||||
soundPref.setChecked(activeAccount.getNotificationSound());
|
||||
|
||||
CheckBoxPreference vibrationPref = (CheckBoxPreference) findPreference("notificationAlertVibrate");
|
||||
vibrationPref.setChecked(activeAccount.getNotificationVibration());
|
||||
|
||||
CheckBoxPreference lightPref = (CheckBoxPreference) findPreference("notificationAlertLight");
|
||||
lightPref.setChecked(activeAccount.getNotificationLight());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -150,15 +189,50 @@ public class PreferencesFragment extends PreferenceFragment implements SharedPre
|
|||
@Override
|
||||
public void onSharedPreferenceChanged(SharedPreferences sharedPreferences,
|
||||
String key) {
|
||||
|
||||
switch (key) {
|
||||
case "httpProxyServer":
|
||||
case "httpProxyPort":
|
||||
updateSummary(key);
|
||||
case "httpProxyEnabled":
|
||||
httpProxyChanged = true;
|
||||
break;
|
||||
return;
|
||||
default:
|
||||
}
|
||||
|
||||
AccountEntity activeAccount = TuskyApplication.getAccountManager().getActiveAccount();
|
||||
|
||||
if(activeAccount != null) {
|
||||
switch(key) {
|
||||
case "notificationsEnabled":
|
||||
activeAccount.setNotificationsEnabled(sharedPreferences.getBoolean(key, true));
|
||||
break;
|
||||
case "notificationFilterMentions":
|
||||
activeAccount.setNotificationsMentioned(sharedPreferences.getBoolean(key, true));
|
||||
break;
|
||||
case "notificationFilterFollows":
|
||||
activeAccount.setNotificationsFollowed(sharedPreferences.getBoolean(key, true));
|
||||
break;
|
||||
case "notificationFilterReblogs":
|
||||
activeAccount.setNotificationsReblogged(sharedPreferences.getBoolean(key, true));
|
||||
break;
|
||||
case "notificationFilterFavourites":
|
||||
activeAccount.setNotificationsFavorited(sharedPreferences.getBoolean(key, true));
|
||||
break;
|
||||
case "notificationAlertSound":
|
||||
activeAccount.setNotificationSound(sharedPreferences.getBoolean(key, true));
|
||||
break;
|
||||
case "notificationAlertVibrate":
|
||||
activeAccount.setNotificationVibration(sharedPreferences.getBoolean(key, true));
|
||||
break;
|
||||
case "notificationAlertLight":
|
||||
activeAccount.setNotificationLight(sharedPreferences.getBoolean(key, true));
|
||||
break;
|
||||
}
|
||||
TuskyApplication.getAccountManager().saveAccount(activeAccount);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private void updateSummary(String key) {
|
||||
|
|
|
@ -19,7 +19,6 @@ import android.content.ClipData;
|
|||
import android.content.ClipboardManager;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.os.Bundle;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.support.annotation.Nullable;
|
||||
|
@ -36,10 +35,12 @@ import com.keylesspalace.tusky.BaseActivity;
|
|||
import com.keylesspalace.tusky.ComposeActivity;
|
||||
import com.keylesspalace.tusky.R;
|
||||
import com.keylesspalace.tusky.ReportActivity;
|
||||
import com.keylesspalace.tusky.TuskyApplication;
|
||||
import com.keylesspalace.tusky.ViewMediaActivity;
|
||||
import com.keylesspalace.tusky.ViewTagActivity;
|
||||
import com.keylesspalace.tusky.ViewThreadActivity;
|
||||
import com.keylesspalace.tusky.ViewVideoActivity;
|
||||
import com.keylesspalace.tusky.db.AccountEntity;
|
||||
import com.keylesspalace.tusky.entity.Attachment;
|
||||
import com.keylesspalace.tusky.entity.Relationship;
|
||||
import com.keylesspalace.tusky.entity.Status;
|
||||
|
@ -73,9 +74,11 @@ public abstract class SFragment extends BaseFragment implements AdapterItemRemov
|
|||
public void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
SharedPreferences preferences = getPrivatePreferences();
|
||||
loggedInAccountId = preferences.getString("loggedInAccountId", null);
|
||||
loggedInUsername = preferences.getString("loggedInAccountUsername", null);
|
||||
AccountEntity activeAccount = TuskyApplication.getAccountManager().getActiveAccount();
|
||||
if(activeAccount != null) {
|
||||
loggedInAccountId = activeAccount.getAccountId();
|
||||
loggedInUsername = activeAccount.getUsername();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
@ -1,11 +1,9 @@
|
|||
package com.keylesspalace.tusky.network;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.support.annotation.Nullable;
|
||||
|
||||
import com.keylesspalace.tusky.R;
|
||||
import com.keylesspalace.tusky.TuskyApplication;
|
||||
import com.keylesspalace.tusky.db.AccountEntity;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
|
@ -17,37 +15,24 @@ import okhttp3.Response;
|
|||
* Created by charlag on 31/10/17.
|
||||
*/
|
||||
|
||||
public final class AuthInterceptor implements Interceptor, SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
public final class AuthInterceptor implements Interceptor {
|
||||
|
||||
private static final String TOKEN_KEY = "accessToken";
|
||||
|
||||
@Nullable
|
||||
private String token;
|
||||
|
||||
public AuthInterceptor(Context context) {
|
||||
SharedPreferences preferences = context.getSharedPreferences(
|
||||
context.getString(R.string.preferences_file_key), Context.MODE_PRIVATE);
|
||||
token = preferences.getString(TOKEN_KEY, null);
|
||||
preferences.registerOnSharedPreferenceChangeListener(this);
|
||||
}
|
||||
public AuthInterceptor() { }
|
||||
|
||||
@Override
|
||||
public Response intercept(@NonNull Chain chain) throws IOException {
|
||||
|
||||
AccountEntity currentAccount = TuskyApplication.getAccountManager().getActiveAccount();
|
||||
|
||||
Request originalRequest = chain.request();
|
||||
|
||||
Request.Builder builder = originalRequest.newBuilder();
|
||||
if (token != null) {
|
||||
builder.header("Authorization", String.format("Bearer %s", token));
|
||||
if (currentAccount != null) {
|
||||
builder.header("Authorization", String.format("Bearer %s", currentAccount.getAccessToken()));
|
||||
}
|
||||
Request newRequest = builder.build();
|
||||
|
||||
return chain.proceed(newRequest);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
|
||||
if (key.equals(TOKEN_KEY)) {
|
||||
token = sharedPreferences.getString(TOKEN_KEY, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -40,6 +40,7 @@ import retrofit2.http.DELETE;
|
|||
import retrofit2.http.Field;
|
||||
import retrofit2.http.FormUrlEncoded;
|
||||
import retrofit2.http.GET;
|
||||
import retrofit2.http.Header;
|
||||
import retrofit2.http.Multipart;
|
||||
import retrofit2.http.PATCH;
|
||||
import retrofit2.http.POST;
|
||||
|
@ -81,6 +82,9 @@ public interface MastodonApi {
|
|||
@Query("max_id") String maxId,
|
||||
@Query("since_id") String sinceId,
|
||||
@Query("limit") Integer limit);
|
||||
@GET("api/v1/notifications")
|
||||
Call<List<Notification>> notificationsWithAuth(
|
||||
@Header("Authorization") String auth);
|
||||
@POST("api/v1/notifications/clear")
|
||||
Call<ResponseBody> clearNotifications();
|
||||
@GET("api/v1/notifications/{id}")
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -16,14 +16,13 @@
|
|||
package com.keylesspalace.tusky.util;
|
||||
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.NotificationChannelGroup;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.BitmapFactory;
|
||||
import android.os.Build;
|
||||
import android.preference.PreferenceManager;
|
||||
import android.provider.Settings;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.support.v4.app.NotificationCompat;
|
||||
|
@ -32,8 +31,10 @@ import android.support.v4.content.ContextCompat;
|
|||
import android.util.Log;
|
||||
|
||||
import com.keylesspalace.tusky.MainActivity;
|
||||
import com.keylesspalace.tusky.NotificationPullJobCreator;
|
||||
import com.keylesspalace.tusky.R;
|
||||
import com.keylesspalace.tusky.TuskyApplication;
|
||||
import com.keylesspalace.tusky.db.AccountEntity;
|
||||
import com.keylesspalace.tusky.db.AccountManager;
|
||||
import com.keylesspalace.tusky.entity.Notification;
|
||||
import com.keylesspalace.tusky.receiver.NotificationClearBroadcastReceiver;
|
||||
import com.keylesspalace.tusky.view.RoundedTransformation;
|
||||
|
@ -47,11 +48,13 @@ import java.util.ArrayList;
|
|||
import java.util.List;
|
||||
|
||||
public class NotificationManager {
|
||||
|
||||
/** constants used in Intents */
|
||||
public static final String ACCOUNT_ID = "account_id";
|
||||
|
||||
private static final String TAG = "NotificationManager";
|
||||
|
||||
/**
|
||||
* notification channels used on Android O+
|
||||
**/
|
||||
/** notification channels used on Android O+ **/
|
||||
private static final String CHANNEL_MENTION = "CHANNEL_MENTION";
|
||||
private static final String CHANNEL_FOLLOW = "CHANNEL_FOLLOW";
|
||||
private static final String CHANNEL_BOOST = "CHANNEL_BOOST";
|
||||
|
@ -62,22 +65,17 @@ public class NotificationManager {
|
|||
* the state of the existing notification to reflect the new interaction.
|
||||
*
|
||||
* @param context to access application preferences and services
|
||||
* @param notifyId an arbitrary number to reference this notification for any future action
|
||||
* @param body a new Mastodon notification
|
||||
* @param account the account for which the notification should be shown
|
||||
*/
|
||||
public static void make(final Context context, final int notifyId, Notification body) {
|
||||
final SharedPreferences preferences =
|
||||
PreferenceManager.getDefaultSharedPreferences(context);
|
||||
final SharedPreferences notificationPreferences = context.getSharedPreferences(
|
||||
"Notifications", Context.MODE_PRIVATE);
|
||||
|
||||
if (!filterNotification(preferences, body)) {
|
||||
public static void make(final Context context, Notification body, AccountEntity account) {
|
||||
|
||||
if (!filterNotification(account, body)) {
|
||||
return;
|
||||
}
|
||||
|
||||
createNotificationChannels(context);
|
||||
|
||||
String rawCurrentNotifications = notificationPreferences.getString("current", "[]");
|
||||
String rawCurrentNotifications = account.getActiveNotifications();
|
||||
JSONArray currentNotifications;
|
||||
|
||||
try {
|
||||
|
@ -102,34 +100,40 @@ public class NotificationManager {
|
|||
currentNotifications.put(body.account.getDisplayName());
|
||||
}
|
||||
|
||||
notificationPreferences.edit()
|
||||
.putString("current", currentNotifications.toString())
|
||||
.apply();
|
||||
account.setActiveNotifications(currentNotifications.toString());
|
||||
|
||||
//no need to save account, this will be done in the calling function
|
||||
|
||||
Intent resultIntent = new Intent(context, MainActivity.class);
|
||||
resultIntent.putExtra("tab_position", 1);
|
||||
resultIntent.putExtra(ACCOUNT_ID, account.getId());
|
||||
TaskStackBuilder stackBuilder = TaskStackBuilder.create(context);
|
||||
stackBuilder.addParentStack(MainActivity.class);
|
||||
stackBuilder.addNextIntent(resultIntent);
|
||||
PendingIntent resultPendingIntent = stackBuilder.getPendingIntent(0,
|
||||
PendingIntent resultPendingIntent = stackBuilder.getPendingIntent((int)account.getId(),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT);
|
||||
|
||||
Intent deleteIntent = new Intent(context, NotificationClearBroadcastReceiver.class);
|
||||
PendingIntent deletePendingIntent = PendingIntent.getBroadcast(context, 0, deleteIntent,
|
||||
PendingIntent.FLAG_CANCEL_CURRENT);
|
||||
deleteIntent.putExtra(ACCOUNT_ID, account.getId());
|
||||
PendingIntent deletePendingIntent = PendingIntent.getBroadcast(context, (int)account.getId(), deleteIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT);
|
||||
|
||||
final NotificationCompat.Builder builder = new NotificationCompat.Builder(context, getChannelId(body))
|
||||
final NotificationCompat.Builder builder = new NotificationCompat.Builder(context, getChannelId(account, body))
|
||||
.setSmallIcon(R.drawable.ic_notify)
|
||||
.setContentIntent(resultPendingIntent)
|
||||
.setDeleteIntent(deletePendingIntent)
|
||||
.setColor(ContextCompat.getColor(context, (R.color.primary)))
|
||||
.setDefaults(0); // So it doesn't ring twice, notify only in Target callback
|
||||
|
||||
setupPreferences(preferences, builder);
|
||||
setupPreferences(account, builder);
|
||||
|
||||
if (currentNotifications.length() == 1) {
|
||||
builder.setContentTitle(titleForType(context, body))
|
||||
.setContentText(truncateWithEllipses(bodyForType(body), 40));
|
||||
.setContentText(bodyForType(body));
|
||||
|
||||
if(body.type == Notification.Type.MENTION) {
|
||||
builder.setStyle(new NotificationCompat.BigTextStyle()
|
||||
.bigText(bodyForType(body)));
|
||||
}
|
||||
|
||||
//load the avatar synchronously
|
||||
Bitmap accountAvatar;
|
||||
|
@ -149,7 +153,7 @@ public class NotificationManager {
|
|||
try {
|
||||
String format = context.getString(R.string.notification_title_summary);
|
||||
String title = String.format(format, currentNotifications.length());
|
||||
String text = truncateWithEllipses(joinNames(context, currentNotifications), 40);
|
||||
String text = joinNames(context, currentNotifications);
|
||||
builder.setContentTitle(title)
|
||||
.setContentText(text);
|
||||
} catch (JSONException e) {
|
||||
|
@ -157,24 +161,29 @@ public class NotificationManager {
|
|||
}
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
builder.setVisibility(android.app.Notification.VISIBILITY_PRIVATE);
|
||||
builder.setCategory(android.app.Notification.CATEGORY_SOCIAL);
|
||||
}
|
||||
builder.setSubText(account.getFullName());
|
||||
|
||||
builder.setVisibility(NotificationCompat.VISIBILITY_PRIVATE);
|
||||
builder.setCategory(NotificationCompat.CATEGORY_SOCIAL);
|
||||
|
||||
android.app.NotificationManager notificationManager = (android.app.NotificationManager)
|
||||
context.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
|
||||
//noinspection ConstantConditions
|
||||
notificationManager.notify(notifyId, builder.build());
|
||||
notificationManager.notify((int)account.getId(), builder.build());
|
||||
}
|
||||
|
||||
public static void createNotificationChannels(Context context) {
|
||||
public static void createNotificationChannelsForAccount(AccountEntity account, Context context) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
|
||||
android.app.NotificationManager mNotificationManager =
|
||||
(android.app.NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
|
||||
String[] channelIds = new String[]{CHANNEL_MENTION, CHANNEL_FOLLOW, CHANNEL_BOOST, CHANNEL_FAVOURITE};
|
||||
String[] channelIds = new String[]{
|
||||
CHANNEL_MENTION+account.getIdentifier(),
|
||||
CHANNEL_FOLLOW+account.getIdentifier(),
|
||||
CHANNEL_BOOST+account.getIdentifier(),
|
||||
CHANNEL_FAVOURITE+account.getIdentifier()};
|
||||
int[] channelNames = {
|
||||
R.string.notification_channel_mention_name,
|
||||
R.string.notification_channel_follow_name,
|
||||
|
@ -190,6 +199,11 @@ public class NotificationManager {
|
|||
|
||||
List<NotificationChannel> channels = new ArrayList<>(4);
|
||||
|
||||
NotificationChannelGroup channelGroup = new NotificationChannelGroup(account.getIdentifier(), account.getFullName());
|
||||
|
||||
//noinspection ConstantConditions
|
||||
mNotificationManager.createNotificationChannelGroup(channelGroup);
|
||||
|
||||
for (int i = 0; i < channelIds.length; i++) {
|
||||
String id = channelIds[i];
|
||||
String name = context.getString(channelNames[i]);
|
||||
|
@ -201,6 +215,7 @@ public class NotificationManager {
|
|||
channel.enableLights(true);
|
||||
channel.enableVibration(true);
|
||||
channel.setShowBadge(true);
|
||||
channel.setGroup(account.getIdentifier());
|
||||
channels.add(channel);
|
||||
}
|
||||
|
||||
|
@ -210,20 +225,48 @@ public class NotificationManager {
|
|||
}
|
||||
}
|
||||
|
||||
public static void clearNotifications(@Nullable Context context) {
|
||||
if(context != null) {
|
||||
SharedPreferences notificationPreferences =
|
||||
context.getSharedPreferences("Notifications", Context.MODE_PRIVATE);
|
||||
notificationPreferences.edit().putString("current", "[]").apply();
|
||||
public static void deleteNotificationChannelsForAccount(AccountEntity account, Context context) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
|
||||
android.app.NotificationManager mNotificationManager =
|
||||
(android.app.NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
|
||||
//noinspection ConstantConditions
|
||||
mNotificationManager.deleteNotificationChannelGroup(account.getIdentifier());
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
public static void deleteLegacyNotificationChannels(Context context) {
|
||||
// delete the notification channels that where used before the multi account mode was introduced to avoid confusion
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
|
||||
android.app.NotificationManager mNotificationManager =
|
||||
(android.app.NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
|
||||
//noinspection ConstantConditions
|
||||
mNotificationManager.deleteNotificationChannel(CHANNEL_MENTION);
|
||||
mNotificationManager.deleteNotificationChannel(CHANNEL_FAVOURITE);
|
||||
mNotificationManager.deleteNotificationChannel(CHANNEL_BOOST);
|
||||
mNotificationManager.deleteNotificationChannel(CHANNEL_FOLLOW);
|
||||
}
|
||||
}
|
||||
|
||||
public static void clearNotificationsForActiveAccount(Context context) {
|
||||
AccountManager accountManager = TuskyApplication.getAccountManager();
|
||||
AccountEntity account = accountManager.getActiveAccount();
|
||||
if (account != null) {
|
||||
account.setActiveNotifications("[]");
|
||||
accountManager.saveAccount(account);
|
||||
|
||||
android.app.NotificationManager manager = (android.app.NotificationManager)
|
||||
context.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
//noinspection ConstantConditions
|
||||
manager.cancel(NotificationPullJobCreator.NOTIFY_ID);
|
||||
manager.cancel((int)account.getId());
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean filterNotification(SharedPreferences preferences,
|
||||
private static boolean filterNotification(AccountEntity account,
|
||||
Notification notification) {
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
|
@ -233,56 +276,47 @@ public class NotificationManager {
|
|||
switch (notification.type) {
|
||||
default:
|
||||
case MENTION:
|
||||
return preferences.getBoolean("notificationFilterMentions", true);
|
||||
return account.getNotificationsMentioned();
|
||||
case FOLLOW:
|
||||
return preferences.getBoolean("notificationFilterFollows", true);
|
||||
return account.getNotificationsFollowed();
|
||||
case REBLOG:
|
||||
return preferences.getBoolean("notificationFilterReblogs", true);
|
||||
return account.getNotificationsReblogged();
|
||||
case FAVOURITE:
|
||||
return preferences.getBoolean("notificationFilterFavourites", true);
|
||||
return account.getNotificationsFavorited();
|
||||
}
|
||||
}
|
||||
|
||||
private static String getChannelId(Notification notification) {
|
||||
private static String getChannelId(AccountEntity account, Notification notification) {
|
||||
switch (notification.type) {
|
||||
default:
|
||||
case MENTION:
|
||||
return CHANNEL_MENTION;
|
||||
return CHANNEL_MENTION+account.getIdentifier();
|
||||
case FOLLOW:
|
||||
return CHANNEL_FOLLOW;
|
||||
return CHANNEL_FOLLOW+account.getIdentifier();
|
||||
case REBLOG:
|
||||
return CHANNEL_BOOST;
|
||||
return CHANNEL_BOOST+account.getIdentifier();
|
||||
case FAVOURITE:
|
||||
return CHANNEL_FAVOURITE;
|
||||
return CHANNEL_FAVOURITE+account.getIdentifier();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@SuppressWarnings("SameParameterValue")
|
||||
private static String truncateWithEllipses(String string, int limit) {
|
||||
if (string.length() < limit) {
|
||||
return string;
|
||||
} else {
|
||||
return string.substring(0, limit - 3) + "...";
|
||||
}
|
||||
}
|
||||
|
||||
private static void setupPreferences(SharedPreferences preferences,
|
||||
private static void setupPreferences(AccountEntity account,
|
||||
NotificationCompat.Builder builder) {
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
return; //do nothing on Android O or newer, the system uses the channel settings anyway
|
||||
}
|
||||
|
||||
if (preferences.getBoolean("notificationAlertSound", true)) {
|
||||
if (account.getNotificationSound()) {
|
||||
builder.setSound(Settings.System.DEFAULT_NOTIFICATION_URI);
|
||||
}
|
||||
|
||||
if (preferences.getBoolean("notificationAlertVibrate", false)) {
|
||||
if (account.getNotificationVibration()) {
|
||||
builder.setVibrate(new long[]{500, 500});
|
||||
}
|
||||
|
||||
if (preferences.getBoolean("notificationAlertLight", false)) {
|
||||
if (account.getNotificationLight()) {
|
||||
builder.setLights(0xFF00FF8F, 300, 1000);
|
||||
}
|
||||
}
|
||||
|
@ -326,7 +360,7 @@ public class NotificationManager {
|
|||
private static String bodyForType(Notification notification) {
|
||||
switch (notification.type) {
|
||||
case FOLLOW:
|
||||
return notification.account.username;
|
||||
return "@"+notification.account.username;
|
||||
case MENTION:
|
||||
case FAVOURITE:
|
||||
case REBLOG:
|
||||
|
|
13
app/src/main/res/anim/explode.xml
Normal file
13
app/src/main/res/anim/explode.xml
Normal file
|
@ -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>
|
BIN
app/src/main/res/drawable-xxhdpi/ic_reblog_dark_.png
Normal file
BIN
app/src/main/res/drawable-xxhdpi/ic_reblog_dark_.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.3 KiB |
|
@ -16,7 +16,16 @@
|
|||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:background="@android:color/transparent" />
|
||||
android:background="@android:color/transparent">
|
||||
<ImageView
|
||||
android:id="@+id/composeAvatar"
|
||||
android:padding="8dp"
|
||||
android:layout_gravity="right|end"
|
||||
android:layout_width="?attr/actionBarSize"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
tools:ignore="ContentDescription" />
|
||||
<!--content description will be set in code -->
|
||||
</android.support.v7.widget.Toolbar>
|
||||
|
||||
<TextView
|
||||
android:textSize="?attr/status_text_small"
|
||||
|
|
|
@ -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:layout_height="match_parent"
|
||||
android:fillViewport="true"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:layout_height="wrap_content">
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="147dp"
|
||||
android:layout_height="160dp"
|
||||
android:layout_marginBottom="50dp"
|
||||
android:src="@drawable/elephant_friend"
|
||||
android:contentDescription="@null" />
|
||||
android:contentDescription="@null"
|
||||
android:src="@drawable/elephant_friend" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/login_input"
|
||||
android:id="@+id/loginInputLayout"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<android.support.design.widget.TextInputLayout
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_width="250dp">
|
||||
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:inputType="textUri"
|
||||
android:hint="@string/hint_domain"
|
||||
android:ems="10"
|
||||
android:id="@+id/edit_text_domain" />
|
||||
android:hint="@string/hint_domain"
|
||||
android:inputType="textUri" />
|
||||
</android.support.design.widget.TextInputLayout>
|
||||
|
||||
<Button
|
||||
android:id="@+id/button_login"
|
||||
android:id="@+id/loginButton"
|
||||
android:layout_width="250dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:textColor="@android:color/white"
|
||||
android:text="@string/action_login" />
|
||||
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:visibility="gone"
|
||||
android:id="@+id/text_error" />
|
||||
|
||||
<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" />
|
||||
android:paddingTop="6dp"
|
||||
android:text="@string/link_whats_an_instance"
|
||||
android:textAlignment="center" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/login_loading"
|
||||
android:visibility="gone"
|
||||
android:id="@+id/loginLoadingLayout"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content">
|
||||
android:visibility="gone">
|
||||
|
||||
<ProgressBar
|
||||
android:layout_gravity="center"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content" />
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center" />
|
||||
|
||||
<TextView
|
||||
android:paddingTop="10dp"
|
||||
android:textAlignment="center"
|
||||
android:layout_width="250dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/login_connection"/>
|
||||
android:paddingTop="10dp"
|
||||
android:text="@string/login_connection"
|
||||
android:textAlignment="center" />
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
</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" />
|
||||
|
||||
</android.support.constraint.ConstraintLayout>
|
|
@ -1,5 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
* This is the for follow notifications, the layout for the follows/following listings on account
|
||||
* This is the for folnotificationsEnabledions, the layout for the follows/following listings on account
|
||||
* pages are instead in item_account.xml.
|
||||
-->
|
||||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?><!--This applies only to favourite and reblog notifications.-->
|
||||
<?xml version="1.0" encoding="utf-8"?><!--This applies only to favourite and rebnotificationsEnabledions.-->
|
||||
<RelativeLayout 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"
|
||||
|
|
|
@ -55,7 +55,6 @@
|
|||
<string name="action_compose">حرر</string>
|
||||
<string name="action_login">التسجيل بواسطة ماستدون</string>
|
||||
<string name="action_logout">خروج</string>
|
||||
<string name="action_logout_confirm">هل تود تسجيل الخروج ؟</string>
|
||||
<string name="action_follow">إتبع</string>
|
||||
<string name="action_unfollow">إلغاء التتبع</string>
|
||||
<string name="action_block">قم بحظره</string>
|
||||
|
|
|
@ -60,7 +60,6 @@
|
|||
<string name="action_compose">Redacta</string>
|
||||
<string name="action_login">Inicia sessió amb Mastodon</string>
|
||||
<string name="action_logout">Tanca sessió</string>
|
||||
<string name="action_logout_confirm">Vols tancar la sessió?</string>
|
||||
<string name="action_follow">Segueix</string>
|
||||
<string name="action_unfollow">Deixa de seguir</string>
|
||||
<string name="action_block">Bloca</string>
|
||||
|
|
|
@ -216,7 +216,6 @@
|
|||
<string name="pref_title_show_boosts">Zeige Boosts</string>
|
||||
<string name="pref_title_pull_notification_check_interval">Überprüfungsintervall</string>
|
||||
<string name="no_content">leer</string>
|
||||
<string name="action_logout_confirm">Willst du dich wirklich ausloggen?</string>
|
||||
<string name="action_hide_media">Verstecke Medien</string>
|
||||
<string name="pref_title_status_filter">Timeline-Filter</string>
|
||||
<string name="title_saved_toot">Gespeicherte Tröts</string>
|
||||
|
|
|
@ -93,7 +93,6 @@
|
|||
<string name="action_save">Sauvegarder</string>
|
||||
<string name="action_edit_profile">Modifier le profil</string>
|
||||
<string name="action_accept">Accepter</string>
|
||||
<string name="action_logout_confirm">Voulez-vous vous déconnectez ?</string>
|
||||
<string name="action_reject">Rejeter</string>
|
||||
<string name="action_undo">Annuler</string>
|
||||
<string name="action_view_follow_requests">Demandes de suivi</string>
|
||||
|
|
|
@ -57,7 +57,6 @@
|
|||
<string name="action_compose">Szerkeszt</string>
|
||||
<string name="action_login">Bejelentkezés Mastodon-al</string>
|
||||
<string name="action_logout">Kijelentkezés</string>
|
||||
<string name="action_logout_confirm">Ki szeretne jelentkezni?</string>
|
||||
<string name="action_follow">Követ</string>
|
||||
<string name="action_unfollow">Követőktől eltávolít</string>
|
||||
<string name="action_block">Blokkol</string>
|
||||
|
|
|
@ -57,7 +57,7 @@
|
|||
<string name="action_compose">新規投稿</string>
|
||||
<string name="action_login">Mastodonでログイン</string>
|
||||
<string name="action_logout">ログアウト</string>
|
||||
<string name="action_logout_confirm">ログアウトしますか?</string>
|
||||
|
||||
<string name="action_follow">フォローする</string>
|
||||
<string name="action_unfollow">フォロー解除</string>
|
||||
<string name="action_block">ブロック</string>
|
||||
|
|
|
@ -51,7 +51,6 @@
|
|||
<string name="action_compose">Schrijven</string>
|
||||
<string name="action_login">Inloggen bij Mastodon</string>
|
||||
<string name="action_logout">Uitloggen</string>
|
||||
<string name="action_logout_confirm">Wil je echt uitloggen?</string>
|
||||
<string name="action_follow">Volgen</string>
|
||||
<string name="action_unfollow">Ontvolgen</string>
|
||||
<string name="action_block">Blokkeren</string>
|
||||
|
|
|
@ -68,9 +68,10 @@
|
|||
<string name="action_compose">Napisz</string>
|
||||
<string name="action_login">Zaloguj z Mastodon</string>
|
||||
<string name="action_logout">Wyloguj</string>
|
||||
<string name="action_logout_confirm">Czy chcesz wylogować się?</string>
|
||||
|
||||
<string name="action_follow">Śledź</string>
|
||||
<string name="action_unfollow">Przestań śledzić</string>
|
||||
|
||||
<string name="action_block">Zablokuj</string>
|
||||
<string name="action_unblock">Odblokuj</string>
|
||||
<string name="action_report">Zgłoś</string>
|
||||
|
|
|
@ -60,7 +60,6 @@
|
|||
<string name="action_compose">Compor</string>
|
||||
<string name="action_login">Entrar com Mastodon</string>
|
||||
<string name="action_logout">Sair</string>
|
||||
<string name="action_logout_confirm">Deseja sair?</string>
|
||||
<string name="action_follow">Seguir</string>
|
||||
<string name="action_unfollow">Deixar de seguir</string>
|
||||
<string name="action_block">Bloquear</string>
|
||||
|
|
|
@ -61,7 +61,6 @@
|
|||
<string name="action_compose">Написать</string>
|
||||
<string name="action_login">Войти</string>
|
||||
<string name="action_logout">Выйти</string>
|
||||
<string name="action_logout_confirm">Вы хотите выйти?</string>
|
||||
<string name="action_follow">Подписаться</string>
|
||||
<string name="action_unfollow">Отписаться</string>
|
||||
<string name="action_block">Заблокировать</string>
|
||||
|
|
|
@ -4,6 +4,8 @@
|
|||
<color name="view_video_background">#000000</color>
|
||||
<color name="toolbar_view_media">#8f000000</color>
|
||||
<color name="semi_transparent">#33000000</color>
|
||||
<color name="header_background_filter">#44000000</color>
|
||||
|
||||
<!--Dark Theme Colors-->
|
||||
<color name="color_primary_dark">#4c5368</color>
|
||||
<color name="color_primary_dark_dark">#313543</color> <!--Dark Dark-->
|
||||
|
|
|
@ -61,7 +61,7 @@
|
|||
<string name="action_compose">Compose</string>
|
||||
<string name="action_login">Login with Mastodon</string>
|
||||
<string name="action_logout">Log Out</string>
|
||||
<string name="action_logout_confirm">Do you wish to logout</string>
|
||||
<string name="action_logout_confirm">Are you sure you want to log out of the account %1$s?</string>
|
||||
<string name="action_follow">Follow</string>
|
||||
<string name="action_unfollow">Unfollow</string>
|
||||
<string name="action_block">Block</string>
|
||||
|
@ -157,6 +157,7 @@
|
|||
<string name="pref_title_notification_settings">Notifications</string>
|
||||
<string name="pref_title_edit_notification_settings">Edit Notifications</string>
|
||||
<string name="pref_title_notifications_enabled">Notifications</string>
|
||||
<string name="pref_summary_notifications">for account %1$s</string>
|
||||
<string name="pref_title_pull_notification_check_interval">Check Interval</string>
|
||||
<string name="pref_title_notification_alerts">Alerts</string>
|
||||
<string name="pref_title_notification_alert_sound">Notify with a sound</string>
|
||||
|
@ -230,7 +231,7 @@
|
|||
<string name="notification_channel_boost_name">Boosts</string>
|
||||
<string name="notification_channel_boost_description">Notifications when your toots get boosted</string>
|
||||
<string name="notification_channel_favourite_name">Favourites</string>
|
||||
<string name="notification_channel_favourite_description">Notifications when your toots get mark as favourite</string>
|
||||
<string name="notification_channel_favourite_description">Notifications when your toots get marked as favourite</string>
|
||||
|
||||
|
||||
<string name="notification_mention_format">%s mentioned you</string>
|
||||
|
@ -285,9 +286,16 @@
|
|||
<string name="title_media">Media</string>
|
||||
<string name="replying_to">Replying to @%s</string>
|
||||
<string name="load_more_placeholder_text">load more</string>
|
||||
|
||||
<string name="add_account_name">Add Account</string>
|
||||
<string name="add_account_description">Add new Mastodon Account</string>
|
||||
|
||||
<string name="action_lists">Lists</string>
|
||||
<string name="title_lists">Lists</string>
|
||||
<string name="title_list_timeline">List timeline</string>
|
||||
|
||||
<string name="compose_active_account_description">Posting with account %1$s</string>
|
||||
|
||||
<string name="error_failed_set_caption">Failed to set caption</string>
|
||||
<string name="hint_describe_for_visually_impaired">Describe for visually impaired</string>
|
||||
<string name="action_set_caption">Set caption</string>
|
||||
|
|
Loading…
Reference in a new issue