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:
Konrad Pozniak 2018-02-03 22:45:14 +01:00 committed by GitHub
parent c9004f1d54
commit 92ae463b38
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 1293 additions and 773 deletions

View File

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

View File

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

View File

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

View File

@ -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);

View File

@ -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()));
}
}

View File

@ -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();
}
}

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

View File

@ -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() {
new AlertDialog.Builder(this)
.setTitle(R.string.action_logout)
.setMessage(R.string.action_logout_confirm)
.setPositiveButton(android.R.string.yes, (dialog, which) -> {
if (arePushNotificationsEnabled()) disablePushNotifications();
getPrivatePreferences().edit()
.remove("domain")
.remove("accessToken")
.remove("appAccountId")
.apply();
AccountEntity activeAccount = TuskyApplication.getAccountManager().getActiveAccount();
Intent intent = new Intent(MainActivity.this, LoginActivity.class);
startActivity(intent);
finish();
})
.setNegativeButton(android.R.string.no, null)
.show();
if(activeAccount != null) {
new AlertDialog.Builder(this)
.setTitle(R.string.action_logout)
.setMessage(getString(R.string.action_logout_confirm, activeAccount.getFullName()))
.setPositiveButton(android.R.string.yes, (dialog, which) -> {
AccountManager accountManager = TuskyApplication.getAccountManager();
NotificationManager.deleteNotificationChannelsForAccount(accountManager.getActiveAccount(), MainActivity.this);
AccountEntity newAccount = accountManager.logActiveAccountOut();
if (!accountManager.notificationsEnabled()) disablePushNotifications();
Intent intent;
if (newAccount == null) {
intent = LoginActivity.getIntent(MainActivity.this, false);
} else {
intent = new Intent(MainActivity.this, MainActivity.class);
}
startActivity(intent);
finish();
})
.setNegativeButton(android.R.string.no, null)
.show();
}
}
private void fetchUserInfo() {
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();
headerResult.addProfiles(
new ProfileDrawerItem()
.withName(me.getDisplayName())
.withEmail(String.format("%s@%s", me.username, domain))
.withIcon(me.avatar)
);
AccountManager am = TuskyApplication.getAccountManager();
am.updateActiveAccount(me);
NotificationManager.createNotificationChannelsForAccount(am.getActiveAccount(), this);
List<AccountEntity> allAccounts = am.getAllAccountsOrderedByActive();
for(AccountEntity acc: allAccounts) {
headerResult.addProfiles(
new ProfileDrawerItem()
.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) {

View File

@ -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) {
try {
Response<List<Notification>> notifications =
mastodonApi.notifications(null, null, null).execute();
if (notifications.isSuccessful()) {
onNotificationsReceived(notifications.body());
} else {
return Result.FAILURE;
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 =
api.notificationsWithAuth(String.format("Bearer %s", account.getAccessToken())).execute();
if (notifications.isSuccessful()) {
onNotificationsReceived(account, notifications.body());
} else {
Log.w(TAG, "error receiving notificationsEnabled");
}
} catch (IOException e) {
Log.w(TAG, "error receiving notificationsEnabled", e);
}
}
} catch (IOException e) {
e.printStackTrace();
return Result.FAILURE;
}
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);
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;
}
}
}

View File

@ -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();

View File

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

View File

@ -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();
}
}

View File

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

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

View File

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

View File

@ -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)) {

View File

@ -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,23 +60,28 @@ 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) {
Intent intent = new Intent();
intent.setAction("android.settings.APP_NOTIFICATION_SETTINGS");
notificationPreferences.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() {
@Override
public boolean onPreferenceClick(Preference preference) {
Intent intent = new Intent();
intent.setAction("android.settings.APP_NOTIFICATION_SETTINGS");
intent.putExtra("android.provider.extra.APP_PACKAGE", BuildConfig.APPLICATION_ID);
intent.putExtra("android.provider.extra.APP_PACKAGE", BuildConfig.APPLICATION_ID);
startActivity(intent);
return true;
}
});
startActivity(intent);
return true;
}
});
} else {
notificationPreferences.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() {
@ -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) {

View File

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

View File

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

View File

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

View File

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

View File

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

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

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

View File

@ -1,82 +1,99 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true"
android:gravity="center"
android:orientation="vertical"
tools:context="com.keylesspalace.tusky.LoginActivity">
<LinearLayout
android:orientation="vertical"
<ScrollView
android:layout_width="match_parent"
android:padding="16dp"
android:gravity="center"
android:layout_height="wrap_content">
<ImageView
android:layout_width="147dp"
android:layout_height="160dp"
android:layout_marginBottom="50dp"
android:src="@drawable/elephant_friend"
android:contentDescription="@null" />
android:layout_height="match_parent"
android:fillViewport="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toTopOf="parent">
<LinearLayout
android:id="@+id/login_input"
android:layout_width="wrap_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<android.support.design.widget.TextInputLayout
android:layout_height="wrap_content"
android:layout_width="250dp">
<android.support.design.widget.TextInputEditText
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textUri"
android:hint="@string/hint_domain"
android:ems="10"
android:id="@+id/edit_text_domain" />
</android.support.design.widget.TextInputLayout>
<Button
android:id="@+id/button_login"
android:layout_width="250dp"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textColor="@android:color/white"
android:text="@string/action_login" />
<TextView
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" />
</LinearLayout>
<LinearLayout
android:id="@+id/login_loading"
android:visibility="gone"
android:gravity="center"
android:orientation="vertical"
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<ProgressBar
android:layout_gravity="center"
android:padding="16dp">
<ImageView
android:layout_width="147dp"
android:layout_height="160dp"
android:layout_marginBottom="50dp"
android:contentDescription="@null"
android:src="@drawable/elephant_friend" />
<LinearLayout
android:id="@+id/loginInputLayout"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<TextView
android:paddingTop="10dp"
android:textAlignment="center"
android:layout_width="250dp"
android:layout_height="wrap_content"
android:text="@string/login_connection"/>
android:orientation="vertical">
<android.support.design.widget.TextInputLayout
android:layout_width="250dp"
android:layout_height="wrap_content">
<android.support.design.widget.TextInputEditText
android:id="@+id/domainEditText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ems="10"
android:hint="@string/hint_domain"
android:inputType="textUri" />
</android.support.design.widget.TextInputLayout>
<Button
android:id="@+id/loginButton"
android:layout_width="250dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:text="@string/action_login"
android:textColor="@android:color/white" />
<TextView
android:id="@+id/whatsAnInstanceTextView"
android:layout_width="250dp"
android:layout_height="wrap_content"
android:paddingTop="6dp"
android:text="@string/link_whats_an_instance"
android:textAlignment="center" />
</LinearLayout>
<LinearLayout
android:id="@+id/loginLoadingLayout"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone">
<ProgressBar
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center" />
<TextView
android:layout_width="250dp"
android:layout_height="wrap_content"
android:paddingTop="10dp"
android:text="@string/login_connection"
android:textAlignment="center" />
</LinearLayout>
</LinearLayout>
</ScrollView>
<android.support.v7.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:layout_alignParentTop="true"
android:background="@android:color/transparent"
app:layout_constraintTop_toTopOf="parent" />
</LinearLayout>
</ScrollView>
</android.support.constraint.ConstraintLayout>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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