diff --git a/README.md b/README.md index 61945808..a59dbb94 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/build.gradle b/app/build.gradle index 11bdc3cd..033afade 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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' }) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2d7fb032..cf7119b7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -9,7 +9,6 @@ - . */ - -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 callback = new Callback() { - @Override - public void onResponse(@NonNull Call call, - @NonNull Response 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 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 parameters) { - StringBuilder s = new StringBuilder(); - String between = ""; - for (Map.Entry 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 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 callback = new Callback() { - @Override - public void onResponse(@NonNull Call call, @NonNull Response 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 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(); - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/LoginActivity.kt b/app/src/main/java/com/keylesspalace/tusky/LoginActivity.kt new file mode 100644 index 00000000..f50565f2 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/LoginActivity.kt @@ -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 . */ + +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(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 { + override fun onResponse(call: Call, + response: Response) { + 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, 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() + 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 { + override fun onResponse(call: Call, response: Response) { + 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, 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 { + 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 + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.java b/app/src/main/java/com/keylesspalace/tusky/MainActivity.java index aed7abe9..7b69ba56 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.java @@ -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() { @Override public void onResponse(@NonNull Call call, @NonNull Response 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 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) { diff --git a/app/src/main/java/com/keylesspalace/tusky/NotificationPullJobCreator.java b/app/src/main/java/com/keylesspalace/tusky/NotificationPullJobCreator.java index 3b35255d..b1b775cb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/NotificationPullJobCreator.java +++ b/app/src/main/java/com/keylesspalace/tusky/NotificationPullJobCreator.java @@ -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> notifications = - mastodonApi.notifications(null, null, null).execute(); - if (notifications.isSuccessful()) { - onNotificationsReceived(notifications.body()); - } else { - return Result.FAILURE; + + List 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> 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 notificationList) { - SharedPreferences notificationsPreferences = context.getSharedPreferences( - "Notifications", Context.MODE_PRIVATE); - //make a copy of the string set, the returned instance should not be modified - Set 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 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; } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/SplashActivity.java b/app/src/main/java/com/keylesspalace/tusky/SplashActivity.java index 9e1b732f..2445111e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/SplashActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/SplashActivity.java @@ -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(); diff --git a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java index b690f217..47efb83d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java +++ b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java @@ -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(); } -} \ No newline at end of file + + public static AccountManager getAccountManager() { + return accountManager; + } + + } \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationClearBroadcastReceiver.java b/app/src/main/java/com/keylesspalace/tusky/db/AccountDao.kt similarity index 51% rename from app/src/main/java/com/keylesspalace/tusky/receiver/NotificationClearBroadcastReceiver.java rename to app/src/main/java/com/keylesspalace/tusky/db/AccountDao.kt index b80cb5fa..a51b2e2b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationClearBroadcastReceiver.java +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountDao.kt @@ -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 . */ -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 -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(); - } } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt new file mode 100644 index 00000000..ccf87360 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt @@ -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 . */ + +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 + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt new file mode 100644 index 00000000..b28c0137 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt @@ -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 . */ + +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 = 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 { + 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 + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java index 9d87b547..9fcc62a5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java +++ b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java @@ -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 diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java index 9eedf178..aecc5deb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java @@ -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 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 newNotifications, @Nullable String fromId, @Nullable String uptoId) { if (ListUtils.isEmpty(newNotifications)) { diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/PreferencesFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/PreferencesFragment.java index 7fab0c39..7103c5e1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/PreferencesFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/PreferencesFragment.java @@ -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) { diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java index 7a9a96ec..4a6d73dd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java @@ -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 diff --git a/app/src/main/java/com/keylesspalace/tusky/network/AuthInterceptor.java b/app/src/main/java/com/keylesspalace/tusky/network/AuthInterceptor.java index 1b615027..60d0dd31 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/AuthInterceptor.java +++ b/app/src/main/java/com/keylesspalace/tusky/network/AuthInterceptor.java @@ -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); - } - } } diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java index 06576b08..37cc8a7c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java @@ -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> notificationsWithAuth( + @Header("Authorization") String auth); @POST("api/v1/notifications/clear") Call clearNotifications(); @GET("api/v1/notifications/{id}") diff --git a/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationClearBroadcastReceiver.kt b/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationClearBroadcastReceiver.kt new file mode 100644 index 00000000..be8f84b2 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationClearBroadcastReceiver.kt @@ -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 . */ + +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) + } + } + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/NotificationManager.java b/app/src/main/java/com/keylesspalace/tusky/util/NotificationManager.java index d42119d1..05dc5fd3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/NotificationManager.java +++ b/app/src/main/java/com/keylesspalace/tusky/util/NotificationManager.java @@ -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 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: diff --git a/app/src/main/res/anim/explode.xml b/app/src/main/res/anim/explode.xml new file mode 100644 index 00000000..27982abc --- /dev/null +++ b/app/src/main/res/anim/explode.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/drawable-xxhdpi/ic_reblog_dark_.png b/app/src/main/res/drawable-xxhdpi/ic_reblog_dark_.png new file mode 100644 index 00000000..cf90f688 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_reblog_dark_.png differ diff --git a/app/src/main/res/layout/activity_compose.xml b/app/src/main/res/layout/activity_compose.xml index e485a29e..5167d83c 100644 --- a/app/src/main/res/layout/activity_compose.xml +++ b/app/src/main/res/layout/activity_compose.xml @@ -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"> + + + - - - - + android:layout_height="match_parent" + android:fillViewport="true" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintTop_toTopOf="parent"> - - - - -