diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..c7993c9d9 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,46 @@ +# Contributing + +## Getting Started +1. Fork the repository on the Github page by clicking the Fork button. This makes a fork of the project under your Github account. +2. Clone your fork to your machine. ```git clone https://github.com//Tusky``` +3. Create a new branch named after your change. ```git checkout -b your-change-name``` (```checkout``` switches to a branch, ```-b``` specifies that the branch is a new one) + +## Making Changes + +### Text +All english text that will be visible to users should be put in ```app/src/main/res/values/strings.xml```. Any text that is missing in a translation will fall back to the version in this file. Be aware that anything added to this file will need to be translated, so be very concise with wording and try to add as few things as possible. Look for existing strings to use first. If there is untranslatable text that you don't want to keep as a string constant in a java class, you can use the string resource file ```app/src/main/res/values/donottranslate.xml```. + +### Translation +Each translation has a single file that contains all of the text. A given locale's file can be found at ```app/src/main/res/values-[_]/strings.xml```. So, it could be ```values-en_US``` or ```values-es_ES```, for example. Specifically, they're the [two-letter ISO 639-1 language code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) and the optional [ISO 3166-1 alpha-2 country code](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2), which is used for a dialect of that particular country. + +If you're starting a translation that doesn't already exist, you can just copy the english ```strings.xml``` to a new ```values``` directory and replace the english text inside each of the `````` `````` pairs. + +Strings follow XML rules, which means that apostrophes and quotation marks have to be "escaped" with a backslash like: ```shouldn\'t``` and ```\"formidable\"```. Also, formatting is ignored when shown in the application, so things like new lines have to be explicitly expressed with codes like ```\n``` for a new line. See also: [String Resources](https://developer.android.com/guide/topics/resources/string-resource.html#FormattingAndStyling). + +Please keep the organization and ordering of each of the strings the same as in the default ```strings.xml``` file. It just helps to keep so many translation files straight and up-to-date. + +There are no icons or other resources needing localization, so it's just the text. + +### Java +For java, I generally follow this [Android Style Guide](https://source.android.com/source/code-style), which is what Android uses for their own source code. I encourage the use of optional annotations like ```@Nullable``` and ```@NotNull```. Also, if you ever make helper functions that take Android resources, annotations like ```@StringRes```, ```@DrawableRes```, and ```@AttrRes``` are helpful. They can prevent small errors, like accidentally passing an attribute id to a function that takes a drawable id, for example (both are ints). + +### Visuals +There are two themes in the app, so any visual changes should be checked with both themes to ensure they look appropriate for both. Usually, you can use existing color attributes like ```?attr/colorPrimary``` and ```?attr/textColorSecondary```. For icons and drawables, use a white drawable and tint it at runtime using ```ThemeUtils``` and specify an attribute that references different colours depending on the theme. Do not reference attributes in drawable files, because it is only supported in API levels 21+. + +### Saving +Any time you get a good chunk of work done it's good to make a commit. You can either uses Android Studio's built-in UI for doing this or running the commands: +``` +git add . +git commit -m "Describe the changes in this commit here." +``` + +## Submitting Your Changes +1. Make sure your branch is up-to-date with the ```master``` branch. Run: +``` +git fetch +git rebase origin/master +``` +It may refuse to start the rebase if there's changes that haven't been committed, so make sure you've added and committed everything. If there were changes on master to any of the parts of files you worked on, a conflict will arise when you rebase. [Resolving a merge conflict](https://help.github.com/articles/resolving-a-merge-conflict-using-the-command-line) is a good guide to help with this. After committing the resolution, you can run ```git rebase --continue``` to finish the rebase. If you want to cancel, like if you make some mistake in resolving the conflict, you can always do ```git rebase --abort```. + +2. Push your local branch to your fork on Github by running ```git push origin your-change-name```. +3. Then, go to the original project page and make a pull request. Select your fork/branch and use ```master``` as the base branch. diff --git a/README.md b/README.md index fd3677bf8..2ab51d893 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Tusky -![](https://lh3.googleusercontent.com/6Ctl3PXaQi19qMaipWwzHAoKS9M9zy328cuulNZNAmRbjsPkSXs2xJ2OcyQNpOy23hI=w100) +![](app/src/main/res/drawable/tusky_logo.png) Tusky is a beautiful Android client for [Mastodon](https://github.com/tootsuite/mastodon). Mastodon is a GNU social-compatible federated social network. That means not one entity controls the whole network, rather, like e-mail, volunteers and organisations operate their own independent servers, users from which can all interact with each other seamlessly. diff --git a/app/build.gradle b/app/build.gradle index 1e3de3e19..575056db7 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,6 +12,14 @@ android { testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary true } + productFlavors { + google { + buildConfigField "boolean", "USES_PUSH_NOTIFICATIONS", "true" + } + fdroid { + buildConfigField "boolean", "USES_PUSH_NOTIFICATIONS", "false" + } + } buildTypes { release { minifyEnabled true @@ -38,23 +46,24 @@ dependencies { compile 'com.android.support:support-v13:25.3.1' compile 'com.android.support:design:25.3.1' compile 'com.android.support:exifinterface:25.3.1' + compile 'com.squareup.retrofit2:retrofit:2.2.0' + compile 'com.squareup.retrofit2:converter-gson:2.1.0' compile 'com.squareup.picasso:picasso:2.5.2' + compile 'com.squareup.okhttp3:okhttp:3.7.0' + compile 'com.jakewharton.picasso:picasso2-okhttp3-downloader:1.1.0' compile 'com.pkmmte.view:circularimageview:1.1' compile 'com.github.peter9870:sparkbutton:master' compile 'com.mikhaellopez:circularfillableloaders:1.2.0' - compile 'com.squareup.retrofit2:retrofit:2.2.0' - compile 'com.squareup.retrofit2:converter-gson:2.1.0' compile 'com.github.chrisbanes:PhotoView:1.3.1' compile 'com.mikepenz:google-material-typeface:3.0.1.0.original@aar' - compile 'com.github.arimorty:floatingsearchview:2.0.3' + compile 'com.github.arimorty:floatingsearchview:2.0.4' + compile 'com.theartofdev.edmodo:android-image-cropper:2.4.0' compile 'com.jakewharton:butterknife:8.4.0' - compile 'com.google.firebase:firebase-messaging:10.0.1' - compile 'com.google.firebase:firebase-crash:10.0.1' + googleCompile 'com.google.firebase:firebase-messaging:10.0.1' + googleCompile 'com.google.firebase:firebase-crash:10.0.1' compile 'com.android.support.constraint:constraint-layout:1.0.0-alpha7' testCompile 'junit:junit:4.12' annotationProcessor 'com.jakewharton:butterknife-compiler:8.4.0' } - - apply plugin: 'com.google.gms.google-services' \ No newline at end of file diff --git a/app/src/fdroid/AndroidManifest.xml b/app/src/fdroid/AndroidManifest.xml new file mode 100644 index 000000000..b7060e6e2 --- /dev/null +++ b/app/src/fdroid/AndroidManifest.xml @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/fdroid/java/com/keylesspalace/tusky/MessagingService.java b/app/src/fdroid/java/com/keylesspalace/tusky/MessagingService.java new file mode 100644 index 000000000..01d98122d --- /dev/null +++ b/app/src/fdroid/java/com/keylesspalace/tusky/MessagingService.java @@ -0,0 +1,132 @@ +/* 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.IntentService; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.preference.PreferenceManager; +import android.text.Spanned; +import android.util.ArraySet; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +import com.keylesspalace.tusky.entity.Notification; + +import java.util.HashSet; +import java.util.List; + +import java.io.IOException; +import java.util.Set; + +import okhttp3.Interceptor; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; +import retrofit2.Retrofit; +import retrofit2.converter.gson.GsonConverterFactory; + +public class MessagingService extends IntentService { + public static final int NOTIFY_ID = 6; // This is an arbitrary number. + + private MastodonAPI mastodonAPI; + + public MessagingService() { + super("Tusky Pull Notification Service"); + } + + @Override + protected void onHandleIntent(Intent intent) { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences( + getApplicationContext()); + boolean enabled = preferences.getBoolean("notificationsEnabled", true); + if (!enabled) { + return; + } + + createMastodonApi(); + + mastodonAPI.notifications(null, null, null).enqueue(new Callback>() { + @Override + public void onResponse(Call> call, + Response> response) { + if (response.isSuccessful()) { + onNotificationsReceived(response.body()); + } + } + + @Override + public void onFailure(Call> call, Throwable t) {} + }); + } + + private void createMastodonApi() { + SharedPreferences preferences = getSharedPreferences( + getString(R.string.preferences_file_key), Context.MODE_PRIVATE); + final String domain = preferences.getString("domain", null); + final String accessToken = preferences.getString("accessToken", null); + + OkHttpClient okHttpClient = OkHttpUtils.getCompatibleClientBuilder() + .addInterceptor(new Interceptor() { + @Override + public okhttp3.Response intercept(Chain chain) throws IOException { + Request originalRequest = chain.request(); + + Request.Builder builder = originalRequest.newBuilder() + .header("Authorization", String.format("Bearer %s", accessToken)); + + Request newRequest = builder.build(); + + return chain.proceed(newRequest); + } + }) + .build(); + + Gson gson = new GsonBuilder() + .registerTypeAdapter(Spanned.class, new SpannedTypeAdapter()) + .registerTypeAdapter(StringWithEmoji.class, new StringWithEmojiTypeAdapter()) + .create(); + + Retrofit retrofit = new Retrofit.Builder() + .baseUrl("https://" + domain) + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create(gson)) + .build(); + + mastodonAPI = retrofit.create(MastodonAPI.class); + } + + private void onNotificationsReceived(List notificationList) { + SharedPreferences notificationsPreferences = getSharedPreferences( + "Notifications", Context.MODE_PRIVATE); + Set currentIds = notificationsPreferences.getStringSet( + "current_ids", new HashSet()); + for (Notification notification : notificationList) { + String id = notification.id; + if (!currentIds.contains(id)) { + currentIds.add(id); + NotificationMaker.make(this, NOTIFY_ID, notification); + } + } + notificationsPreferences.edit() + .putStringSet("current_ids", currentIds) + .apply(); + } +} diff --git a/app/src/google/AndroidManifest.xml b/app/src/google/AndroidManifest.xml new file mode 100644 index 000000000..20ecbe94c --- /dev/null +++ b/app/src/google/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/google/java/com/keylesspalace/tusky/MessagingService.java b/app/src/google/java/com/keylesspalace/tusky/MessagingService.java new file mode 100644 index 000000000..cf4e52733 --- /dev/null +++ b/app/src/google/java/com/keylesspalace/tusky/MessagingService.java @@ -0,0 +1,121 @@ +/* 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 . + * + * If you modify this Program, or any covered work, by linking or combining it with Firebase Cloud + * Messaging and Firebase Crash Reporting (or a modified version of those libraries), containing + * parts covered by the Google APIs Terms of Service, the licensors of this Program grant you + * additional permission to convey the resulting work. */ + +package com.keylesspalace.tusky; + +import android.content.Context; +import android.content.SharedPreferences; +import android.preference.PreferenceManager; +import android.text.Spanned; + +import com.google.firebase.messaging.FirebaseMessagingService; +import com.google.firebase.messaging.RemoteMessage; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +import com.keylesspalace.tusky.entity.Notification; + +import java.io.IOException; + +import okhttp3.Interceptor; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; +import retrofit2.Retrofit; +import retrofit2.converter.gson.GsonConverterFactory; + +public class MessagingService extends FirebaseMessagingService { + private MastodonAPI mastodonAPI; + private static final String TAG = "MessagingService"; + public static final int NOTIFY_ID = 666; + + @Override + public void onMessageReceived(RemoteMessage remoteMessage) { + Log.d(TAG, remoteMessage.getFrom()); + Log.d(TAG, remoteMessage.toString()); + + String notificationId = remoteMessage.getData().get("notification_id"); + + if (notificationId == null) { + Log.e(TAG, "No notification ID in payload!!"); + return; + } + + Log.d(TAG, notificationId); + + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences( + getApplicationContext()); + boolean enabled = preferences.getBoolean("notificationsEnabled", true); + if (!enabled) { + return; + } + + createMastodonAPI(); + + mastodonAPI.notification(notificationId).enqueue(new Callback() { + @Override + public void onResponse(Call call, Response response) { + if (response.isSuccessful()) { + NotificationMaker.make(MessagingService.this, NOTIFY_ID, response.body()); + } + } + + @Override + public void onFailure(Call call, Throwable t) {} + }); + } + + private void createMastodonAPI() { + SharedPreferences preferences = getSharedPreferences(getString(R.string.preferences_file_key), Context.MODE_PRIVATE); + final String domain = preferences.getString("domain", null); + final String accessToken = preferences.getString("accessToken", null); + + OkHttpClient okHttpClient = OkHttpUtils.getCompatibleClientBuilder() + .addInterceptor(new Interceptor() { + @Override + public okhttp3.Response intercept(Chain chain) throws IOException { + Request originalRequest = chain.request(); + + Request.Builder builder = originalRequest.newBuilder() + .header("Authorization", String.format("Bearer %s", accessToken)); + + Request newRequest = builder.build(); + + return chain.proceed(newRequest); + } + }) + .build(); + + Gson gson = new GsonBuilder() + .registerTypeAdapter(Spanned.class, new SpannedTypeAdapter()) + .registerTypeAdapter(StringWithEmoji.class, new StringWithEmojiTypeAdapter()) + .create(); + + Retrofit retrofit = new Retrofit.Builder() + .baseUrl("https://" + domain) + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create(gson)) + .build(); + + mastodonAPI = retrofit.create(MastodonAPI.class); + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/MyFirebaseInstanceIdService.java b/app/src/google/java/com/keylesspalace/tusky/MyFirebaseInstanceIdService.java similarity index 97% rename from app/src/main/java/com/keylesspalace/tusky/MyFirebaseInstanceIdService.java rename to app/src/google/java/com/keylesspalace/tusky/MyFirebaseInstanceIdService.java index 9e139187f..adb478795 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MyFirebaseInstanceIdService.java +++ b/app/src/google/java/com/keylesspalace/tusky/MyFirebaseInstanceIdService.java @@ -33,7 +33,7 @@ import retrofit2.Response; import retrofit2.Retrofit; public class MyFirebaseInstanceIdService extends FirebaseInstanceIdService { - private static final String TAG = "MyFirebaseInstanceIdService"; + private static final String TAG = "com.keylesspalace.tusky.MyFirebaseInstanceIdService"; private TuskyAPI tuskyAPI; diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7bbe64854..8f375863c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ + - + android:theme="@style/AppTheme" + android:name=".TuskyApplication"> @@ -70,6 +69,7 @@ + @@ -106,6 +106,15 @@ + + + \ No newline at end of file diff --git a/app/src/main/ic_launcher-web.png b/app/src/main/ic_launcher-web.png index 2289649f5..cbb0f714d 100644 Binary files a/app/src/main/ic_launcher-web.png and b/app/src/main/ic_launcher-web.png differ diff --git a/app/src/main/ic_launcher.svg b/app/src/main/ic_launcher.svg new file mode 100644 index 000000000..f8cbd7890 --- /dev/null +++ b/app/src/main/ic_launcher.svg @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/keylesspalace/tusky/AccountActionListener.java b/app/src/main/java/com/keylesspalace/tusky/AccountActionListener.java index 020a504d7..bca609cdf 100644 --- a/app/src/main/java/com/keylesspalace/tusky/AccountActionListener.java +++ b/app/src/main/java/com/keylesspalace/tusky/AccountActionListener.java @@ -17,5 +17,7 @@ package com.keylesspalace.tusky; interface AccountActionListener { void onViewAccount(String id); + void onMute(final boolean mute, final String id, final int position); void onBlock(final boolean block, final String id, final int position); + void onRespondToFollowRequest(final boolean accept, final String id, final int position); } diff --git a/app/src/main/java/com/keylesspalace/tusky/AccountActivity.java b/app/src/main/java/com/keylesspalace/tusky/AccountActivity.java index ebf1ebd99..63d16be66 100644 --- a/app/src/main/java/com/keylesspalace/tusky/AccountActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/AccountActivity.java @@ -28,11 +28,11 @@ import android.support.design.widget.CollapsingToolbarLayout; import android.support.design.widget.FloatingActionButton; import android.support.design.widget.Snackbar; import android.support.design.widget.TabLayout; +import android.support.v4.app.Fragment; import android.support.v4.view.ViewCompat; import android.support.v4.view.ViewPager; import android.support.v7.app.ActionBar; import android.support.v7.widget.Toolbar; -import android.text.method.LinkMovementMethod; import android.view.Menu; import android.view.MenuItem; import android.view.View; @@ -54,7 +54,7 @@ import retrofit2.Call; import retrofit2.Callback; import retrofit2.Response; -public class AccountActivity extends BaseActivity { +public class AccountActivity extends BaseActivity implements SFragment.OnUserRemovedListener { private static final String TAG = "AccountActivity"; // logging tag private String accountId; @@ -63,6 +63,7 @@ public class AccountActivity extends BaseActivity { private boolean muting = false; private boolean isSelf; private TabLayout tabLayout; + private AccountPagerAdapter pagerAdapter; private Account loadedAccount; @BindView(R.id.account_locked) ImageView accountLockedView; @@ -80,8 +81,7 @@ public class AccountActivity extends BaseActivity { accountId = intent.getStringExtra("id"); } - SharedPreferences preferences = getSharedPreferences( - getString(R.string.preferences_file_key), Context.MODE_PRIVATE); + SharedPreferences preferences = getPrivatePreferences(); String loggedInAccountId = preferences.getString("loggedInAccountId", null); final Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); @@ -142,6 +142,7 @@ public class AccountActivity extends BaseActivity { // Setup the tabs and timeline pager. AccountPagerAdapter adapter = new AccountPagerAdapter(getSupportFragmentManager(), this, accountId); + pagerAdapter = adapter; String[] pageTitles = { getString(R.string.title_statuses), getString(R.string.title_follows), @@ -165,6 +166,12 @@ public class AccountActivity extends BaseActivity { } } + @Override + protected void onSaveInstanceState(Bundle outState) { + outState.putString("accountId", accountId); + super.onSaveInstanceState(outState); + } + private void obtainAccount() { mastodonAPI.account(accountId).enqueue(new Callback() { @Override @@ -183,12 +190,6 @@ public class AccountActivity extends BaseActivity { }); } - @Override - protected void onSaveInstanceState(Bundle outState) { - outState.putString("accountId", accountId); - super.onSaveInstanceState(outState); - } - private void onObtainAccountSuccess(Account account) { loadedAccount = account; @@ -204,9 +205,21 @@ public class AccountActivity extends BaseActivity { displayName.setText(account.getDisplayName()); - note.setText(account.note); - note.setLinksClickable(true); - note.setMovementMethod(LinkMovementMethod.getInstance()); + LinkHelper.setClickableText(note, account.note, null, new LinkListener() { + @Override + public void onViewTag(String tag) { + Intent intent = new Intent(AccountActivity.this, ViewTagActivity.class); + intent.putExtra("hashtag", tag); + startActivity(intent); + } + + @Override + public void onViewAccount(String id) { + Intent intent = new Intent(AccountActivity.this, AccountActivity.class); + intent.putExtra("id", id); + startActivity(intent); + } + }); if (account.locked) { accountLockedView.setVisibility(View.VISIBLE); @@ -289,6 +302,16 @@ public class AccountActivity extends BaseActivity { updateButtons(); } + @Override + public void onUserRemoved(String accountId) { + for (Fragment fragment : pagerAdapter.getRegisteredFragments()) { + if (fragment instanceof StatusRemoveListener) { + StatusRemoveListener listener = (StatusRemoveListener) fragment; + listener.removePostsByUser(accountId); + } + } + } + private void updateFollowButton(FloatingActionButton button) { if (following) { button.setImageResource(R.drawable.ic_person_minus_24px); diff --git a/app/src/main/java/com/keylesspalace/tusky/AccountAdapter.java b/app/src/main/java/com/keylesspalace/tusky/AccountAdapter.java index 2773cce1f..9941d34d3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/AccountAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/AccountAdapter.java @@ -15,6 +15,7 @@ package com.keylesspalace.tusky; +import android.support.annotation.Nullable; import android.support.v7.widget.RecyclerView; import com.keylesspalace.tusky.entity.Account; @@ -64,6 +65,24 @@ abstract class AccountAdapter extends RecyclerView.Adapter { notifyItemRangeInserted(end, newAccounts.size()); } + @Nullable + Account removeItem(int position) { + if (position < 0 || position >= accountList.size()) { + return null; + } + Account account = accountList.remove(position); + notifyItemRemoved(position); + return account; + } + + void addItem(Account account, int position) { + if (position < 0 || position > accountList.size()) { + return; + } + accountList.add(position, account); + notifyItemInserted(position); + } + public Account getItem(int position) { if (position >= 0 && position < accountList.size()) { return accountList.get(position); diff --git a/app/src/main/java/com/keylesspalace/tusky/BlocksActivity.java b/app/src/main/java/com/keylesspalace/tusky/AccountListActivity.java similarity index 59% rename from app/src/main/java/com/keylesspalace/tusky/BlocksActivity.java rename to app/src/main/java/com/keylesspalace/tusky/AccountListActivity.java index 2718d3d6c..1d1276fbc 100644 --- a/app/src/main/java/com/keylesspalace/tusky/BlocksActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/AccountListActivity.java @@ -15,6 +15,7 @@ package com.keylesspalace.tusky; +import android.content.Intent; import android.os.Bundle; import android.support.annotation.Nullable; import android.support.v4.app.Fragment; @@ -23,23 +24,54 @@ import android.support.v7.app.ActionBar; import android.support.v7.widget.Toolbar; import android.view.MenuItem; -public class BlocksActivity extends BaseActivity { +public class AccountListActivity extends BaseActivity { + enum Type { + BLOCKS, + MUTES, + FOLLOW_REQUESTS, + } + @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); - setContentView(R.layout.activity_blocks); + setContentView(R.layout.activity_account_list); + + Type type; + Intent intent = getIntent(); + if (intent != null) { + type = (Type) intent.getSerializableExtra("type"); + } else { + type = Type.BLOCKS; + } Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); setSupportActionBar(toolbar); ActionBar bar = getSupportActionBar(); if (bar != null) { - bar.setTitle(getString(R.string.title_blocks)); + switch (type) { + case BLOCKS: { bar.setTitle(getString(R.string.title_blocks)); break; } + case MUTES: { bar.setTitle(getString(R.string.title_mutes)); break; } + case FOLLOW_REQUESTS: { + bar.setTitle(getString(R.string.title_follow_requests)); + break; + } + } bar.setDisplayHomeAsUpEnabled(true); bar.setDisplayShowHomeEnabled(true); } FragmentTransaction fragmentTransaction = getSupportFragmentManager().beginTransaction(); - Fragment fragment = AccountFragment.newInstance(AccountFragment.Type.BLOCKS); + AccountListFragment.Type fragmentType; + switch (type) { + default: + case BLOCKS: { fragmentType = AccountListFragment.Type.BLOCKS; break; } + case MUTES: { fragmentType = AccountListFragment.Type.MUTES; break; } + case FOLLOW_REQUESTS: { + fragmentType = AccountListFragment.Type.FOLLOW_REQUESTS; + break; + } + } + Fragment fragment = AccountListFragment.newInstance(fragmentType); fragmentTransaction.add(R.id.fragment_container, fragment); fragmentTransaction.commit(); } diff --git a/app/src/main/java/com/keylesspalace/tusky/AccountFragment.java b/app/src/main/java/com/keylesspalace/tusky/AccountListFragment.java similarity index 60% rename from app/src/main/java/com/keylesspalace/tusky/AccountFragment.java rename to app/src/main/java/com/keylesspalace/tusky/AccountListFragment.java index 67b6ed7d8..1128602f8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/AccountFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/AccountListFragment.java @@ -20,6 +20,7 @@ import android.content.Intent; import android.graphics.drawable.Drawable; import android.os.Bundle; import android.support.annotation.Nullable; +import android.support.design.widget.Snackbar; import android.support.design.widget.TabLayout; import android.support.v7.widget.DividerItemDecoration; import android.support.v7.widget.LinearLayoutManager; @@ -37,16 +38,15 @@ import retrofit2.Call; import retrofit2.Callback; import retrofit2.Response; -public class AccountFragment extends BaseFragment implements AccountActionListener { - private static final String TAG = "Account"; // logging tag - - private Call> listCall; +public class AccountListFragment extends BaseFragment implements AccountActionListener { + private static final String TAG = "AccountList"; // logging tag public enum Type { FOLLOWS, FOLLOWERS, BLOCKS, MUTES, + FOLLOW_REQUESTS, } private Type type; @@ -58,18 +58,18 @@ public class AccountFragment extends BaseFragment implements AccountActionListen private TabLayout.OnTabSelectedListener onTabSelectedListener; private MastodonAPI api; - public static AccountFragment newInstance(Type type) { + public static AccountListFragment newInstance(Type type) { Bundle arguments = new Bundle(); - AccountFragment fragment = new AccountFragment(); - arguments.putString("type", type.name()); + AccountListFragment fragment = new AccountListFragment(); + arguments.putSerializable("type", type); fragment.setArguments(arguments); return fragment; } - public static AccountFragment newInstance(Type type, String accountId) { + public static AccountListFragment newInstance(Type type, String accountId) { Bundle arguments = new Bundle(); - AccountFragment fragment = new AccountFragment(); - arguments.putString("type", type.name()); + AccountListFragment fragment = new AccountListFragment(); + arguments.putSerializable("type", type); arguments.putString("accountId", accountId); fragment.setArguments(arguments); return fragment; @@ -79,7 +79,7 @@ public class AccountFragment extends BaseFragment implements AccountActionListen public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); Bundle arguments = getArguments(); - type = Type.valueOf(arguments.getString("type")); + type = (Type) arguments.getSerializable("type"); accountId = arguments.getString("accountId"); api = null; } @@ -89,7 +89,7 @@ public class AccountFragment extends BaseFragment implements AccountActionListen public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - View rootView = inflater.inflate(R.layout.fragment_account, container, false); + View rootView = inflater.inflate(R.layout.fragment_account_list, container, false); Context context = getContext(); recyclerView = (RecyclerView) rootView.findViewById(R.id.recycler_view); @@ -105,6 +105,10 @@ public class AccountFragment extends BaseFragment implements AccountActionListen scrollListener = null; if (type == Type.BLOCKS) { adapter = new BlocksAdapter(this); + } else if (type == Type.MUTES) { + adapter = new MutesAdapter(this); + } else if (type == Type.FOLLOW_REQUESTS) { + adapter = new FollowRequestsAdapter(this); } else { adapter = new FollowAdapter(this); } @@ -154,12 +158,6 @@ public class AccountFragment extends BaseFragment implements AccountActionListen recyclerView.addOnScrollListener(scrollListener); } - @Override - public void onDestroy() { - super.onDestroy(); - if (listCall != null) listCall.cancel(); - } - @Override public void onDestroyView() { if (jumpToTopAllowed()) { @@ -186,6 +184,7 @@ public class AccountFragment extends BaseFragment implements AccountActionListen } }; + Call> listCall; switch (type) { default: case FOLLOWS: { @@ -204,6 +203,10 @@ public class AccountFragment extends BaseFragment implements AccountActionListen listCall = api.mutes(fromId, uptoId, null); break; } + case FOLLOW_REQUESTS: { + listCall = api.followRequests(fromId, uptoId, null); + break; + } } callList.add(listCall); listCall.enqueue(cb); @@ -236,12 +239,78 @@ public class AccountFragment extends BaseFragment implements AccountActionListen Log.e(TAG, "Fetch failure: " + exception.getMessage()); } + @Override public void onViewAccount(String id) { Intent intent = new Intent(getContext(), AccountActivity.class); intent.putExtra("id", id); startActivity(intent); } + @Override + public void onMute(final boolean mute, final String id, final int position) { + if (api == null) { + /* If somehow an unmute button is clicked after onCreateView but before + * onActivityCreated, then this would get called with a null api object, so this eats + * that input. */ + Log.d(TAG, "MastodonAPI isn't initialised so this mute can't occur."); + return; + } + + Callback callback = new Callback() { + @Override + public void onResponse(Call call, Response response) { + if (response.isSuccessful()) { + onMuteSuccess(mute, id, position); + } else { + onMuteFailure(mute, id); + } + } + + @Override + public void onFailure(Call call, Throwable t) { + onMuteFailure(mute, id); + } + }; + + Call call; + if (!mute) { + call = api.unmuteAccount(id); + } else { + call = api.muteAccount(id); + } + callList.add(call); + call.enqueue(callback); + } + + private void onMuteSuccess(boolean muted, final String id, final int position) { + if (muted) { + return; + } + final MutesAdapter mutesAdapter = (MutesAdapter) adapter; + final Account unmutedUser = mutesAdapter.removeItem(position); + View.OnClickListener listener = new View.OnClickListener() { + @Override + public void onClick(View v) { + mutesAdapter.addItem(unmutedUser, position); + onMute(true, id, position); + } + }; + Snackbar.make(recyclerView, R.string.confirmation_unmuted, Snackbar.LENGTH_LONG) + .setAction(R.string.action_undo, listener) + .show(); + } + + private void onMuteFailure(boolean mute, String id) { + String verb; + if (mute) { + verb = "mute"; + } else { + verb = "unmute"; + } + Log.e(TAG, String.format("Failed to %s account id %s", verb, id)); + } + + @Override public void onBlock(final boolean block, final String id, final int position) { if (api == null) { /* If somehow an unblock button is clicked after onCreateView but before @@ -255,7 +324,7 @@ public class AccountFragment extends BaseFragment implements AccountActionListen @Override public void onResponse(Call call, Response response) { if (response.isSuccessful()) { - onBlockSuccess(block, position); + onBlockSuccess(block, id, position); } else { onBlockFailure(block, id); } @@ -277,9 +346,22 @@ public class AccountFragment extends BaseFragment implements AccountActionListen call.enqueue(cb); } - private void onBlockSuccess(boolean blocked, int position) { - BlocksAdapter blocksAdapter = (BlocksAdapter) adapter; - blocksAdapter.setBlocked(blocked, position); + private void onBlockSuccess(boolean blocked, final String id, final int position) { + if (blocked) { + return; + } + final BlocksAdapter blocksAdapter = (BlocksAdapter) adapter; + final Account unblockedUser = blocksAdapter.removeItem(position); + View.OnClickListener listener = new View.OnClickListener() { + @Override + public void onClick(View v) { + blocksAdapter.addItem(unblockedUser, position); + onBlock(true, id, position); + } + }; + Snackbar.make(recyclerView, R.string.confirmation_unblocked, Snackbar.LENGTH_LONG) + .setAction(R.string.action_undo, listener) + .show(); } private void onBlockFailure(boolean block, String id) { @@ -292,8 +374,56 @@ public class AccountFragment extends BaseFragment implements AccountActionListen Log.e(TAG, String.format("Failed to %s account id %s", verb, id)); } + @Override + public void onRespondToFollowRequest(final boolean accept, final String accountId, + final int position) { + if (api == null) { + /* If somehow an response button is clicked after onCreateView but before + * onActivityCreated, then this would get called with a null api object, so this eats + * that input. */ + Log.d(TAG, "MastodonAPI isn't initialised, so follow requests can't be responded to."); + return; + } + + Callback callback = new Callback() { + @Override + public void onResponse(Call call, Response response) { + if (response.isSuccessful()) { + onRespondToFollowRequestSuccess(position); + } else { + onRespondToFollowRequestFailure(accept, accountId); + } + } + + @Override + public void onFailure(Call call, Throwable t) { + onRespondToFollowRequestFailure(accept, accountId); + } + }; + + Call call; + if (accept) { + call = api.authorizeFollowRequest(accountId); + } else { + call = api.rejectFollowRequest(accountId); + } + callList.add(call); + call.enqueue(callback); + } + + private void onRespondToFollowRequestSuccess(int position) { + FollowRequestsAdapter followRequestsAdapter = (FollowRequestsAdapter) adapter; + followRequestsAdapter.removeItem(position); + } + + private void onRespondToFollowRequestFailure(boolean accept, String accountId) { + String verb = (accept) ? "accept" : "reject"; + String message = String.format("Failed to %s account id %s.", verb, accountId); + Log.e(TAG, message); + } + private boolean jumpToTopAllowed() { - return type != Type.BLOCKS; + return type == Type.FOLLOWS || type == Type.FOLLOWERS; } private void jumpToTop() { diff --git a/app/src/main/java/com/keylesspalace/tusky/AccountPagerAdapter.java b/app/src/main/java/com/keylesspalace/tusky/AccountPagerAdapter.java index ff5000017..505d89bd2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/AccountPagerAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/AccountPagerAdapter.java @@ -24,15 +24,20 @@ import android.view.View; import android.view.ViewGroup; import android.widget.TextView; +import java.util.ArrayList; +import java.util.List; + class AccountPagerAdapter extends FragmentPagerAdapter { private Context context; private String accountId; private String[] pageTitles; + private List registeredFragments; AccountPagerAdapter(FragmentManager manager, Context context, String accountId) { super(manager); this.context = context; this.accountId = accountId; + registeredFragments = new ArrayList<>(); } void setPageTitles(String[] titles) { @@ -46,10 +51,10 @@ class AccountPagerAdapter extends FragmentPagerAdapter { return TimelineFragment.newInstance(TimelineFragment.Kind.USER, accountId); } case 1: { - return AccountFragment.newInstance(AccountFragment.Type.FOLLOWS, accountId); + return AccountListFragment.newInstance(AccountListFragment.Type.FOLLOWS, accountId); } case 2: { - return AccountFragment.newInstance(AccountFragment.Type.FOLLOWERS, accountId); + return AccountListFragment.newInstance(AccountListFragment.Type.FOLLOWERS, accountId); } default: { return null; @@ -73,4 +78,21 @@ class AccountPagerAdapter extends FragmentPagerAdapter { title.setText(pageTitles[position]); return view; } + + @Override + public Object instantiateItem(ViewGroup container, int position) { + Fragment fragment = (Fragment) super.instantiateItem(container, position); + registeredFragments.add(fragment); + return fragment; + } + + @Override + public void destroyItem(ViewGroup container, int position, Object object) { + registeredFragments.remove((Fragment) object); + super.destroyItem(container, position, object); + } + + List getRegisteredFragments() { + return registeredFragments; + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java index fd50ec569..5bbe7ba13 100644 --- a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java @@ -15,6 +15,8 @@ package com.keylesspalace.tusky; +import android.app.AlarmManager; +import android.app.PendingIntent; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; @@ -22,6 +24,7 @@ import android.graphics.Color; import android.graphics.PorterDuff; import android.graphics.drawable.Drawable; import android.os.Bundle; +import android.os.SystemClock; import android.preference.PreferenceManager; import android.support.annotation.Nullable; import android.support.v7.app.AppCompatActivity; @@ -29,7 +32,6 @@ import android.text.Spanned; import android.util.TypedValue; import android.view.Menu; -import com.google.firebase.iid.FirebaseInstanceId; import com.google.gson.Gson; import com.google.gson.GsonBuilder; @@ -46,16 +48,13 @@ import retrofit2.Callback; import retrofit2.Retrofit; import retrofit2.converter.gson.GsonConverterFactory; -/* There isn't presently a way to globally change the theme of a whole application at runtime, just - * individual activities. So, each activity has to set its theme before any views are created. And - * the most expedient way to accomplish this was to put it in a base class and just have every - * activity extend from it. */ public class BaseActivity extends AppCompatActivity { private static final String TAG = "BaseActivity"; // logging tag protected MastodonAPI mastodonAPI; protected TuskyAPI tuskyAPI; protected Dispatcher mastodonApiDispatcher; + protected PendingIntent serviceAlarmIntent; @Override protected void onCreate(@Nullable Bundle savedInstanceState) { @@ -65,6 +64,9 @@ public class BaseActivity extends AppCompatActivity { createMastodonAPI(); createTuskyAPI(); + /* There isn't presently a way to globally change the theme of a whole application at + * runtime, just individual activities. So, each activity has to set its theme before any + * views are created. */ if (PreferenceManager.getDefaultSharedPreferences(this).getBoolean("lightTheme", false)) { setTheme(R.style.AppTheme_Light); } @@ -96,8 +98,12 @@ public class BaseActivity extends AppCompatActivity { overridePendingTransition(R.anim.slide_from_left, R.anim.slide_to_right); } + protected SharedPreferences getPrivatePreferences() { + return getSharedPreferences(getString(R.string.preferences_file_key), Context.MODE_PRIVATE); + } + protected String getAccessToken() { - SharedPreferences preferences = getSharedPreferences(getString(R.string.preferences_file_key), Context.MODE_PRIVATE); + SharedPreferences preferences = getPrivatePreferences(); return preferences.getString("accessToken", null); } @@ -107,7 +113,7 @@ public class BaseActivity extends AppCompatActivity { } protected String getBaseUrl() { - SharedPreferences preferences = getSharedPreferences(getString(R.string.preferences_file_key), Context.MODE_PRIVATE); + SharedPreferences preferences = getPrivatePreferences(); return "https://" + preferences.getString("domain", null); } @@ -116,6 +122,7 @@ public class BaseActivity extends AppCompatActivity { Gson gson = new GsonBuilder() .registerTypeAdapter(Spanned.class, new SpannedTypeAdapter()) + .registerTypeAdapter(StringWithEmoji.class, new StringWithEmojiTypeAdapter()) .create(); OkHttpClient okHttpClient = OkHttpUtils.getCompatibleClientBuilder() @@ -148,17 +155,18 @@ public class BaseActivity extends AppCompatActivity { } protected void createTuskyAPI() { - Retrofit retrofit = new Retrofit.Builder() - .baseUrl(getString(R.string.tusky_api_url)) - .client(OkHttpUtils.getCompatibleClient()) - .build(); + if (BuildConfig.USES_PUSH_NOTIFICATIONS) { + Retrofit retrofit = new Retrofit.Builder() + .baseUrl(getString(R.string.tusky_api_url)) + .client(OkHttpUtils.getCompatibleClient()) + .build(); - tuskyAPI = retrofit.create(TuskyAPI.class); + tuskyAPI = retrofit.create(TuskyAPI.class); + } } protected void redirectIfNotLoggedIn() { - SharedPreferences preferences = getSharedPreferences( - getString(R.string.preferences_file_key), Context.MODE_PRIVATE); + SharedPreferences preferences = getPrivatePreferences(); String domain = preferences.getString("domain", null); String accessToken = preferences.getString("accessToken", null); if (domain == null || accessToken == null) { @@ -188,30 +196,49 @@ public class BaseActivity extends AppCompatActivity { } protected void enablePushNotifications() { - tuskyAPI.register(getBaseUrl(), getAccessToken(), FirebaseInstanceId.getInstance().getToken()).enqueue(new Callback() { - @Override - public void onResponse(Call call, retrofit2.Response response) { - Log.d(TAG, "Enable push notifications response: " + response.message()); - } + if (BuildConfig.USES_PUSH_NOTIFICATIONS) { + String token = com.google.firebase.iid.FirebaseInstanceId.getInstance().getToken(); + tuskyAPI.register(getBaseUrl(), getAccessToken(), token).enqueue(new Callback() { + @Override + public void onResponse(Call call, retrofit2.Response response) { + Log.d(TAG, "Enable push notifications response: " + response.message()); + } - @Override - public void onFailure(Call call, Throwable t) { - Log.d(TAG, "Enable push notifications failed: " + t.getMessage()); - } - }); + @Override + public void onFailure(Call call, Throwable t) { + Log.d(TAG, "Enable push notifications failed: " + t.getMessage()); + } + }); + } else { + // Start up the MessagingService on a repeating interval for "pull" notifications. + long checkInterval = 60 * 1000 * 5; + AlarmManager alarmManager = (AlarmManager) getSystemService(Context.ALARM_SERVICE); + Intent intent = new Intent(this, MessagingService.class); + final int SERVICE_REQUEST_CODE = 8574603; // This number is arbitrary. + serviceAlarmIntent = PendingIntent.getService(this, SERVICE_REQUEST_CODE, intent, + PendingIntent.FLAG_UPDATE_CURRENT); + alarmManager.setRepeating(AlarmManager.ELAPSED_REALTIME_WAKEUP, + SystemClock.elapsedRealtime(), checkInterval, serviceAlarmIntent); + } } protected void disablePushNotifications() { - tuskyAPI.unregister(getBaseUrl(), getAccessToken()).enqueue(new Callback() { - @Override - public void onResponse(Call call, retrofit2.Response response) { - Log.d(TAG, "Disable push notifications response: " + response.message()); - } + if (BuildConfig.USES_PUSH_NOTIFICATIONS) { + tuskyAPI.unregister(getBaseUrl(), getAccessToken()).enqueue(new Callback() { + @Override + public void onResponse(Call call, retrofit2.Response response) { + Log.d(TAG, "Disable push notifications response: " + response.message()); + } - @Override - public void onFailure(Call call, Throwable t) { - Log.d(TAG, "Disable push notifications failed: " + t.getMessage()); - } - }); + @Override + public void onFailure(Call call, Throwable t) { + Log.d(TAG, "Disable push notifications failed: " + t.getMessage()); + } + }); + } else if (serviceAlarmIntent != null) { + // Cancel the repeating call for "pull" notifications. + AlarmManager alarmManager = (AlarmManager) getSystemService(Context.ALARM_SERVICE); + alarmManager.cancel(serviceAlarmIntent); + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/BaseFragment.java b/app/src/main/java/com/keylesspalace/tusky/BaseFragment.java index 79569e82f..0a9dcbcf3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/BaseFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/BaseFragment.java @@ -15,6 +15,8 @@ package com.keylesspalace.tusky; +import android.content.Context; +import android.content.SharedPreferences; import android.os.Bundle; import android.support.annotation.Nullable; import android.support.v4.app.Fragment; @@ -40,4 +42,9 @@ public class BaseFragment extends Fragment { } super.onDestroy(); } + + protected SharedPreferences getPrivatePreferences() { + return getContext().getSharedPreferences( + getString(R.string.preferences_file_key), Context.MODE_PRIVATE); + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/BlocksAdapter.java b/app/src/main/java/com/keylesspalace/tusky/BlocksAdapter.java index c8c6abbf7..d052db34b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/BlocksAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/BlocksAdapter.java @@ -26,9 +26,6 @@ import com.keylesspalace.tusky.entity.Account; import com.pkmmte.view.CircularImageView; import com.squareup.picasso.Picasso; -import java.util.HashSet; -import java.util.Set; - import butterknife.BindView; import butterknife.ButterKnife; @@ -36,11 +33,8 @@ class BlocksAdapter extends AccountAdapter { private static final int VIEW_TYPE_BLOCKED_USER = 0; private static final int VIEW_TYPE_FOOTER = 1; - private Set unblockedAccountPositions; - BlocksAdapter(AccountActionListener accountActionListener) { super(accountActionListener); - unblockedAccountPositions = new HashSet<>(); } @Override @@ -65,8 +59,7 @@ class BlocksAdapter extends AccountAdapter { if (position < accountList.size()) { BlockedUserViewHolder holder = (BlockedUserViewHolder) viewHolder; holder.setupWithAccount(accountList.get(position)); - boolean blocked = !unblockedAccountPositions.contains(position); - holder.setupActionListener(accountActionListener, blocked, position); + holder.setupActionListener(accountActionListener, true); } } @@ -79,15 +72,6 @@ class BlocksAdapter extends AccountAdapter { } } - void setBlocked(boolean blocked, int position) { - if (blocked) { - unblockedAccountPositions.remove(position); - } else { - unblockedAccountPositions.add(position); - } - notifyItemChanged(position); - } - static class BlockedUserViewHolder extends RecyclerView.ViewHolder { @BindView(R.id.blocked_user_avatar) CircularImageView avatar; @BindView(R.id.blocked_user_username) TextView username; @@ -114,12 +98,14 @@ class BlocksAdapter extends AccountAdapter { .into(avatar); } - void setupActionListener(final AccountActionListener listener, final boolean blocked, - final int position) { + void setupActionListener(final AccountActionListener listener, final boolean blocked) { unblock.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { - listener.onBlock(!blocked, id, position); + int position = getAdapterPosition(); + if (position != RecyclerView.NO_POSITION) { + listener.onBlock(!blocked, id, position); + } } }); avatar.setOnClickListener(new View.OnClickListener() { diff --git a/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java b/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java index 5be683a2f..b05a0915f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java @@ -18,7 +18,6 @@ package com.keylesspalace.tusky; import android.Manifest; import android.app.ProgressDialog; import android.content.ContentResolver; -import android.content.Context; import android.content.DialogInterface; import android.content.Intent; import android.content.SharedPreferences; @@ -36,8 +35,10 @@ import android.net.Uri; import android.os.AsyncTask; import android.os.Build; import android.os.Bundle; +import android.os.Environment; import android.os.Parcel; import android.os.Parcelable; +import android.provider.MediaStore; import android.provider.OpenableColumns; import android.support.annotation.NonNull; import android.support.annotation.Nullable; @@ -48,6 +49,7 @@ import android.support.v13.view.inputmethod.InputConnectionCompat; import android.support.v13.view.inputmethod.InputContentInfoCompat; import android.support.v4.app.ActivityCompat; import android.support.v4.content.ContextCompat; +import android.support.v4.content.FileProvider; import android.support.v7.app.ActionBar; import android.support.v7.content.res.AppCompatResources; import android.support.v7.widget.Toolbar; @@ -77,9 +79,11 @@ import com.keylesspalace.tusky.entity.Media; import com.keylesspalace.tusky.entity.Status; import java.io.ByteArrayOutputStream; +import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; +import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Arrays; import java.util.Date; @@ -99,8 +103,11 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFrag private static final int STATUS_CHARACTER_LIMIT = 500; private static final int STATUS_MEDIA_SIZE_LIMIT = 4000000; // 4MB private static final int MEDIA_PICK_RESULT = 1; + private static final int MEDIA_TAKE_PHOTO_RESULT = 2; private static final int PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE = 1; private static final int MEDIA_SIZE_UNKNOWN = -1; + private static final int COMPOSE_SUCCESS = -1; + private static final int THUMBNAIL_SIZE = 128; // pixels private String inReplyToId; private EditText textEditor; @@ -120,8 +127,11 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFrag private TextView charactersLeft; private Button floatingBtn; private ImageButton pickBtn; + private ImageButton takeBtn; private Button nsfwBtn; private ProgressBar postProgress; + private ImageButton visibilityBtn; + private Uri photoUploadUri; private static class QueuedMedia { enum Type { @@ -335,23 +345,17 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFrag actionBar.setHomeAsUpIndicator(closeIcon); } - SharedPreferences preferences = getSharedPreferences( - getString(R.string.preferences_file_key), Context.MODE_PRIVATE); + SharedPreferences preferences = getPrivatePreferences(); floatingBtn = (Button) findViewById(R.id.floating_btn); pickBtn = (ImageButton) findViewById(R.id.compose_photo_pick); + takeBtn = (ImageButton) findViewById(R.id.compose_photo_take); nsfwBtn = (Button) findViewById(R.id.action_toggle_nsfw); - final ImageButton visibilityBtn = (ImageButton) findViewById(R.id.action_toggle_visibility); + visibilityBtn = (ImageButton) findViewById(R.id.action_toggle_visibility); floatingBtn.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { - pickBtn.setClickable(false); - nsfwBtn.setClickable(false); - visibilityBtn.setClickable(false); - floatingBtn.setEnabled(false); - - postProgress.setVisibility(View.VISIBLE); sendStatus(); } }); @@ -361,6 +365,12 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFrag onMediaPick(); } }); + takeBtn.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + initiateCameraApp(); + } + }); nsfwBtn.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { @@ -567,26 +577,69 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFrag } } + private void disableButtons() { + pickBtn.setClickable(false); + takeBtn.setClickable(false); + nsfwBtn.setClickable(false); + visibilityBtn.setClickable(false); + floatingBtn.setEnabled(false); + } + + private void enableButtons() { + pickBtn.setClickable(true); + takeBtn.setClickable(true); + nsfwBtn.setClickable(true); + visibilityBtn.setClickable(true); + floatingBtn.setEnabled(true); + } + + private void addLockToSendButton() { + floatingBtn.setText(R.string.action_send); + Drawable lock = AppCompatResources.getDrawable(this, R.drawable.send_private); + if (lock != null) { + lock.setBounds(0, 0, lock.getIntrinsicWidth(), lock.getIntrinsicHeight()); + floatingBtn.setCompoundDrawables(null, null, lock, null); + } + } + private void setStatusVisibility(String visibility) { statusVisibility = visibility; switch (visibility) { case "public": { floatingBtn.setText(R.string.action_send_public); floatingBtn.setCompoundDrawables(null, null, null, null); - break; - } - case "private": { - floatingBtn.setText(R.string.action_send); - Drawable lock = AppCompatResources.getDrawable(this, R.drawable.send_private); - if (lock != null) { - lock.setBounds(0, 0, lock.getIntrinsicWidth(), lock.getIntrinsicHeight()); - floatingBtn.setCompoundDrawables(null, null, lock, null); + Drawable globe = AppCompatResources.getDrawable(this, R.drawable.ic_public_24dp); + if (globe != null) { + visibilityBtn.setImageDrawable(globe); } break; } + case "private": { + addLockToSendButton(); + Drawable lock = AppCompatResources.getDrawable(this, + R.drawable.ic_lock_outline_24dp); + if (lock != null) { + visibilityBtn.setImageDrawable(lock); + } + break; + } + case "direct": { + addLockToSendButton(); + Drawable envelope = AppCompatResources.getDrawable(this, R.drawable.ic_email_24dp); + if (envelope != null) { + visibilityBtn.setImageDrawable(envelope); + } + break; + } + case "unlisted": default: { floatingBtn.setText(R.string.action_send); floatingBtn.setCompoundDrawables(null, null, null, null); + Drawable openLock = AppCompatResources.getDrawable(this, + R.drawable.ic_lock_open_24dp); + if (openLock != null) { + visibilityBtn.setImageDrawable(openLock); + } break; } } @@ -615,6 +668,18 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFrag updateVisibleCharactersLeft(); } + void setStateToReadying() { + statusAlreadyInFlight = true; + disableButtons(); + postProgress.setVisibility(View.VISIBLE); + } + + void setStateToNotReadying() { + postProgress.setVisibility(View.INVISIBLE); + statusAlreadyInFlight = false; + enableButtons(); + } + private void sendStatus() { if (statusAlreadyInFlight) { return; @@ -624,9 +689,12 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFrag if (statusHideText) { spoilerText = contentWarningEditor.getText().toString(); } - if (contentText.length() + spoilerText.length() <= STATUS_CHARACTER_LIMIT) { - statusAlreadyInFlight = true; + int characterCount = contentText.length() + spoilerText.length(); + if (characterCount > 0 && characterCount <= STATUS_CHARACTER_LIMIT) { + setStateToReadying(); readyStatus(contentText, statusVisibility, statusMarkSensitive, spoilerText); + } else if (characterCount <= 0) { + textEditor.setError(getString(R.string.error_empty)); } else { textEditor.setError(getString(R.string.error_compose_character_limit)); } @@ -685,11 +753,9 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFrag * the status they reply to and that behaviour needs to be kept separate. */ return; } - SharedPreferences preferences = getSharedPreferences( - getString(R.string.preferences_file_key), Context.MODE_PRIVATE); - SharedPreferences.Editor editor = preferences.edit(); - editor.putString("rememberedVisibility", statusVisibility); - editor.apply(); + getPrivatePreferences().edit() + .putString("rememberedVisibility", statusVisibility) + .apply(); } private EditText createEditText(String[] contentMimeTypes) { @@ -719,7 +785,7 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFrag ViewGroup.LayoutParams layoutParams = new ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); editText.setLayoutParams(layoutParams); - editText.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE); + editText.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE | InputType.TYPE_TEXT_FLAG_CAP_SENTENCES); editText.setEms(10); editText.setBackgroundColor(0); editText.setGravity(Gravity.START | Gravity.TOP); @@ -816,13 +882,13 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFrag private void onSendSuccess() { Snackbar bar = Snackbar.make(findViewById(R.id.activity_compose), getString(R.string.confirmation_send), Snackbar.LENGTH_SHORT); bar.show(); + setResult(COMPOSE_SUCCESS); finish(); } private void onSendFailure() { - postProgress.setVisibility(View.INVISIBLE); textEditor.setError(getString(R.string.error_generic)); - statusAlreadyInFlight = false; + setStateToNotReadying(); } private void readyStatus(final String content, final String visibility, final boolean sensitive, @@ -857,7 +923,7 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFrag @Override protected void onCancelled() { removeAllMediaFromQueue(); - statusAlreadyInFlight = false; + setStateToNotReadying(); super.onCancelled(); } }; @@ -882,7 +948,7 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFrag readyStatus(content, visibility, sensitive, spoilerText); } }); - statusAlreadyInFlight = false; + setStateToNotReadying(); } private void onMediaPick() { @@ -919,6 +985,41 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFrag } } + private File createNewImageFile() throws IOException { + // Create an image file name + String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(new Date()); + String imageFileName = "Tusky_" + timeStamp + "_"; + File storageDir = getExternalFilesDir(Environment.DIRECTORY_PICTURES); + return File.createTempFile( + imageFileName, /* prefix */ + ".jpg", /* suffix */ + storageDir /* directory */ + ); + } + + private void initiateCameraApp() { + // We don't need to ask for permission in this case, because the used calls require + // android.permission.WRITE_EXTERNAL_STORAGE only on SDKs *older* than Kitkat, which was + // way before permission dialogues have been introduced. + Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); + if (intent.resolveActivity(getPackageManager()) != null) { + File photoFile = null; + try { + photoFile = createNewImageFile(); + } catch (IOException ex) { + displayTransientError(R.string.error_media_upload_opening); + } + // Continue only if the File was successfully created + if (photoFile != null) { + photoUploadUri = FileProvider.getUriForFile(this, + "com.keylesspalace.tusky.fileprovider", + photoFile); + intent.putExtra(MediaStore.EXTRA_OUTPUT, photoUploadUri); + startActivityForResult(intent, MEDIA_TAKE_PHOTO_RESULT); + } + } + } + private void initiateMediaPicking() { Intent intent = new Intent(Intent.ACTION_GET_CONTENT); intent.addCategory(Intent.CATEGORY_OPENABLE); @@ -932,16 +1033,22 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFrag startActivityForResult(intent, MEDIA_PICK_RESULT); } - private void enableMediaPicking() { + private void enableMediaButtons() { pickBtn.setEnabled(true); ThemeUtils.setDrawableTint(this, pickBtn.getDrawable(), R.attr.compose_media_button_tint); + takeBtn.setEnabled(true); + ThemeUtils.setDrawableTint(this, takeBtn.getDrawable(), + R.attr.compose_media_button_tint); } - private void disableMediaPicking() { + private void disableMediaButtons() { pickBtn.setEnabled(false); ThemeUtils.setDrawableTint(this, pickBtn.getDrawable(), R.attr.compose_media_button_disabled_tint); + takeBtn.setEnabled(false); + ThemeUtils.setDrawableTint(this, takeBtn.getDrawable(), + R.attr.compose_media_button_disabled_tint); } private void addMediaToQueue(QueuedMedia.Type type, Bitmap preview, Uri uri, long mediaSize) { @@ -976,11 +1083,11 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFrag textEditor.getPaddingRight(), totalHeight); // If there's one video in the queue it is full, so disable the button to queue more. if (item.type == QueuedMedia.Type.VIDEO) { - disableMediaPicking(); + disableMediaButtons(); } } else if (queuedCount >= Status.MAX_MEDIA_ATTACHMENTS) { // Limit the total media attachments, also. - disableMediaPicking(); + disableMediaButtons(); } if (queuedCount >= 1) { showMarkSensitive(true); @@ -1003,7 +1110,7 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFrag textEditor.setPadding(textEditor.getPaddingLeft(), textEditor.getPaddingTop(), textEditor.getPaddingRight(), 0); } - enableMediaPicking(); + enableMediaButtons(); cancelReadyingMedia(item); } @@ -1164,6 +1271,9 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFrag Uri uri = data.getData(); long mediaSize = getMediaSize(getContentResolver(), uri); pickMedia(uri, mediaSize); + } else if (requestCode == MEDIA_TAKE_PHOTO_RESULT && resultCode == RESULT_OK) { + long mediaSize = getMediaSize(getContentResolver(), photoUploadUri); + pickMedia(photoUploadUri, mediaSize); } } @@ -1190,7 +1300,7 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFrag MediaMetadataRetriever retriever = new MediaMetadataRetriever(); retriever.setDataSource(this, uri); Bitmap source = retriever.getFrameAtTime(); - Bitmap bitmap = ThumbnailUtils.extractThumbnail(source, 128, 128); + Bitmap bitmap = ThumbnailUtils.extractThumbnail(source, THUMBNAIL_SIZE, THUMBNAIL_SIZE); source.recycle(); addMediaToQueue(QueuedMedia.Type.VIDEO, bitmap, uri, mediaSize); break; @@ -1203,8 +1313,9 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFrag displayTransientError(R.string.error_media_upload_opening); return; } + Bitmap source = BitmapFactory.decodeStream(stream); - Bitmap bitmap = ThumbnailUtils.extractThumbnail(source, 128, 128); + Bitmap bitmap = ThumbnailUtils.extractThumbnail(source, THUMBNAIL_SIZE, THUMBNAIL_SIZE); source.recycle(); try { if (stream != null) { diff --git a/app/src/main/java/com/keylesspalace/tusky/ComposeOptionsFragment.java b/app/src/main/java/com/keylesspalace/tusky/ComposeOptionsFragment.java index 25dbd4b81..3f3e1c0be 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ComposeOptionsFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/ComposeOptionsFragment.java @@ -73,10 +73,14 @@ public class ComposeOptionsFragment extends BottomSheetDialogFragment { radioCheckedId = R.id.radio_unlisted; } if (statusVisibility != null) { - if (statusVisibility.equals("unlisted")) { - radioCheckedId = R.id.radio_unlisted; + if (statusVisibility.equals("public")) { + radioCheckedId = R.id.radio_public; } else if (statusVisibility.equals("private")) { radioCheckedId = R.id.radio_private; + } else if (statusVisibility.equals("unlisted")) { + radioCheckedId = R.id.radio_unlisted; + } else if (statusVisibility.equals("direct")) { + radioCheckedId = R.id.radio_direct; } } radio.check(radioCheckedId); @@ -113,6 +117,10 @@ public class ComposeOptionsFragment extends BottomSheetDialogFragment { visibility = "private"; break; } + case R.id.radio_direct: { + visibility = "direct"; + break; + } } listener.onVisibilityChanged(visibility); } diff --git a/app/src/main/java/com/keylesspalace/tusky/CustomTabURLSpan.java b/app/src/main/java/com/keylesspalace/tusky/CustomTabURLSpan.java index dce7d45c4..7af9a84fe 100644 --- a/app/src/main/java/com/keylesspalace/tusky/CustomTabURLSpan.java +++ b/app/src/main/java/com/keylesspalace/tusky/CustomTabURLSpan.java @@ -54,7 +54,7 @@ class CustomTabURLSpan extends URLSpan { customTabsIntent.launchUrl(context, uri); } } catch (ActivityNotFoundException e) { - android.util.Log.w("URLSpan", "Activity was not found for intent, " + customTabsIntent.toString()); + Log.w("URLSpan", "Activity was not found for intent, " + customTabsIntent.toString()); } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/DownsizeImageTask.java b/app/src/main/java/com/keylesspalace/tusky/DownsizeImageTask.java index e9fcc11a9..4e0d77db9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/DownsizeImageTask.java +++ b/app/src/main/java/com/keylesspalace/tusky/DownsizeImageTask.java @@ -21,6 +21,7 @@ import android.graphics.BitmapFactory; import android.graphics.Matrix; import android.net.Uri; import android.os.AsyncTask; +import android.support.annotation.Nullable; import android.support.media.ExifInterface; import java.io.ByteArrayOutputStream; @@ -42,6 +43,7 @@ class DownsizeImageTask extends AsyncTask { this.listener = listener; } + @Nullable private static Bitmap reorientBitmap(Bitmap bitmap, int orientation) { Matrix matrix = new Matrix(); switch (orientation) { diff --git a/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.java b/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.java new file mode 100644 index 000000000..f0313f946 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.java @@ -0,0 +1,515 @@ +/* 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.Manifest; +import android.content.ContentResolver; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Build; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.design.widget.Snackbar; +import android.support.v4.app.ActivityCompat; +import android.support.v4.content.ContextCompat; +import android.support.v7.app.ActionBar; +import android.support.v7.widget.Toolbar; +import android.util.Base64; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.widget.Button; +import android.widget.EditText; +import android.widget.ImageView; +import android.widget.ProgressBar; + +import com.keylesspalace.tusky.entity.Account; +import com.keylesspalace.tusky.entity.Profile; +import com.theartofdev.edmodo.cropper.CropImage; + +import java.io.ByteArrayOutputStream; +import java.io.FileNotFoundException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; + +import butterknife.BindView; +import butterknife.ButterKnife; +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; + +public class EditProfileActivity extends BaseActivity { + private static final String TAG = "EditProfileActivity"; + private static final int AVATAR_PICK_RESULT = 1; + private static final int HEADER_PICK_RESULT = 2; + private static final int PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE = 1; + private static final int AVATAR_WIDTH = 120; + private static final int AVATAR_HEIGHT = 120; + private static final int HEADER_WIDTH = 700; + private static final int HEADER_HEIGHT = 335; + + private enum PickType { + NOTHING, + AVATAR, + HEADER + } + + @BindView(R.id.edit_profile_display_name) EditText displayNameEditText; + @BindView(R.id.edit_profile_note) EditText noteEditText; + @BindView(R.id.edit_profile_avatar) Button avatarButton; + @BindView(R.id.edit_profile_avatar_preview) ImageView avatarPreview; + @BindView(R.id.edit_profile_avatar_progress) ProgressBar avatarProgress; + @BindView(R.id.edit_profile_header) Button headerButton; + @BindView(R.id.edit_profile_header_preview) ImageView headerPreview; + @BindView(R.id.edit_profile_header_progress) ProgressBar headerProgress; + @BindView(R.id.edit_profile_save_progress) ProgressBar saveProgress; + + private String priorDisplayName; + private String priorNote; + private boolean isAlreadySaving; + private PickType currentlyPicking; + private String avatarBase64; + private String headerBase64; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_edit_profile); + ButterKnife.bind(this); + + Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); + setSupportActionBar(toolbar); + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.setTitle(null); + actionBar.setDisplayHomeAsUpEnabled(true); + actionBar.setDisplayShowHomeEnabled(true); + } + + if (savedInstanceState != null) { + priorDisplayName = savedInstanceState.getString("priorDisplayName"); + priorNote = savedInstanceState.getString("priorNote"); + isAlreadySaving = savedInstanceState.getBoolean("isAlreadySaving"); + currentlyPicking = (PickType) savedInstanceState.getSerializable("currentlyPicking"); + avatarBase64 = savedInstanceState.getString("avatarBase64"); + headerBase64 = savedInstanceState.getString("headerBase64"); + } else { + priorDisplayName = null; + priorNote = null; + isAlreadySaving = false; + currentlyPicking = PickType.NOTHING; + avatarBase64 = null; + headerBase64 = null; + } + + avatarButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + onMediaPick(PickType.AVATAR); + } + }); + headerButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + onMediaPick(PickType.HEADER); + } + }); + + avatarPreview.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + avatarPreview.setImageBitmap(null); + avatarPreview.setVisibility(View.GONE); + avatarBase64 = null; + } + }); + headerPreview.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + headerPreview.setImageBitmap(null); + headerPreview.setVisibility(View.GONE); + headerBase64 = null; + } + }); + + mastodonAPI.accountVerifyCredentials().enqueue(new Callback() { + @Override + public void onResponse(Call call, Response response) { + if (!response.isSuccessful()) { + onAccountVerifyCredentialsFailed(); + return; + } + Account me = response.body(); + priorDisplayName = me.getDisplayName(); + priorNote = me.note.toString(); + displayNameEditText.setText(priorDisplayName); + noteEditText.setText(priorNote); + } + + @Override + public void onFailure(Call call, Throwable t) { + onAccountVerifyCredentialsFailed(); + } + }); + } + + @Override + protected void onSaveInstanceState(Bundle outState) { + outState.putString("priorDisplayName", priorDisplayName); + outState.putString("priorNote", priorNote); + outState.putBoolean("isAlreadySaving", isAlreadySaving); + outState.putSerializable("currentlyPicking", currentlyPicking); + outState.putString("avatarBase64", avatarBase64); + outState.putString("headerBase64", headerBase64); + super.onSaveInstanceState(outState); + } + + private void onAccountVerifyCredentialsFailed() { + Log.e(TAG, "The account failed to load."); + } + + private void onMediaPick(PickType pickType) { + if (currentlyPicking != PickType.NOTHING) { + // Ignore inputs if another pick operation is still occurring. + return; + } + currentlyPicking = pickType; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN && + ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) + != PackageManager.PERMISSION_GRANTED) { + ActivityCompat.requestPermissions(this, + new String[] { Manifest.permission.READ_EXTERNAL_STORAGE }, + PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE); + } else { + initiateMediaPicking(); + } + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String permissions[], + @NonNull int[] grantResults) { + switch (requestCode) { + case PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE: { + if (grantResults.length > 0 + && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + initiateMediaPicking(); + } else { + endMediaPicking(); + Snackbar.make(avatarButton, R.string.error_media_upload_permission, + Snackbar.LENGTH_LONG).show(); + } + break; + } + } + } + + private void initiateMediaPicking() { + Intent intent = new Intent(Intent.ACTION_GET_CONTENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType("image/*"); + switch (currentlyPicking) { + case AVATAR: { startActivityForResult(intent, AVATAR_PICK_RESULT); break; } + case HEADER: { startActivityForResult(intent, HEADER_PICK_RESULT); break; } + } + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.edit_profile_toolbar, menu); + return super.onCreateOptionsMenu(menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: { + onBackPressed(); + return true; + } + case R.id.action_save: { + save(); + return true; + } + } + return super.onOptionsItemSelected(item); + } + + private void save() { + if (isAlreadySaving || currentlyPicking != PickType.NOTHING) { + return; + } + String newDisplayName = displayNameEditText.getText().toString(); + if (newDisplayName.isEmpty()) { + displayNameEditText.setError(getString(R.string.error_empty)); + return; + } + if (priorDisplayName != null && priorDisplayName.equals(newDisplayName)) { + // If it's not any different, don't patch it. + newDisplayName = null; + } + + String newNote = noteEditText.getText().toString(); + if (newNote.isEmpty()) { + noteEditText.setError(getString(R.string.error_empty)); + return; + } + if (priorNote != null && priorNote.equals(newNote)) { + // If it's not any different, don't patch it. + newNote = null; + } + if (newDisplayName == null && newNote == null && avatarBase64 == null + && headerBase64 == null) { + // If nothing is changed, then there's nothing to save. + return; + } + + saveProgress.setVisibility(View.VISIBLE); + + isAlreadySaving = true; + + Profile profile = new Profile(); + profile.displayName = newDisplayName; + profile.note = newNote; + profile.avatar = avatarBase64; + profile.header = headerBase64; + mastodonAPI.accountUpdateCredentials(profile).enqueue(new Callback() { + @Override + public void onResponse(Call call, Response response) { + if (!response.isSuccessful()) { + onSaveFailure(); + return; + } + getPrivatePreferences().edit() + .putBoolean("refreshProfileHeader", true) + .apply(); + finish(); + } + + @Override + public void onFailure(Call call, Throwable t) { + onSaveFailure(); + } + }); + } + + private void onSaveFailure() { + isAlreadySaving = false; + Snackbar.make(avatarButton, R.string.error_media_upload_sending, Snackbar.LENGTH_LONG) + .show(); + saveProgress.setVisibility(View.GONE); + } + + private void beginMediaPicking() { + switch (currentlyPicking) { + case AVATAR: { + avatarProgress.setVisibility(View.VISIBLE); + avatarPreview.setVisibility(View.INVISIBLE); + break; + } + case HEADER: { + headerProgress.setVisibility(View.VISIBLE); + headerPreview.setVisibility(View.INVISIBLE); + break; + } + } + } + + private void endMediaPicking() { + switch (currentlyPicking) { + case AVATAR: { + avatarProgress.setVisibility(View.GONE); + avatarPreview.setVisibility(View.GONE); + break; + } + case HEADER: { + headerProgress.setVisibility(View.GONE); + headerPreview.setVisibility(View.GONE); + break; + } + } + currentlyPicking = PickType.NOTHING; + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + switch (requestCode) { + case AVATAR_PICK_RESULT: { + if (resultCode == RESULT_OK && data != null) { + CropImage.activity(data.getData()) + .setInitialCropWindowPaddingRatio(0) + .setAspectRatio(AVATAR_WIDTH, AVATAR_HEIGHT) + .start(this); + } else { + endMediaPicking(); + } + break; + } + case HEADER_PICK_RESULT: { + if (resultCode == RESULT_OK && data != null) { + CropImage.activity(data.getData()) + .setInitialCropWindowPaddingRatio(0) + .setAspectRatio(HEADER_WIDTH, HEADER_HEIGHT) + .start(this); + } else { + endMediaPicking(); + } + break; + } + case CropImage.CROP_IMAGE_ACTIVITY_REQUEST_CODE: { + CropImage.ActivityResult result = CropImage.getActivityResult(data); + if (resultCode == RESULT_OK) { + beginResize(result.getUri()); + } else if (resultCode == CropImage.CROP_IMAGE_ACTIVITY_RESULT_ERROR_CODE) { + onResizeFailure(); + } + break; + } + } + } + + private void beginResize(Uri uri) { + beginMediaPicking(); + int width, height; + switch (currentlyPicking) { + default: { + throw new AssertionError("PickType not set."); + } + case AVATAR: { + width = AVATAR_WIDTH; + height = AVATAR_HEIGHT; + break; + } + case HEADER: { + width = HEADER_WIDTH; + height = HEADER_HEIGHT; + break; + } + } + new ResizeImageTask(getContentResolver(), width, height, new ResizeImageTask.Listener() { + @Override + public void onSuccess(List contentList) { + Bitmap bitmap = contentList.get(0); + PickType pickType = currentlyPicking; + endMediaPicking(); + switch (pickType) { + case AVATAR: { + avatarPreview.setImageBitmap(bitmap); + avatarPreview.setVisibility(View.VISIBLE); + avatarBase64 = bitmapToBase64(bitmap); + break; + } + case HEADER: { + headerPreview.setImageBitmap(bitmap); + headerPreview.setVisibility(View.VISIBLE); + headerBase64 = bitmapToBase64(bitmap); + break; + } + } + } + + @Override + public void onFailure() { + onResizeFailure(); + } + }).execute(uri); + } + + private void onResizeFailure() { + Snackbar.make(avatarButton, R.string.error_media_upload_sending, Snackbar.LENGTH_LONG) + .show(); + endMediaPicking(); + } + + private static String bitmapToBase64(Bitmap bitmap) { + ByteArrayOutputStream stream = new ByteArrayOutputStream(); + bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream); + byte[] byteArray = stream.toByteArray(); + IOUtils.closeQuietly(stream); + return "data:image/png;base64," + Base64.encodeToString(byteArray, Base64.DEFAULT); + } + + private static class ResizeImageTask extends AsyncTask { + private ContentResolver contentResolver; + private int resizeWidth; + private int resizeHeight; + private Listener listener; + private List resultList; + + ResizeImageTask(ContentResolver contentResolver, int width, int height, Listener listener) { + this.contentResolver = contentResolver; + this.resizeWidth = width; + this.resizeHeight = height; + this.listener = listener; + } + + @Override + protected Boolean doInBackground(Uri... uris) { + resultList = new ArrayList<>(); + for (Uri uri : uris) { + InputStream inputStream; + try { + inputStream = contentResolver.openInputStream(uri); + } catch (FileNotFoundException e) { + return false; + } + Bitmap sourceBitmap; + try { + sourceBitmap = BitmapFactory.decodeStream(inputStream, null, null); + } catch (OutOfMemoryError error) { + return false; + } finally { + IOUtils.closeQuietly(inputStream); + } + if (sourceBitmap == null) { + return false; + } + Bitmap bitmap = Bitmap.createScaledBitmap(sourceBitmap, resizeWidth, resizeHeight, + false); + sourceBitmap.recycle(); + if (bitmap == null) { + return false; + } + resultList.add(bitmap); + if (isCancelled()) { + return false; + } + } + return true; + } + + @Override + protected void onPostExecute(Boolean successful) { + if (successful) { + listener.onSuccess(resultList); + } else { + listener.onFailure(); + } + super.onPostExecute(successful); + } + + interface Listener { + void onSuccess(List contentList); + void onFailure(); + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/FollowRequestsAdapter.java b/app/src/main/java/com/keylesspalace/tusky/FollowRequestsAdapter.java new file mode 100644 index 000000000..0a54b558b --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/FollowRequestsAdapter.java @@ -0,0 +1,129 @@ +/* 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.support.v7.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageButton; +import android.widget.TextView; + +import com.keylesspalace.tusky.entity.Account; +import com.pkmmte.view.CircularImageView; +import com.squareup.picasso.Picasso; + +import butterknife.BindView; +import butterknife.ButterKnife; + +class FollowRequestsAdapter extends AccountAdapter { + private static final int VIEW_TYPE_FOLLOW_REQUEST = 0; + private static final int VIEW_TYPE_FOOTER = 1; + + FollowRequestsAdapter(AccountActionListener accountActionListener) { + super(accountActionListener); + } + + @Override + public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + switch (viewType) { + default: + case VIEW_TYPE_FOLLOW_REQUEST: { + View view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.item_follow_request, parent, false); + return new FollowRequestViewHolder(view); + } + case VIEW_TYPE_FOOTER: { + View view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.item_footer, parent, false); + return new FooterViewHolder(view); + } + } + } + + @Override + public void onBindViewHolder(RecyclerView.ViewHolder viewHolder, int position) { + if (position < accountList.size()) { + FollowRequestViewHolder holder = (FollowRequestViewHolder) viewHolder; + holder.setupWithAccount(accountList.get(position)); + holder.setupActionListener(accountActionListener); + } + } + + @Override + public int getItemViewType(int position) { + if (position == accountList.size()) { + return VIEW_TYPE_FOOTER; + } else { + return VIEW_TYPE_FOLLOW_REQUEST; + } + } + + static class FollowRequestViewHolder extends RecyclerView.ViewHolder { + @BindView(R.id.follow_request_avatar) CircularImageView avatar; + @BindView(R.id.follow_request_username) TextView username; + @BindView(R.id.follow_request_display_name) TextView displayName; + @BindView(R.id.follow_request_accept) ImageButton accept; + @BindView(R.id.follow_request_reject) ImageButton reject; + + private String id; + + FollowRequestViewHolder(View itemView) { + super(itemView); + ButterKnife.bind(this, itemView); + } + + void setupWithAccount(Account account) { + id = account.id; + displayName.setText(account.getDisplayName()); + String format = username.getContext().getString(R.string.status_username_format); + String formattedUsername = String.format(format, account.username); + username.setText(formattedUsername); + Picasso.with(avatar.getContext()) + .load(account.avatar) + .error(R.drawable.avatar_error) + .placeholder(R.drawable.avatar_default) + .into(avatar); + } + + void setupActionListener(final AccountActionListener listener) { + accept.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + int position = getAdapterPosition(); + if (position != RecyclerView.NO_POSITION) { + listener.onRespondToFollowRequest(true, id, position); + } + } + }); + reject.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + int position = getAdapterPosition(); + if (position != RecyclerView.NO_POSITION) { + listener.onRespondToFollowRequest(false, id, position); + } + } + }); + avatar.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + listener.onViewAccount(id); + } + }); + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/FooterViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/FooterViewHolder.java index 6ebe217ff..e39ca44af 100644 --- a/app/src/main/java/com/keylesspalace/tusky/FooterViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/FooterViewHolder.java @@ -23,6 +23,8 @@ class FooterViewHolder extends RecyclerView.ViewHolder { FooterViewHolder(View itemView) { super(itemView); ProgressBar progressBar = (ProgressBar) itemView.findViewById(R.id.footer_progress_bar); - progressBar.setIndeterminate(true); + if (progressBar != null) { + progressBar.setIndeterminate(true); + } } } \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/IOUtils.java b/app/src/main/java/com/keylesspalace/tusky/IOUtils.java index 72c15160e..76e53b822 100644 --- a/app/src/main/java/com/keylesspalace/tusky/IOUtils.java +++ b/app/src/main/java/com/keylesspalace/tusky/IOUtils.java @@ -19,6 +19,7 @@ import android.support.annotation.Nullable; import java.io.IOException; import java.io.InputStream; +import java.io.OutputStream; class IOUtils { static void closeQuietly(@Nullable InputStream stream) { @@ -30,4 +31,14 @@ class IOUtils { // intentionally unhandled } } + + static void closeQuietly(@Nullable OutputStream stream) { + try { + if (stream != null) { + stream.close(); + } + } catch (IOException e) { + // intentionally unhandled + } + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/LinkHelper.java b/app/src/main/java/com/keylesspalace/tusky/LinkHelper.java new file mode 100644 index 000000000..ba9a591fe --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/LinkHelper.java @@ -0,0 +1,82 @@ +/* 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.preference.PreferenceManager; +import android.support.annotation.Nullable; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.method.LinkMovementMethod; +import android.text.style.ClickableSpan; +import android.text.style.URLSpan; +import android.view.View; +import android.widget.TextView; + +import com.keylesspalace.tusky.entity.Status; + +class LinkHelper { + static void setClickableText(TextView view, Spanned content, + @Nullable Status.Mention[] mentions, + final LinkListener listener) { + SpannableStringBuilder builder = new SpannableStringBuilder(content); + boolean useCustomTabs = PreferenceManager.getDefaultSharedPreferences(view.getContext()) + .getBoolean("customTabs", true); + URLSpan[] urlSpans = content.getSpans(0, content.length(), URLSpan.class); + for (URLSpan span : urlSpans) { + int start = builder.getSpanStart(span); + int end = builder.getSpanEnd(span); + int flags = builder.getSpanFlags(span); + CharSequence text = builder.subSequence(start, end); + if (text.charAt(0) == '#') { + final String tag = text.subSequence(1, text.length()).toString(); + ClickableSpan newSpan = new ClickableSpan() { + @Override + public void onClick(View widget) { + listener.onViewTag(tag); + } + }; + builder.removeSpan(span); + builder.setSpan(newSpan, start, end, flags); + } else if (text.charAt(0) == '@' && mentions != null) { + final String accountUsername = text.subSequence(1, text.length()).toString(); + String id = null; + for (Status.Mention mention : mentions) { + if (mention.localUsername.equals(accountUsername)) { + id = mention.id; + } + } + if (id != null) { + final String accountId = id; + ClickableSpan newSpan = new ClickableSpan() { + @Override + public void onClick(View widget) { + listener.onViewAccount(accountId); + } + }; + builder.removeSpan(span); + builder.setSpan(newSpan, start, end, flags); + } + } else if (useCustomTabs) { + ClickableSpan newSpan = new CustomTabURLSpan(span.getURL()); + builder.removeSpan(span); + builder.setSpan(newSpan, start, end, flags); + } + } + view.setText(builder); + view.setLinksClickable(true); + view.setMovementMethod(LinkMovementMethod.getInstance()); + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/LinkListener.java b/app/src/main/java/com/keylesspalace/tusky/LinkListener.java new file mode 100644 index 000000000..9e188ae42 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/LinkListener.java @@ -0,0 +1,21 @@ +/* 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; + +interface LinkListener { + void onViewTag(String tag); + void onViewAccount(String id); +} diff --git a/app/src/main/java/com/keylesspalace/tusky/LoginActivity.java b/app/src/main/java/com/keylesspalace/tusky/LoginActivity.java index e4695dd7e..b85f1faaa 100644 --- a/app/src/main/java/com/keylesspalace/tusky/LoginActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/LoginActivity.java @@ -16,15 +16,17 @@ 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.content.pm.PackageManager; 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.v4.content.ContextCompat; import android.support.v7.app.AppCompatActivity; import android.text.method.LinkMovementMethod; import android.view.View; @@ -110,26 +112,6 @@ public class LoginActivity extends AppCompatActivity { textView.setMovementMethod(LinkMovementMethod.getInstance()); } }); - - // Apply any updates needed. - int versionCode = 1; - try { - versionCode = getPackageManager().getPackageInfo(getPackageName(), 0).versionCode; - } catch (PackageManager.NameNotFoundException e) { - Log.e(TAG, "The app version was not found. " + e.getMessage()); - } - if (preferences.getInt("lastUpdateVersion", 0) != versionCode) { - SharedPreferences.Editor editor = preferences.edit(); - if (versionCode == 14) { - /* This version switches the order of scheme and host in the OAuth redirect URI. - * But to fix it requires forcing the app to re-authenticate with servers. So, clear - * out the stored client id/secret pairs. The only other things that are lost are - * "rememberedVisibility", "loggedInUsername", and "loggedInAccountId". */ - editor.clear(); - } - editor.putInt("lastUpdateVersion", versionCode); - editor.apply(); - } } @Override @@ -201,10 +183,10 @@ public class LoginActivity extends AppCompatActivity { AppCredentials credentials = response.body(); clientId = credentials.clientId; clientSecret = credentials.clientSecret; - SharedPreferences.Editor editor = preferences.edit(); - editor.putString(domain + "/client_id", clientId); - editor.putString(domain + "/client_secret", clientSecret); - editor.apply(); + preferences.edit() + .putString(domain + "/client_id", clientId) + .putString(domain + "/client_secret", clientSecret) + .apply(); redirectUserToAuthorizeAndLogin(editText); } @@ -226,7 +208,6 @@ public class LoginActivity extends AppCompatActivity { } } - /** * Chain together the key-value pairs into a query string, for either appending to a URL or * as the content of an HTTP request. @@ -245,6 +226,36 @@ public class LoginActivity extends AppCompatActivity { return s.toString(); } + private static boolean openInCustomTab(Uri uri, Context context) { + boolean lightTheme = PreferenceManager.getDefaultSharedPreferences(context) + .getBoolean("lightTheme", false); + int toolbarColorRes; + if (lightTheme) { + toolbarColorRes = R.color.custom_tab_toolbar_light; + } else { + toolbarColorRes = R.color.custom_tab_toolbar_dark; + } + int toolbarColor = ContextCompat.getColor(context, toolbarColorRes); + 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. */ @@ -256,11 +267,14 @@ public class LoginActivity extends AppCompatActivity { parameters.put("response_type", "code"); parameters.put("scope", OAUTH_SCOPES); String url = "https://" + domain + endpoint + "?" + toQueryString(parameters); - Intent viewIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); - if (viewIntent.resolveActivity(getPackageManager()) != null) { - startActivity(viewIntent); - } else { - editText.setError(getString(R.string.error_no_web_browser_found)); + 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)); + } } } @@ -268,11 +282,11 @@ public class LoginActivity extends AppCompatActivity { protected void onStop() { super.onStop(); if (domain != null) { - SharedPreferences.Editor editor = preferences.edit(); - editor.putString("domain", domain); - editor.putString("clientId", clientId); - editor.putString("clientSecret", clientSecret); - editor.apply(); + preferences.edit() + .putString("domain", domain) + .putString("clientId", clientId) + .putString("clientSecret", clientSecret) + .apply(); } } @@ -347,10 +361,14 @@ public class LoginActivity extends AppCompatActivity { } private void onLoginSuccess(String accessToken) { - SharedPreferences.Editor editor = preferences.edit(); - editor.putString("domain", domain); - editor.putString("accessToken", accessToken); - editor.commit(); + boolean committed = preferences.edit() + .putString("domain", domain) + .putString("accessToken", accessToken) + .commit(); + if (!committed) { + editText.setError(getString(R.string.error_retrieving_oauth_token)); + return; + } Intent intent = new Intent(this, MainActivity.class); startActivity(intent); finish(); diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.java b/app/src/main/java/com/keylesspalace/tusky/MainActivity.java index effc48d0e..7641a6212 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.java @@ -16,7 +16,6 @@ package com.keylesspalace.tusky; import android.app.NotificationManager; -import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.graphics.Typeface; @@ -24,8 +23,11 @@ import android.graphics.drawable.Drawable; import android.net.Uri; import android.os.Bundle; import android.os.PersistableBundle; +import android.support.annotation.NonNull; import android.support.design.widget.FloatingActionButton; import android.support.design.widget.TabLayout; +import android.support.v4.app.Fragment; +import android.support.v4.content.ContextCompat; import android.support.v4.view.ViewPager; import android.text.SpannableStringBuilder; import android.text.Spanned; @@ -64,8 +66,9 @@ import retrofit2.Call; import retrofit2.Callback; import retrofit2.Response; -public class MainActivity extends BaseActivity { - private static final String TAG = "MainActivity"; // logging tag and Volley request tag +public class MainActivity extends BaseActivity implements SFragment.OnUserRemovedListener { + private static final String TAG = "MainActivity"; // logging tag + protected static int COMPOSE_RESULT = 1; private String loggedInAccountId; private String loggedInAccountUsername; @@ -99,7 +102,7 @@ public class MainActivity extends BaseActivity { @Override public void onClick(View v) { Intent intent = new Intent(getApplicationContext(), ComposeActivity.class); - startActivity(intent); + startActivityForResult(intent, COMPOSE_RESULT); } }); @@ -184,7 +187,11 @@ public class MainActivity extends BaseActivity { } // Setup push notifications - if (arePushNotificationsEnabled()) enablePushNotifications(); + if (arePushNotificationsEnabled()) { + enablePushNotifications(); + } else { + disablePushNotifications(); + } composeButton = floatingBtn; } @@ -193,12 +200,24 @@ public class MainActivity extends BaseActivity { protected void onResume() { super.onResume(); - SharedPreferences notificationPreferences = getApplicationContext().getSharedPreferences("Notifications", MODE_PRIVATE); - SharedPreferences.Editor editor = notificationPreferences.edit(); - editor.putString("current", "[]"); - editor.apply(); + SharedPreferences notificationPreferences = getApplicationContext() + .getSharedPreferences("Notifications", MODE_PRIVATE); + notificationPreferences.edit() + .putString("current", "[]") + .apply(); - ((NotificationManager) (getSystemService(NOTIFICATION_SERVICE))).cancel(MyFirebaseMessagingService.NOTIFY_ID); + ((NotificationManager) (getSystemService(NOTIFICATION_SERVICE))) + .cancel(MessagingService.NOTIFY_ID); + + /* After editing a profile, the profile header in the navigation drawer needs to be + * refreshed */ + SharedPreferences preferences = getPrivatePreferences(); + if (preferences.getBoolean("refreshProfileHeader", false)) { + fetchUserInfo(); + preferences.edit() + .putBoolean("refreshProfileHeader", false) + .apply(); + } } @Override @@ -254,6 +273,9 @@ public class MainActivity extends BaseActivity { } }); + Drawable muteDrawable = ContextCompat.getDrawable(this, R.drawable.ic_mute_24dp); + ThemeUtils.setDrawableTint(this, muteDrawable, R.attr.toolbar_icon_tint); + drawer = new DrawerBuilder() .withActivity(this) //.withToolbar(toolbar) @@ -261,13 +283,14 @@ public class MainActivity extends BaseActivity { .withHasStableIds(true) .withSelectedItem(-1) .addDrawerItems( - new PrimaryDrawerItem().withIdentifier(0).withName(R.string.action_view_profile).withSelectable(false).withIcon(GoogleMaterial.Icon.gmd_person), + new PrimaryDrawerItem().withIdentifier(0).withName(getString(R.string.action_edit_profile)).withSelectable(false).withIcon(GoogleMaterial.Icon.gmd_person), new PrimaryDrawerItem().withIdentifier(1).withName(getString(R.string.action_view_favourites)).withSelectable(false).withIcon(GoogleMaterial.Icon.gmd_star), - new PrimaryDrawerItem().withIdentifier(2).withName(getString(R.string.action_view_blocks)).withSelectable(false).withIcon(GoogleMaterial.Icon.gmd_block), + new PrimaryDrawerItem().withIdentifier(2).withName(getString(R.string.action_view_mutes)).withSelectable(false).withIcon(muteDrawable), + new PrimaryDrawerItem().withIdentifier(3).withName(getString(R.string.action_view_blocks)).withSelectable(false).withIcon(GoogleMaterial.Icon.gmd_block), new DividerDrawerItem(), - new SecondaryDrawerItem().withIdentifier(3).withName(getString(R.string.action_view_preferences)).withSelectable(false), - new SecondaryDrawerItem().withIdentifier(4).withName(getString(R.string.about_title_activity)).withSelectable(false), - new SecondaryDrawerItem().withIdentifier(5).withName(getString(R.string.action_logout)).withSelectable(false) + new SecondaryDrawerItem().withIdentifier(4).withName(getString(R.string.action_view_preferences)).withSelectable(false).withIcon(GoogleMaterial.Icon.gmd_settings), + new SecondaryDrawerItem().withIdentifier(5).withName(getString(R.string.about_title_activity)).withSelectable(false).withIcon(GoogleMaterial.Icon.gmd_info), + new SecondaryDrawerItem().withIdentifier(6).withName(getString(R.string.action_logout)).withSelectable(false).withIcon(GoogleMaterial.Icon.gmd_exit_to_app) ) .withOnDrawerItemClickListener(new Drawer.OnDrawerItemClickListener() { @Override @@ -276,25 +299,31 @@ public class MainActivity extends BaseActivity { long drawerItemIdentifier = drawerItem.getIdentifier(); if (drawerItemIdentifier == 0) { - if (loggedInAccountId != null) { - Intent intent = new Intent(MainActivity.this, AccountActivity.class); - intent.putExtra("id", loggedInAccountId); - startActivity(intent); - } + Intent intent = new Intent(MainActivity.this, EditProfileActivity.class); + startActivity(intent); } else if (drawerItemIdentifier == 1) { Intent intent = new Intent(MainActivity.this, FavouritesActivity.class); startActivity(intent); } else if (drawerItemIdentifier == 2) { - Intent intent = new Intent(MainActivity.this, BlocksActivity.class); + Intent intent = new Intent(MainActivity.this, AccountListActivity.class); + intent.putExtra("type", AccountListActivity.Type.MUTES); startActivity(intent); } else if (drawerItemIdentifier == 3) { - Intent intent = new Intent(MainActivity.this, PreferencesActivity.class); + Intent intent = new Intent(MainActivity.this, AccountListActivity.class); + intent.putExtra("type", AccountListActivity.Type.BLOCKS); startActivity(intent); } else if (drawerItemIdentifier == 4) { - Intent intent = new Intent(MainActivity.this, AboutActivity.class); + Intent intent = new Intent(MainActivity.this, PreferencesActivity.class); startActivity(intent); } else if (drawerItemIdentifier == 5) { + Intent intent = new Intent(MainActivity.this, AboutActivity.class); + startActivity(intent); + } else if (drawerItemIdentifier == 6) { logout(); + } else if (drawerItemIdentifier == 7) { + Intent intent = new Intent(MainActivity.this, AccountListActivity.class); + intent.putExtra("type", AccountListActivity.Type.FOLLOW_REQUESTS); + startActivity(intent); } } @@ -307,11 +336,10 @@ public class MainActivity extends BaseActivity { private void logout() { if (arePushNotificationsEnabled()) disablePushNotifications(); - SharedPreferences preferences = getSharedPreferences(getString(R.string.preferences_file_key), Context.MODE_PRIVATE); - SharedPreferences.Editor editor = preferences.edit(); - editor.remove("domain"); - editor.remove("accessToken"); - editor.apply(); + getPrivatePreferences().edit() + .remove("domain") + .remove("accessToken") + .apply(); Intent intent = new Intent(MainActivity.this, LoginActivity.class); startActivity(intent); @@ -381,9 +409,7 @@ public class MainActivity extends BaseActivity { } @Override - public void onSearchAction(String currentQuery) { - - } + public void onSearchAction(String currentQuery) {} }); searchView.setOnBindSuggestionCallback(new SearchSuggestionsAdapter.OnBindSuggestionCallback() { @@ -408,8 +434,7 @@ public class MainActivity extends BaseActivity { } private void fetchUserInfo() { - SharedPreferences preferences = getSharedPreferences( - getString(R.string.preferences_file_key), Context.MODE_PRIVATE); + SharedPreferences preferences = getPrivatePreferences(); final String domain = preferences.getString("domain", null); String id = preferences.getString("loggedInAccountId", null); String username = preferences.getString("loggedInAccountUsername", null); @@ -426,34 +451,7 @@ public class MainActivity extends BaseActivity { onFetchUserInfoFailure(new Exception(response.message())); return; } - - Account me = response.body(); - ImageView background = headerResult.getHeaderBackgroundView(); - int backgroundWidth = background.getWidth(); - int backgroundHeight = background.getHeight(); - if (backgroundWidth == 0 || backgroundHeight == 0) { - /* The header ImageView may not be layed out when the verify credentials call - * returns so measure the dimensions and use those. */ - background.measure(View.MeasureSpec.EXACTLY, View.MeasureSpec.EXACTLY); - backgroundWidth = background.getMeasuredWidth(); - backgroundHeight = background.getMeasuredHeight(); - } - - Picasso.with(MainActivity.this) - .load(me.header) - .placeholder(R.drawable.account_header_missing) - .resize(backgroundWidth, backgroundHeight) - .centerCrop() - .into(background); - - headerResult.addProfiles( - new ProfileDrawerItem() - .withName(me.getDisplayName()) - .withEmail(String.format("%s@%s", me.username, domain)) - .withIcon(me.avatar) - ); - - onFetchUserInfoSuccess(me.id, me.username); + onFetchUserInfoSuccess(response.body(), domain); } @Override @@ -463,21 +461,69 @@ public class MainActivity extends BaseActivity { }); } - private void onFetchUserInfoSuccess(String id, String username) { - loggedInAccountId = id; - loggedInAccountUsername = username; - SharedPreferences preferences = getSharedPreferences( - getString(R.string.preferences_file_key), Context.MODE_PRIVATE); - SharedPreferences.Editor editor = preferences.edit(); - editor.putString("loggedInAccountId", loggedInAccountId); - editor.putString("loggedInAccountUsername", loggedInAccountUsername); - editor.apply(); + private void onFetchUserInfoSuccess(Account me, String domain) { + // Add the header image and avatar from the account, into the navigation drawer header. + headerResult.clear(); + + ImageView background = headerResult.getHeaderBackgroundView(); + int backgroundWidth = background.getWidth(); + int backgroundHeight = background.getHeight(); + if (backgroundWidth == 0 || backgroundHeight == 0) { + /* The header ImageView may not be layed out when the verify credentials call returns so + * measure the dimensions and use those. */ + background.measure(View.MeasureSpec.EXACTLY, View.MeasureSpec.EXACTLY); + backgroundWidth = background.getMeasuredWidth(); + backgroundHeight = background.getMeasuredHeight(); + } + + Picasso.with(MainActivity.this) + .load(me.header) + .placeholder(R.drawable.account_header_missing) + .resize(backgroundWidth, backgroundHeight) + .centerCrop() + .into(background); + + headerResult.addProfiles( + new ProfileDrawerItem() + .withName(me.getDisplayName()) + .withEmail(String.format("%s@%s", me.username, domain)) + .withIcon(me.avatar) + ); + + // Show follow requests in the menu, if this is a locked account. + if (me.locked) { + PrimaryDrawerItem followRequestsItem = new PrimaryDrawerItem() + .withIdentifier(6) + .withName(R.string.action_view_follow_requests) + .withSelectable(false) + .withIcon(GoogleMaterial.Icon.gmd_person_add); + drawer.addItemAtPosition(followRequestsItem, 3); + } + + // Update the current login information. + loggedInAccountId = me.id; + loggedInAccountUsername = me.username; + getPrivatePreferences().edit() + .putString("loggedInAccountId", loggedInAccountId) + .putString("loggedInAccountUsername", loggedInAccountUsername) + .apply(); } private void onFetchUserInfoFailure(Exception exception) { Log.e(TAG, "Failed to fetch user info. " + exception.getMessage()); } + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + if (requestCode == COMPOSE_RESULT && resultCode == ComposeActivity.RESULT_OK) { + TimelinePagerAdapter adapter = (TimelinePagerAdapter) viewPager.getAdapter(); + if (adapter.getCurrentFragment() instanceof SFragment) { + ((SFragment) adapter.getCurrentFragment()).onSuccessfulStatus(); + } + } + super.onActivityResult(requestCode, resultCode, data); + } + @Override public void onBackPressed() { if(drawer != null && drawer.isDrawerOpen()) { @@ -489,4 +535,25 @@ public class MainActivity extends BaseActivity { viewPager.setCurrentItem(pageHistory.peek()); } } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, + @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + TimelinePagerAdapter adapter = (TimelinePagerAdapter) viewPager.getAdapter(); + for (Fragment fragment : adapter.getRegisteredFragments()) { + fragment.onRequestPermissionsResult(requestCode, permissions, grantResults); + } + } + + @Override + public void onUserRemoved(String accountId) { + TimelinePagerAdapter adapter = (TimelinePagerAdapter) viewPager.getAdapter(); + for (Fragment fragment : adapter.getRegisteredFragments()) { + if (fragment instanceof StatusRemoveListener) { + StatusRemoveListener listener = (StatusRemoveListener) fragment; + listener.removePostsByUser(accountId); + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/MastodonAPI.java b/app/src/main/java/com/keylesspalace/tusky/MastodonAPI.java index 5e10938a5..011fbc40a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MastodonAPI.java +++ b/app/src/main/java/com/keylesspalace/tusky/MastodonAPI.java @@ -20,6 +20,7 @@ import com.keylesspalace.tusky.entity.Account; import com.keylesspalace.tusky.entity.AppCredentials; import com.keylesspalace.tusky.entity.Media; import com.keylesspalace.tusky.entity.Notification; +import com.keylesspalace.tusky.entity.Profile; import com.keylesspalace.tusky.entity.Relationship; import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.entity.StatusContext; @@ -29,6 +30,7 @@ import java.util.List; import okhttp3.MultipartBody; import okhttp3.ResponseBody; import retrofit2.Call; +import retrofit2.http.Body; import retrofit2.http.DELETE; import retrofit2.http.Field; import retrofit2.http.FormUrlEncoded; @@ -115,11 +117,7 @@ public interface MastodonAPI { @GET("api/v1/accounts/verify_credentials") Call accountVerifyCredentials(); @PATCH("api/v1/accounts/update_credentials") - Call accountUpdateCredentials( - @Field("display_name") String displayName, - @Field("note") String note, - @Field("avatar") String avatar, - @Field("header") String header); + Call accountUpdateCredentials(@Body Profile profile); @GET("api/v1/accounts/search") Call> searchAccounts( @Query("q") String q, diff --git a/app/src/main/java/com/keylesspalace/tusky/MutesAdapter.java b/app/src/main/java/com/keylesspalace/tusky/MutesAdapter.java new file mode 100644 index 000000000..2513cac6b --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/MutesAdapter.java @@ -0,0 +1,105 @@ +package com.keylesspalace.tusky; + +import android.support.v7.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageButton; +import android.widget.TextView; + +import com.keylesspalace.tusky.entity.Account; +import com.pkmmte.view.CircularImageView; +import com.squareup.picasso.Picasso; + +import java.util.HashSet; +import java.util.Set; + +import butterknife.BindView; +import butterknife.ButterKnife; + +class MutesAdapter extends AccountAdapter { + private static final int VIEW_TYPE_MUTED_USER = 0; + private static final int VIEW_TYPE_FOOTER = 1; + + MutesAdapter(AccountActionListener accountActionListener) { + super(accountActionListener); + } + + @Override + public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + switch (viewType) { + default: + case VIEW_TYPE_MUTED_USER: { + View view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.item_muted_user, parent, false); + return new MutesAdapter.MutedUserViewHolder(view); + } + case VIEW_TYPE_FOOTER: { + View view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.item_footer, parent, false); + return new FooterViewHolder(view); + } + } + } + + @Override + public void onBindViewHolder(RecyclerView.ViewHolder viewHolder, int position) { + if (position < accountList.size()) { + MutedUserViewHolder holder = (MutedUserViewHolder) viewHolder; + holder.setupWithAccount(accountList.get(position)); + holder.setupActionListener(accountActionListener, true, position); + } + } + + @Override + public int getItemViewType(int position) { + if (position == accountList.size()) { + return VIEW_TYPE_FOOTER; + } else { + return VIEW_TYPE_MUTED_USER; + } + } + + static class MutedUserViewHolder extends RecyclerView.ViewHolder { + @BindView(R.id.muted_user_avatar) CircularImageView avatar; + @BindView(R.id.muted_user_username) TextView username; + @BindView(R.id.muted_user_display_name) TextView displayName; + @BindView(R.id.muted_user_unmute) ImageButton unmute; + + private String id; + + MutedUserViewHolder(View itemView) { + super(itemView); + ButterKnife.bind(this, itemView); + } + + void setupWithAccount(Account account) { + id = account.id; + displayName.setText(account.getDisplayName()); + String format = username.getContext().getString(R.string.status_username_format); + String formattedUsername = String.format(format, account.username); + username.setText(formattedUsername); + Picasso.with(avatar.getContext()) + .load(account.avatar) + .error(R.drawable.avatar_error) + .placeholder(R.drawable.avatar_default) + .into(avatar); + } + + void setupActionListener(final AccountActionListener listener, final boolean muted, + final int position) { + unmute.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + listener.onMute(!muted, id, position); + } + }); + avatar.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + listener.onViewAccount(id); + } + }); + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/MyFirebaseMessagingService.java b/app/src/main/java/com/keylesspalace/tusky/MyFirebaseMessagingService.java deleted file mode 100644 index d278d611d..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/MyFirebaseMessagingService.java +++ /dev/null @@ -1,318 +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 . - * - * If you modify this Program, or any covered work, by linking or combining it with Firebase Cloud - * Messaging and Firebase Crash Reporting (or a modified version of those libraries), containing - * parts covered by the Google APIs Terms of Service, the licensors of this Program grant you - * additional permission to convey the resulting work. */ - -package com.keylesspalace.tusky; - -import android.app.NotificationManager; -import android.app.PendingIntent; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.graphics.Bitmap; -import android.graphics.drawable.Drawable; -import android.os.Build; -import android.preference.PreferenceManager; -import android.provider.Settings; -import android.support.v4.app.NotificationCompat; -import android.support.v4.app.TaskStackBuilder; -import android.text.Spanned; - -import com.google.firebase.messaging.FirebaseMessagingService; -import com.google.firebase.messaging.RemoteMessage; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import com.keylesspalace.tusky.entity.Notification; -import com.squareup.picasso.Picasso; -import com.squareup.picasso.Target; - -import org.json.JSONArray; -import org.json.JSONException; - -import java.io.IOException; - -import okhttp3.Interceptor; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import retrofit2.Call; -import retrofit2.Callback; -import retrofit2.Response; -import retrofit2.Retrofit; -import retrofit2.converter.gson.GsonConverterFactory; - -public class MyFirebaseMessagingService extends FirebaseMessagingService { - private MastodonAPI mastodonAPI; - private static final String TAG = "MyFirebaseMessagingService"; - public static final int NOTIFY_ID = 666; - - @Override - public void onMessageReceived(RemoteMessage remoteMessage) { - Log.d(TAG, remoteMessage.getFrom()); - Log.d(TAG, remoteMessage.toString()); - - String notificationId = remoteMessage.getData().get("notification_id"); - - if (notificationId == null) { - Log.e(TAG, "No notification ID in payload!!"); - return; - } - - Log.d(TAG, notificationId); - - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences( - getApplicationContext()); - boolean enabled = preferences.getBoolean("notificationsEnabled", true); - if (!enabled) { - return; - } - - createMastodonAPI(); - - mastodonAPI.notification(notificationId).enqueue(new Callback() { - @Override - public void onResponse(Call call, Response response) { - if (response.isSuccessful()) { - buildNotification(response.body()); - } - } - - @Override - public void onFailure(Call call, Throwable t) { - - } - }); - } - - private void createMastodonAPI() { - SharedPreferences preferences = getSharedPreferences(getString(R.string.preferences_file_key), Context.MODE_PRIVATE); - final String domain = preferences.getString("domain", null); - final String accessToken = preferences.getString("accessToken", null); - - OkHttpClient okHttpClient = OkHttpUtils.getCompatibleClientBuilder() - .addInterceptor(new Interceptor() { - @Override - public okhttp3.Response intercept(Chain chain) throws IOException { - Request originalRequest = chain.request(); - - Request.Builder builder = originalRequest.newBuilder() - .header("Authorization", String.format("Bearer %s", accessToken)); - - Request newRequest = builder.build(); - - return chain.proceed(newRequest); - } - }) - .build(); - - Gson gson = new GsonBuilder() - .registerTypeAdapter(Spanned.class, new SpannedTypeAdapter()) - .create(); - - Retrofit retrofit = new Retrofit.Builder() - .baseUrl("https://" + domain) - .client(okHttpClient) - .addConverterFactory(GsonConverterFactory.create(gson)) - .build(); - - mastodonAPI = retrofit.create(MastodonAPI.class); - } - - private String truncateWithEllipses(String string, int limit) { - if (string.length() < limit) { - return string; - } else { - return string.substring(0, limit - 3) + "..."; - } - } - - private static boolean filterNotification(SharedPreferences preferences, - Notification notification) { - switch (notification.type) { - default: - case MENTION: { - return preferences.getBoolean("notificationFilterMentions", true); - } - case FOLLOW: { - return preferences.getBoolean("notificationFilterFollows", true); - } - case REBLOG: { - return preferences.getBoolean("notificationFilterReblogs", true); - } - case FAVOURITE: { - return preferences.getBoolean("notificationFilterFavourites", true); - } - } - } - - private void buildNotification(Notification body) { - final SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this); - final SharedPreferences notificationPreferences = getApplicationContext().getSharedPreferences("Notifications", MODE_PRIVATE); - - if (!filterNotification(preferences, body)) { - return; - } - - String rawCurrentNotifications = notificationPreferences.getString("current", "[]"); - JSONArray currentNotifications; - - try { - currentNotifications = new JSONArray(rawCurrentNotifications); - } catch (JSONException e) { - currentNotifications = new JSONArray(); - } - - boolean alreadyContains = false; - - for(int i = 0; i < currentNotifications.length(); i++) { - try { - if (currentNotifications.getString(i).equals(body.account.displayName)) { - alreadyContains = true; - } - } catch (JSONException e) { - e.printStackTrace(); - } - } - - if (!alreadyContains) { - currentNotifications.put(body.account.displayName); - } - - SharedPreferences.Editor editor = notificationPreferences.edit(); - editor.putString("current", currentNotifications.toString()); - editor.commit(); - - Intent resultIntent = new Intent(this, MainActivity.class); - resultIntent.putExtra("tab_position", 1); - TaskStackBuilder stackBuilder = TaskStackBuilder.create(this); - stackBuilder.addParentStack(MainActivity.class); - stackBuilder.addNextIntent(resultIntent); - PendingIntent resultPendingIntent = stackBuilder.getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT); - - Intent deleteIntent = new Intent(this, NotificationClearBroadcastReceiver.class); - PendingIntent deletePendingIntent = PendingIntent.getBroadcast(this, 0, deleteIntent, PendingIntent.FLAG_CANCEL_CURRENT); - - final NotificationCompat.Builder builder = new NotificationCompat.Builder(this) - .setSmallIcon(R.drawable.ic_notify) - .setContentIntent(resultPendingIntent) - .setDeleteIntent(deletePendingIntent) - .setDefaults(0); // So it doesn't ring twice, notify only in Target callback - - if (currentNotifications.length() == 1) { - builder.setContentTitle(titleForType(body)) - .setContentText(truncateWithEllipses(bodyForType(body), 40)); - - Target mTarget = new Target() { - @Override - public void onBitmapLoaded(Bitmap bitmap, Picasso.LoadedFrom from) { - builder.setLargeIcon(bitmap); - - setupPreferences(preferences, builder); - - ((NotificationManager) (getSystemService(NOTIFICATION_SERVICE))).notify(NOTIFY_ID, builder.build()); - } - - @Override - public void onBitmapFailed(Drawable errorDrawable) { - - } - - @Override - public void onPrepareLoad(Drawable placeHolderDrawable) { - - } - }; - - Picasso.with(this) - .load(body.account.avatar) - .placeholder(R.drawable.avatar_default) - .transform(new RoundedTransformation(7, 0)) - .into(mTarget); - } else { - setupPreferences(preferences, builder); - - try { - builder.setContentTitle(String.format(getString(R.string.notification_title_summary), currentNotifications.length())) - .setContentText(truncateWithEllipses(joinNames(currentNotifications), 40)); - } catch (JSONException e) { - e.printStackTrace(); - } - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - builder.setVisibility(android.app.Notification.VISIBILITY_PRIVATE); - builder.setCategory(android.app.Notification.CATEGORY_SOCIAL); - } - - ((NotificationManager) (getSystemService(NOTIFICATION_SERVICE))).notify(NOTIFY_ID, builder.build()); - } - - private void setupPreferences(SharedPreferences preferences, NotificationCompat.Builder builder) { - if (preferences.getBoolean("notificationAlertSound", true)) { - builder.setSound(Settings.System.DEFAULT_NOTIFICATION_URI); - } - - if (preferences.getBoolean("notificationAlertVibrate", false)) { - builder.setVibrate(new long[] { 500, 500 }); - } - - if (preferences.getBoolean("notificationAlertLight", false)) { - builder.setLights(0xFF00FF8F, 300, 1000); - } - } - - private String joinNames(JSONArray array) throws JSONException { - if (array.length() > 3) { - return String.format(getString(R.string.notification_summary_large), array.get(0), array.get(1), array.get(2), array.length() - 3); - } else if (array.length() == 3) { - return String.format(getString(R.string.notification_summary_medium), array.get(0), array.get(1), array.get(2)); - } else if (array.length() == 2) { - return String.format(getString(R.string.notification_summary_small), array.get(0), array.get(1)); - } - - return null; - } - - private String titleForType(Notification notification) { - switch (notification.type) { - case MENTION: - return String.format(getString(R.string.notification_mention_format), notification.account.getDisplayName()); - case FOLLOW: - return String.format(getString(R.string.notification_follow_format), notification.account.getDisplayName()); - case FAVOURITE: - return String.format(getString(R.string.notification_favourite_format), notification.account.getDisplayName()); - case REBLOG: - return String.format(getString(R.string.notification_reblog_format), notification.account.getDisplayName()); - } - - return null; - } - - private String bodyForType(Notification notification) { - switch (notification.type) { - case FOLLOW: - return notification.account.username; - case MENTION: - case FAVOURITE: - case REBLOG: - return notification.status.content.toString(); - } - - return null; - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/NotificationMaker.java b/app/src/main/java/com/keylesspalace/tusky/NotificationMaker.java new file mode 100644 index 000000000..8c7bfb85d --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/NotificationMaker.java @@ -0,0 +1,224 @@ +/* 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.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.graphics.Bitmap; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.preference.PreferenceManager; +import android.provider.Settings; +import android.support.annotation.Nullable; +import android.support.v4.app.NotificationCompat; +import android.support.v4.app.TaskStackBuilder; + +import com.keylesspalace.tusky.entity.Notification; +import com.squareup.picasso.Picasso; +import com.squareup.picasso.Target; + +import org.json.JSONArray; +import org.json.JSONException; + +class NotificationMaker { + 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)) { + return; + } + + String rawCurrentNotifications = notificationPreferences.getString("current", "[]"); + JSONArray currentNotifications; + + try { + currentNotifications = new JSONArray(rawCurrentNotifications); + } catch (JSONException e) { + currentNotifications = new JSONArray(); + } + + boolean alreadyContains = false; + + for(int i = 0; i < currentNotifications.length(); i++) { + try { + if (currentNotifications.getString(i).equals(body.account.getDisplayName())) { + alreadyContains = true; + } + } catch (JSONException e) { + e.printStackTrace(); + } + } + + if (!alreadyContains) { + currentNotifications.put(body.account.getDisplayName()); + } + + notificationPreferences.edit() + .putString("current", currentNotifications.toString()) + .commit(); + + Intent resultIntent = new Intent(context, MainActivity.class); + resultIntent.putExtra("tab_position", 1); + TaskStackBuilder stackBuilder = TaskStackBuilder.create(context); + stackBuilder.addParentStack(MainActivity.class); + stackBuilder.addNextIntent(resultIntent); + PendingIntent resultPendingIntent = stackBuilder.getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT); + + Intent deleteIntent = new Intent(context, NotificationClearBroadcastReceiver.class); + PendingIntent deletePendingIntent = PendingIntent.getBroadcast(context, 0, deleteIntent, PendingIntent.FLAG_CANCEL_CURRENT); + + final NotificationCompat.Builder builder = new NotificationCompat.Builder(context) + .setSmallIcon(R.drawable.ic_notify) + .setContentIntent(resultPendingIntent) + .setDeleteIntent(deletePendingIntent) + .setDefaults(0); // So it doesn't ring twice, notify only in Target callback + + if (currentNotifications.length() == 1) { + builder.setContentTitle(titleForType(context, body)) + .setContentText(truncateWithEllipses(bodyForType(body), 40)); + + Target mTarget = new Target() { + @Override + public void onBitmapLoaded(Bitmap bitmap, Picasso.LoadedFrom from) { + builder.setLargeIcon(bitmap); + + setupPreferences(preferences, builder); + + ((NotificationManager) (context.getSystemService(Context.NOTIFICATION_SERVICE))) + .notify(notifyId, builder.build()); + } + + @Override + public void onBitmapFailed(Drawable errorDrawable) {} + + @Override + public void onPrepareLoad(Drawable placeHolderDrawable) {} + }; + + Picasso.with(context) + .load(body.account.avatar) + .placeholder(R.drawable.avatar_default) + .transform(new RoundedTransformation(7, 0)) + .into(mTarget); + } else { + setupPreferences(preferences, builder); + try { + builder.setContentTitle(String.format(context.getString(R.string.notification_title_summary), currentNotifications.length())) + .setContentText(truncateWithEllipses(joinNames(context, currentNotifications), 40)); + } catch (JSONException e) { + e.printStackTrace(); + } + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + builder.setVisibility(android.app.Notification.VISIBILITY_PRIVATE); + builder.setCategory(android.app.Notification.CATEGORY_SOCIAL); + } + + ((NotificationManager) (context.getSystemService(Context.NOTIFICATION_SERVICE))) + .notify(notifyId, builder.build()); + } + + private static boolean filterNotification(SharedPreferences preferences, + Notification notification) { + switch (notification.type) { + default: + case MENTION: { + return preferences.getBoolean("notificationFilterMentions", true); + } + case FOLLOW: { + return preferences.getBoolean("notificationFilterFollows", true); + } + case REBLOG: { + return preferences.getBoolean("notificationFilterReblogs", true); + } + case FAVOURITE: { + return preferences.getBoolean("notificationFilterFavourites", true); + } + } + } + + 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, + NotificationCompat.Builder builder) { + if (preferences.getBoolean("notificationAlertSound", true)) { + builder.setSound(Settings.System.DEFAULT_NOTIFICATION_URI); + } + + if (preferences.getBoolean("notificationAlertVibrate", false)) { + builder.setVibrate(new long[] { 500, 500 }); + } + + if (preferences.getBoolean("notificationAlertLight", false)) { + builder.setLights(0xFF00FF8F, 300, 1000); + } + } + + @Nullable + private static String joinNames(Context context, JSONArray array) throws JSONException { + if (array.length() > 3) { + return String.format(context.getString(R.string.notification_summary_large), array.get(0), array.get(1), array.get(2), array.length() - 3); + } else if (array.length() == 3) { + return String.format(context.getString(R.string.notification_summary_medium), array.get(0), array.get(1), array.get(2)); + } else if (array.length() == 2) { + return String.format(context.getString(R.string.notification_summary_small), array.get(0), array.get(1)); + } + + return null; + } + + @Nullable + private static String titleForType(Context context, Notification notification) { + switch (notification.type) { + case MENTION: + return String.format(context.getString(R.string.notification_mention_format), notification.account.getDisplayName()); + case FOLLOW: + return String.format(context.getString(R.string.notification_follow_format), notification.account.getDisplayName()); + case FAVOURITE: + return String.format(context.getString(R.string.notification_favourite_format), notification.account.getDisplayName()); + case REBLOG: + return String.format(context.getString(R.string.notification_reblog_format), notification.account.getDisplayName()); + } + return null; + } + + @Nullable + private static String bodyForType(Notification notification) { + switch (notification.type) { + case FOLLOW: + return notification.account.username; + case MENTION: + case FAVOURITE: + case REBLOG: + return notification.status.content.toString(); + } + + return null; + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/NotificationsAdapter.java b/app/src/main/java/com/keylesspalace/tusky/NotificationsAdapter.java index ea45b0782..2376469b5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/NotificationsAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/NotificationsAdapter.java @@ -34,6 +34,7 @@ import com.keylesspalace.tusky.entity.Status; import com.squareup.picasso.Picasso; import java.util.ArrayList; +import java.util.Iterator; import java.util.List; class NotificationsAdapter extends RecyclerView.Adapter implements AdapterItemRemover { @@ -42,9 +43,16 @@ class NotificationsAdapter extends RecyclerView.Adapter implements AdapterItemRe private static final int VIEW_TYPE_STATUS_NOTIFICATION = 2; private static final int VIEW_TYPE_FOLLOW = 3; + enum FooterState { + EMPTY, + END, + LOADING + } + private List notifications; private StatusActionListener statusListener; private NotificationActionListener notificationActionListener; + private FooterState footerState = FooterState.END; NotificationsAdapter(StatusActionListener statusListener, NotificationActionListener notificationActionListener) { @@ -54,6 +62,15 @@ class NotificationsAdapter extends RecyclerView.Adapter implements AdapterItemRe this.notificationActionListener = notificationActionListener; } + + void setFooterState(FooterState newFooterState) { + FooterState oldValue = footerState; + footerState = newFooterState; + if (footerState != oldValue) { + notifyItemChanged(notifications.size()); + } + } + @Override public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { switch (viewType) { @@ -64,8 +81,24 @@ class NotificationsAdapter extends RecyclerView.Adapter implements AdapterItemRe return new StatusViewHolder(view); } case VIEW_TYPE_FOOTER: { - View view = LayoutInflater.from(parent.getContext()) - .inflate(R.layout.item_footer, parent, false); + View view; + switch (footerState) { + default: + case LOADING: + view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.item_footer, parent, false); + break; + case END: { + view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.item_footer_end, parent, false); + break; + } + case EMPTY: { + view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.item_footer_empty, parent, false); + break; + } + } return new FooterViewHolder(view); } case VIEW_TYPE_STATUS_NOTIFICATION: { @@ -178,6 +211,18 @@ class NotificationsAdapter extends RecyclerView.Adapter implements AdapterItemRe notifyItemChanged(position); } + public void removeAllByAccountId(String id) { + for (int i = 0; i < notifications.size();) { + Notification notification = notifications.get(i); + if (id.equals(notification.account.id)) { + notifications.remove(i); + notifyItemRemoved(i); + } else { + i += 1; + } + } + } + interface NotificationActionListener { void onViewAccount(String id); } diff --git a/app/src/main/java/com/keylesspalace/tusky/NotificationsFragment.java b/app/src/main/java/com/keylesspalace/tusky/NotificationsFragment.java index 152a67eed..56afc0fd8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/NotificationsFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/NotificationsFragment.java @@ -19,7 +19,9 @@ import android.content.Context; import android.content.SharedPreferences; import android.graphics.drawable.Drawable; import android.os.Bundle; +import android.preference.PreferenceManager; import android.support.annotation.Nullable; +import android.support.design.widget.FloatingActionButton; import android.support.design.widget.TabLayout; import android.support.v4.widget.SwipeRefreshLayout; import android.support.v7.widget.DividerItemDecoration; @@ -39,16 +41,18 @@ import retrofit2.Callback; import retrofit2.Response; public class NotificationsFragment extends SFragment implements - SwipeRefreshLayout.OnRefreshListener, StatusActionListener, - NotificationsAdapter.NotificationActionListener { + SwipeRefreshLayout.OnRefreshListener, StatusActionListener, StatusRemoveListener, + NotificationsAdapter.NotificationActionListener, SharedPreferences.OnSharedPreferenceChangeListener { private static final String TAG = "Notifications"; // logging tag private SwipeRefreshLayout swipeRefreshLayout; private LinearLayoutManager layoutManager; + private RecyclerView recyclerView; private EndlessOnScrollListener scrollListener; private NotificationsAdapter adapter; private TabLayout.OnTabSelectedListener onTabSelectedListener; private Call> listCall; + private boolean hideFab; public static NotificationsFragment newInstance() { NotificationsFragment fragment = new NotificationsFragment(); @@ -57,15 +61,10 @@ public class NotificationsFragment extends SFragment implements return fragment; } - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - } - @Nullable @Override public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, - @Nullable Bundle savedInstanceState) { + @Nullable Bundle savedInstanceState) { View rootView = inflater.inflate(R.layout.fragment_timeline, container, false); // Setup the SwipeRefreshLayout. @@ -73,7 +72,7 @@ public class NotificationsFragment extends SFragment implements swipeRefreshLayout = (SwipeRefreshLayout) rootView.findViewById(R.id.swipe_refresh_layout); swipeRefreshLayout.setOnRefreshListener(this); // Setup the RecyclerView. - RecyclerView recyclerView = (RecyclerView) rootView.findViewById(R.id.recycler_view); + recyclerView = (RecyclerView) rootView.findViewById(R.id.recycler_view); recyclerView.setHasFixedSize(true); layoutManager = new LinearLayoutManager(context); recyclerView.setLayoutManager(layoutManager); @@ -83,19 +82,7 @@ public class NotificationsFragment extends SFragment implements R.drawable.status_divider_dark); divider.setDrawable(drawable); recyclerView.addItemDecoration(divider); - scrollListener = new EndlessOnScrollListener(layoutManager) { - @Override - public void onLoadMore(int page, int totalItemsCount, RecyclerView view) { - NotificationsAdapter adapter = (NotificationsAdapter) view.getAdapter(); - Notification notification = adapter.getItem(adapter.getItemCount() - 2); - if (notification != null) { - sendFetchNotificationsRequest(notification.id, null); - } else { - sendFetchNotificationsRequest(); - } - } - }; - recyclerView.addOnScrollListener(scrollListener); + adapter = new NotificationsAdapter(this, this); recyclerView.setAdapter(adapter); @@ -118,9 +105,48 @@ public class NotificationsFragment extends SFragment implements } @Override - public void onResume() { - super.onResume(); - sendFetchNotificationsRequest(); + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + /* 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 + * the compose button on down-scroll. */ + MainActivity activity = (MainActivity) getActivity(); + final FloatingActionButton composeButton = activity.composeButton; + final SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences( + activity); + preferences.registerOnSharedPreferenceChangeListener(this); + hideFab = preferences.getBoolean("fabHide", false); + scrollListener = new EndlessOnScrollListener(layoutManager) { + @Override + public void onScrolled(RecyclerView view, int dx, int dy) { + super.onScrolled(view, dx, dy); + + if (hideFab) { + if (dy > 0 && composeButton.isShown()) { + composeButton.hide(); // hides the button if we're scrolling down + } else if (dy < 0 && !composeButton.isShown()) { + composeButton.show(); // shows it if we are scrolling up + } + } else if (!composeButton.isShown()) { + composeButton.show(); + } + } + + @Override + public void onLoadMore(int page, int totalItemsCount, RecyclerView view) { + NotificationsAdapter adapter = (NotificationsAdapter) view.getAdapter(); + Notification notification = adapter.getItem(adapter.getItemCount() - 2); + if (notification != null) { + sendFetchNotificationsRequest(notification.id, null); + } else { + sendFetchNotificationsRequest(); + } + } + }; + + recyclerView.addOnScrollListener(scrollListener); } @Override @@ -142,9 +168,11 @@ public class NotificationsFragment extends SFragment implements } private void sendFetchNotificationsRequest(final String fromId, String uptoId) { - MastodonAPI api = ((BaseActivity) getActivity()).mastodonAPI; + if (fromId != null || adapter.getItemCount() <= 1) { + adapter.setFooterState(NotificationsAdapter.FooterState.LOADING); + } - listCall = api.notifications(fromId, uptoId, null); + listCall = mastodonAPI.notifications(fromId, uptoId, null); listCall.enqueue(new Callback>() { @Override @@ -168,6 +196,10 @@ public class NotificationsFragment extends SFragment implements sendFetchNotificationsRequest(null, null); } + public void removePostsByUser(String accountId) { + adapter.removeAllByAccountId(accountId); + } + private static boolean findNotification(List notifications, String id) { for (Notification notification : notifications) { if (notification.id.equals(id)) { @@ -192,6 +224,11 @@ public class NotificationsFragment extends SFragment implements } else { adapter.update(notifications); } + if (notifications.size() == 0 && adapter.getItemCount() == 1) { + adapter.setFooterState(NotificationsAdapter.FooterState.EMPTY); + } else if (fromId != null) { + adapter.setFooterState(NotificationsAdapter.FooterState.END); + } swipeRefreshLayout.setRefreshing(false); } @@ -245,4 +282,11 @@ public class NotificationsFragment extends SFragment implements public void onViewAccount(String id) { super.viewAccount(id); } + + @Override + public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { + if(key.equals("fabHide")) { + hideFab = sharedPreferences.getBoolean("fabHide", false); + } + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/OkHttpUtils.java b/app/src/main/java/com/keylesspalace/tusky/OkHttpUtils.java index 9c239c803..a821df4d8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/OkHttpUtils.java +++ b/app/src/main/java/com/keylesspalace/tusky/OkHttpUtils.java @@ -37,9 +37,12 @@ import javax.net.ssl.TrustManagerFactory; import javax.net.ssl.X509TrustManager; import okhttp3.ConnectionSpec; +import okhttp3.Interceptor; import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; -class OkHttpUtils { +public class OkHttpUtils { static final String TAG = "OkHttpUtils"; // logging tag /** @@ -55,8 +58,7 @@ class OkHttpUtils { * TLS 1.1 and 1.2 have to be manually enabled on API levels 16-20. */ @NonNull - static OkHttpClient.Builder getCompatibleClientBuilder() { - + public static OkHttpClient.Builder getCompatibleClientBuilder() { ConnectionSpec fallback = new ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS) .allEnabledCipherSuites() .supportsTlsExtensions(true) @@ -69,16 +71,37 @@ class OkHttpUtils { specList.add(ConnectionSpec.CLEARTEXT); OkHttpClient.Builder builder = new OkHttpClient.Builder() + .addInterceptor(getUserAgentInterceptor()) .connectionSpecs(specList); return enableHigherTlsOnPreLollipop(builder); } @NonNull - static OkHttpClient getCompatibleClient() { + public static OkHttpClient getCompatibleClient() { return getCompatibleClientBuilder().build(); } + /** + * Add a custom User-Agent that contains Tusky & Android Version to all requests + * Example: + * User-Agent: Tusky/1.1.2 Android/5.0.2 + */ + @NonNull + private static Interceptor getUserAgentInterceptor() { + return new Interceptor() { + @Override + public Response intercept(Chain chain) throws IOException { + Request originalRequest = chain.request(); + Request requestWithUserAgent = originalRequest.newBuilder() + .header("User-Agent", "Tusky/"+BuildConfig.VERSION_NAME+" Android/"+Build.VERSION.RELEASE) + .build(); + return chain.proceed(requestWithUserAgent); + } + }; + } + + /** * Android version Nougat has a regression where elliptic curve cipher suites are supported, but * only the curve secp256r1 is allowed. So, first it's best to just disable all elliptic @@ -194,7 +217,7 @@ class OkHttpUtils { @Override public Socket createSocket(InetAddress address, int port, InetAddress localAddress, - int localPort) throws IOException { + int localPort) throws IOException { return patch(delegate.createSocket(address, port, localAddress, localPort)); } diff --git a/app/src/main/java/com/keylesspalace/tusky/SFragment.java b/app/src/main/java/com/keylesspalace/tusky/SFragment.java index f18f07f04..3af5cce0c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/SFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/SFragment.java @@ -15,7 +15,6 @@ package com.keylesspalace.tusky; -import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.os.Bundle; @@ -37,6 +36,7 @@ import java.util.List; import okhttp3.ResponseBody; import retrofit2.Call; import retrofit2.Callback; +import retrofit2.Response; /* Note from Andrew on Jan. 22, 2017: This class is a design problem for me, so I left it with an * awkward name. TimelineFragment and NotificationFragment have significant overlap but the nature @@ -44,22 +44,32 @@ import retrofit2.Callback; * adapters. I feel like the profile pages and thread viewer, which I haven't made yet, will also * overlap functionality. So, I'm momentarily leaving it and hopefully working on those will clear * up what needs to be where. */ -public class SFragment extends BaseFragment { +public abstract class SFragment extends BaseFragment { + interface OnUserRemovedListener { + void onUserRemoved(String accountId); + } + protected String loggedInAccountId; protected String loggedInUsername; + protected MastodonAPI mastodonAPI; + protected OnUserRemovedListener userRemovedListener; + protected static int COMPOSE_RESULT = 1; @Override public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); - SharedPreferences preferences = getContext().getSharedPreferences( - getString(R.string.preferences_file_key), Context.MODE_PRIVATE); + SharedPreferences preferences = getPrivatePreferences(); loggedInAccountId = preferences.getString("loggedInAccountId", null); loggedInUsername = preferences.getString("loggedInAccountUsername", null); } - public MastodonAPI getApi() { - return ((BaseActivity) getActivity()).mastodonAPI; + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + BaseActivity activity = (BaseActivity) getActivity(); + mastodonAPI = activity.mastodonAPI; + userRemovedListener = (OnUserRemovedListener) activity; } protected void reply(Status status) { @@ -79,11 +89,22 @@ public class SFragment extends BaseFragment { intent.putExtra("reply_visibility", replyVisibility); intent.putExtra("content_warning", contentWarning); intent.putExtra("mentioned_usernames", mentionedUsernames.toArray(new String[0])); - startActivity(intent); + startActivityForResult(intent, COMPOSE_RESULT); + } + + public void onSuccessfulStatus() {} + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + if (requestCode == COMPOSE_RESULT && resultCode == ComposeActivity.RESULT_OK) { + onSuccessfulStatus(); + } else { + super.onActivityResult(requestCode, resultCode, data); + } } protected void reblog(final Status status, final boolean reblog, - final RecyclerView.Adapter adapter, final int position) { + final RecyclerView.Adapter adapter, final int position) { String id = status.getActionableId(); Callback cb = new Callback() { @@ -101,16 +122,14 @@ public class SFragment extends BaseFragment { } @Override - public void onFailure(Call call, Throwable t) { - - } + public void onFailure(Call call, Throwable t) {} }; Call call; if (reblog) { - call = getApi().reblogStatus(id); + call = mastodonAPI.reblogStatus(id); } else { - call = getApi().unreblogStatus(id); + call = mastodonAPI.unreblogStatus(id); } call.enqueue(cb); callList.add(call); @@ -135,55 +154,59 @@ public class SFragment extends BaseFragment { } @Override - public void onFailure(Call call, Throwable t) { - - } + public void onFailure(Call call, Throwable t) {} }; Call call; if (favourite) { - call = getApi().favouriteStatus(id); + call = mastodonAPI.favouriteStatus(id); } else { - call = getApi().unfavouriteStatus(id); + call = mastodonAPI.unfavouriteStatus(id); } call.enqueue(cb); callList.add(call); } - private void block(String id) { - Call call = getApi().blockAccount(id); + private void mute(String id) { + Call call = mastodonAPI.muteAccount(id); call.enqueue(new Callback() { @Override - public void onResponse(Call call, retrofit2.Response response) { - - } + public void onResponse(Call call, Response response) {} @Override - public void onFailure(Call call, Throwable t) { - - } + public void onFailure(Call call, Throwable t) {} }); callList.add(call); + userRemovedListener.onUserRemoved(id); + } + + private void block(String id) { + Call call = mastodonAPI.blockAccount(id); + call.enqueue(new Callback() { + @Override + public void onResponse(Call call, retrofit2.Response response) {} + + @Override + public void onFailure(Call call, Throwable t) {} + }); + callList.add(call); + userRemovedListener.onUserRemoved(id); } private void delete(String id) { - Call call = getApi().deleteStatus(id); + Call call = mastodonAPI.deleteStatus(id); call.enqueue(new Callback() { @Override - public void onResponse(Call call, retrofit2.Response response) { - - } + public void onResponse(Call call, retrofit2.Response response) {} @Override - public void onFailure(Call call, Throwable t) { - - } + public void onFailure(Call call, Throwable t) {} }); callList.add(call); } - protected void more(Status status, View view, final AdapterItemRemover adapter, - final int position) { + protected void more(final Status status, View view, final AdapterItemRemover adapter, + final int position) { final String id = status.getActionableId(); final String accountId = status.getActionableStatus().account.id; final String accountUsename = status.getActionableStatus().account.username; @@ -201,12 +224,29 @@ public class SFragment extends BaseFragment { @Override public boolean onMenuItemClick(MenuItem item) { switch (item.getItemId()) { - case R.id.status_share: { + case R.id.status_share_content: { + StringBuilder sb = new StringBuilder(); + sb.append(status.account.username); + sb.append(" - "); + sb.append(status.content.toString()); + + Intent sendIntent = new Intent(); + sendIntent.setAction(Intent.ACTION_SEND); + sendIntent.putExtra(Intent.EXTRA_TEXT, sb.toString()); + sendIntent.setType("text/plain"); + startActivity(Intent.createChooser(sendIntent, getResources().getText(R.string.send_status_content_to))); + return true; + } + case R.id.status_share_link: { Intent sendIntent = new Intent(); sendIntent.setAction(Intent.ACTION_SEND); sendIntent.putExtra(Intent.EXTRA_TEXT, statusUrl); sendIntent.setType("text/plain"); - startActivity(Intent.createChooser(sendIntent, getResources().getText(R.string.send_status_to))); + startActivity(Intent.createChooser(sendIntent, getResources().getText(R.string.send_status_link_to))); + return true; + } + case R.id.status_mute: { + mute(accountId); return true; } case R.id.status_block: { diff --git a/app/src/main/java/com/keylesspalace/tusky/SpannedTypeAdapter.java b/app/src/main/java/com/keylesspalace/tusky/SpannedTypeAdapter.java index 817d6d3f3..3b47acc3b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/SpannedTypeAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/SpannedTypeAdapter.java @@ -25,7 +25,7 @@ import com.google.gson.JsonParseException; import java.lang.reflect.Type; -class SpannedTypeAdapter implements JsonDeserializer { +public class SpannedTypeAdapter implements JsonDeserializer { @Override public Spanned deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { return HtmlUtils.fromHtml(Emojione.shortnameToUnicode(json.getAsString(), false)); diff --git a/app/src/main/java/com/keylesspalace/tusky/StatusActionListener.java b/app/src/main/java/com/keylesspalace/tusky/StatusActionListener.java index 9d14ed96b..1004bb824 100644 --- a/app/src/main/java/com/keylesspalace/tusky/StatusActionListener.java +++ b/app/src/main/java/com/keylesspalace/tusky/StatusActionListener.java @@ -19,13 +19,11 @@ import android.view.View; import com.keylesspalace.tusky.entity.Status; -interface StatusActionListener { +interface StatusActionListener extends LinkListener { void onReply(int position); void onReblog(final boolean reblog, final int position); void onFavourite(final boolean favourite, final int position); void onMore(View view, final int position); void onViewMedia(String url, Status.MediaAttachment.Type type); void onViewThread(int position); - void onViewTag(String tag); - void onViewAccount(String id); } diff --git a/app/src/main/java/com/keylesspalace/tusky/StatusRemoveListener.java b/app/src/main/java/com/keylesspalace/tusky/StatusRemoveListener.java new file mode 100644 index 000000000..23111c6ab --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/StatusRemoveListener.java @@ -0,0 +1,20 @@ +/* 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; + +interface StatusRemoveListener { + void removePostsByUser(String accountId); +} diff --git a/app/src/main/java/com/keylesspalace/tusky/StatusViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/StatusViewHolder.java index 9addd6565..6a2e431df 100644 --- a/app/src/main/java/com/keylesspalace/tusky/StatusViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/StatusViewHolder.java @@ -16,14 +16,9 @@ package com.keylesspalace.tusky; import android.content.Context; -import android.preference.PreferenceManager; import android.support.annotation.Nullable; import android.support.v7.widget.RecyclerView; -import android.text.SpannableStringBuilder; import android.text.Spanned; -import android.text.method.LinkMovementMethod; -import android.text.style.ClickableSpan; -import android.text.style.URLSpan; import android.view.View; import android.widget.CompoundButton; import android.widget.ImageButton; @@ -102,57 +97,10 @@ class StatusViewHolder extends RecyclerView.ViewHolder { } private void setContent(Spanned content, Status.Mention[] mentions, - final StatusActionListener listener) { + StatusActionListener listener) { /* Redirect URLSpan's in the status content to the listener for viewing tag pages and * account pages. */ - SpannableStringBuilder builder = new SpannableStringBuilder(content); - boolean useCustomTabs = PreferenceManager.getDefaultSharedPreferences(container.getContext()).getBoolean("customTabs", true); - URLSpan[] urlSpans = content.getSpans(0, content.length(), URLSpan.class); - for (URLSpan span : urlSpans) { - int start = builder.getSpanStart(span); - int end = builder.getSpanEnd(span); - int flags = builder.getSpanFlags(span); - CharSequence text = builder.subSequence(start, end); - if (text.charAt(0) == '#') { - final String tag = text.subSequence(1, text.length()).toString(); - ClickableSpan newSpan = new ClickableSpan() { - @Override - public void onClick(View widget) { - listener.onViewTag(tag); - } - }; - builder.removeSpan(span); - builder.setSpan(newSpan, start, end, flags); - } else if (text.charAt(0) == '@') { - final String accountUsername = text.subSequence(1, text.length()).toString(); - String id = null; - for (Status.Mention mention: mentions) { - if (mention.username.equals(accountUsername)) { - id = mention.id; - } - } - if (id != null) { - final String accountId = id; - ClickableSpan newSpan = new ClickableSpan() { - @Override - public void onClick(View widget) { - listener.onViewAccount(accountId); - } - }; - builder.removeSpan(span); - builder.setSpan(newSpan, start, end, flags); - } - } else if (useCustomTabs) { - ClickableSpan newSpan = new CustomTabURLSpan(span.getURL()); - builder.removeSpan(span); - builder.setSpan(newSpan, start, end, flags); - } - } - // Set the contents. - this.content.setText(builder); - // Make links clickable. - this.content.setLinksClickable(true); - this.content.setMovementMethod(LinkMovementMethod.getInstance()); + LinkHelper.setClickableText(this.content, content, mentions, listener); } private void setAvatar(String url) { @@ -230,7 +178,7 @@ class StatusViewHolder extends RecyclerView.ViewHolder { } private void setMediaPreviews(final Status.MediaAttachment[] attachments, - boolean sensitive, final StatusActionListener listener) { + boolean sensitive, final StatusActionListener listener) { final ImageView[] previews = { mediaPreview0, mediaPreview1, @@ -249,20 +197,32 @@ class StatusViewHolder extends RecyclerView.ViewHolder { previews[i].setVisibility(View.VISIBLE); - Picasso.with(context) - .load(previewUrl) - .placeholder(mediaPreviewUnloadedId) - .into(previews[i]); + if(previewUrl == null || previewUrl.isEmpty()) { + Picasso.with(context) + .load(mediaPreviewUnloadedId) + .into(previews[i]); + } else { + Picasso.with(context) + .load(previewUrl) + .placeholder(mediaPreviewUnloadedId) + .into(previews[i]); + } final String url = attachments[i].url; final Status.MediaAttachment.Type type = attachments[i].type; - previews[i].setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - listener.onViewMedia(url, type); - } - }); + if(url == null || url.isEmpty()) { + previews[i].setOnClickListener(null); + } else { + previews[i].setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + listener.onViewMedia(url, type); + } + }); + } + + } if (sensitive) { diff --git a/app/src/main/java/com/keylesspalace/tusky/StringWithEmoji.java b/app/src/main/java/com/keylesspalace/tusky/StringWithEmoji.java new file mode 100644 index 000000000..cad8e8c08 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/StringWithEmoji.java @@ -0,0 +1,31 @@ +/* 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; + +/** + * This is just a wrapper class for a String. + * + * It was designed to get around the limitation of a Json deserializer which only allows custom + * deserializing based on types, when special handling for a specific field was what was actually + * desired (in this case, display names). So, it was most expedient to just make up a type. + */ +public class StringWithEmoji { + public String value; + + public StringWithEmoji(String value) { + this.value = value; + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/StringWithEmojiTypeAdapter.java b/app/src/main/java/com/keylesspalace/tusky/StringWithEmojiTypeAdapter.java new file mode 100644 index 000000000..ecf18bcd9 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/StringWithEmojiTypeAdapter.java @@ -0,0 +1,38 @@ +/* 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 com.emojione.Emojione; +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonParseException; + +import java.lang.reflect.Type; + +/** This is a type-based workaround to allow for shortcode conversion when loading display names. */ +class StringWithEmojiTypeAdapter implements JsonDeserializer { + @Override + public StringWithEmoji deserialize(JsonElement json, Type typeOfT, + JsonDeserializationContext context) throws JsonParseException { + String value = json.getAsString(); + if (value != null) { + return new StringWithEmoji(Emojione.shortnameToUnicode(value, false)); + } else { + return new StringWithEmoji(""); + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/ThreadAdapter.java b/app/src/main/java/com/keylesspalace/tusky/ThreadAdapter.java index fea024558..afa407f8e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ThreadAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/ThreadAdapter.java @@ -65,20 +65,55 @@ class ThreadAdapter extends RecyclerView.Adapter implements AdapterItemRemover { notifyItemRemoved(position); } - int insertStatus(Status status) { + public void removeAllByAccountId(String accountId) { + for (int i = 0; i < statuses.size();) { + Status status = statuses.get(i); + if (accountId.equals(status.account.id)) { + statuses.remove(i); + notifyItemRemoved(i); + } else { + i += 1; + } + } + } + + int setStatus(Status status) { + if (statuses.size() > 0 && statuses.get(statusIndex).equals(status)) { + // Do not add this status on refresh, it's already in there. + statuses.set(statusIndex, status); + return statusIndex; + } int i = statusIndex; statuses.add(i, status); notifyItemInserted(i); return i; } - void addAncestors(List ancestors) { + void setContext(List ancestors, List descendants) { + Status mainStatus = null; + + // In case of refresh, remove old ancestors and descendants first. We'll remove all blindly, + // as we have no guarantee on their order to be the same as before + int old_size = statuses.size(); + if (old_size > 0) { + mainStatus = statuses.get(statusIndex); + statuses.clear(); + notifyItemRangeRemoved(0, old_size); + } + + // Insert newly fetched ancestors statusIndex = ancestors.size(); statuses.addAll(0, ancestors); notifyItemRangeInserted(0, statusIndex); - } - void addDescendants(List descendants) { + if (mainStatus != null) { + // In case we needed to delete everything (which is way easier than deleting + // everything except one), re-insert the remaining status here. + statuses.add(statusIndex, mainStatus); + notifyItemInserted(statusIndex); + } + + // Insert newly fetched descendants int end = statuses.size(); statuses.addAll(descendants); notifyItemRangeInserted(end, descendants.size()); diff --git a/app/src/main/java/com/keylesspalace/tusky/TimelineAdapter.java b/app/src/main/java/com/keylesspalace/tusky/TimelineAdapter.java index 8f8fc5460..759a23579 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TimelineAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/TimelineAdapter.java @@ -30,8 +30,15 @@ class TimelineAdapter extends RecyclerView.Adapter implements AdapterItemRemover private static final int VIEW_TYPE_STATUS = 0; private static final int VIEW_TYPE_FOOTER = 1; + enum FooterState { + EMPTY, + END, + LOADING + } + private List statuses; private StatusActionListener statusListener; + private FooterState footerState = FooterState.END; TimelineAdapter(StatusActionListener statusListener) { super(); @@ -49,13 +56,37 @@ class TimelineAdapter extends RecyclerView.Adapter implements AdapterItemRemover return new StatusViewHolder(view); } case VIEW_TYPE_FOOTER: { - View view = LayoutInflater.from(viewGroup.getContext()) - .inflate(R.layout.item_footer, viewGroup, false); + View view; + switch (footerState) { + default: + case LOADING: + view = LayoutInflater.from(viewGroup.getContext()) + .inflate(R.layout.item_footer, viewGroup, false); + break; + case END: { + view = LayoutInflater.from(viewGroup.getContext()) + .inflate(R.layout.item_footer_end, viewGroup, false); + break; + } + case EMPTY: { + view = LayoutInflater.from(viewGroup.getContext()) + .inflate(R.layout.item_footer_empty, viewGroup, false); + break; + } + } return new FooterViewHolder(view); } } } + void setFooterState(FooterState newFooterState) { + FooterState oldValue = footerState; + footerState = newFooterState; + if (footerState != oldValue) { + notifyItemChanged(statuses.size()); + } + } + @Override public void onBindViewHolder(RecyclerView.ViewHolder viewHolder, int position) { if (position < statuses.size()) { @@ -111,6 +142,18 @@ class TimelineAdapter extends RecyclerView.Adapter implements AdapterItemRemover notifyItemRemoved(position); } + void removeAllByAccountId(String accountId) { + for (int i = 0; i < statuses.size();) { + Status status = statuses.get(i); + if (accountId.equals(status.account.id)) { + statuses.remove(i); + notifyItemRemoved(i); + } else { + i += 1; + } + } + } + @Nullable Status getItem(int position) { if (position >= 0 && position < statuses.size()) { diff --git a/app/src/main/java/com/keylesspalace/tusky/TimelineFragment.java b/app/src/main/java/com/keylesspalace/tusky/TimelineFragment.java index 4f1b6e205..d23bbed3a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TimelineFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/TimelineFragment.java @@ -39,7 +39,10 @@ import retrofit2.Call; import retrofit2.Callback; public class TimelineFragment extends SFragment implements - SwipeRefreshLayout.OnRefreshListener, StatusActionListener { + SwipeRefreshLayout.OnRefreshListener, + StatusActionListener, + StatusRemoveListener, + SharedPreferences.OnSharedPreferenceChangeListener { private static final String TAG = "Timeline"; // logging tag private Call> listCall; @@ -61,6 +64,7 @@ public class TimelineFragment extends SFragment implements private LinearLayoutManager layoutManager; private EndlessOnScrollListener scrollListener; private TabLayout.OnTabSelectedListener onTabSelectedListener; + private boolean hideFab; public static TimelineFragment newInstance(Kind kind) { TimelineFragment fragment = new TimelineFragment(); @@ -145,24 +149,28 @@ public class TimelineFragment extends SFragment implements /* This is delayed until onActivityCreated solely because MainActivity.composeButton isn't * guaranteed to be set until then. */ - if (followButtonPresent()) { + if (composeButtonPresent()) { /* Use a modified scroll listener that both loads more statuses as it goes, and hides * the follow button on down-scroll. */ MainActivity activity = (MainActivity) getActivity(); final FloatingActionButton composeButton = activity.composeButton; final SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences( activity); + preferences.registerOnSharedPreferenceChangeListener(this); + hideFab = preferences.getBoolean("fabHide", false); scrollListener = new EndlessOnScrollListener(layoutManager) { @Override public void onScrolled(RecyclerView view, int dx, int dy) { super.onScrolled(view, dx, dy); - if (preferences.getBoolean("fabHide", false)) { + if (hideFab) { if (dy > 0 && composeButton.isShown()) { composeButton.hide(); // hides the button if we're scrolling down } else if (dy < 0 && !composeButton.isShown()) { composeButton.show(); // shows it if we are scrolling up } + } else if (!composeButton.isShown()) { + composeButton.show(); } } @@ -202,7 +210,7 @@ public class TimelineFragment extends SFragment implements return kind != Kind.TAG && kind != Kind.FAVOURITES; } - private boolean followButtonPresent() { + private boolean composeButtonPresent() { return kind != Kind.TAG && kind != Kind.FAVOURITES && kind != Kind.USER; } @@ -212,7 +220,9 @@ public class TimelineFragment extends SFragment implements } private void sendFetchTimelineRequest(@Nullable final String fromId, @Nullable String uptoId) { - MastodonAPI api = ((BaseActivity) getActivity()).mastodonAPI; + if (fromId != null || adapter.getItemCount() <= 1) { + adapter.setFooterState(TimelineAdapter.FooterState.LOADING); + } Callback> cb = new Callback>() { @Override @@ -233,27 +243,27 @@ public class TimelineFragment extends SFragment implements switch (kind) { default: case HOME: { - listCall = api.homeTimeline(fromId, uptoId, null); + listCall = mastodonAPI.homeTimeline(fromId, uptoId, null); break; } case PUBLIC_FEDERATED: { - listCall = api.publicTimeline(null, fromId, uptoId, null); + listCall = mastodonAPI.publicTimeline(null, fromId, uptoId, null); break; } case PUBLIC_LOCAL: { - listCall = api.publicTimeline(true, fromId, uptoId, null); + listCall = mastodonAPI.publicTimeline(true, fromId, uptoId, null); break; } case TAG: { - listCall = api.hashtagTimeline(hashtagOrId, null, fromId, uptoId, null); + listCall = mastodonAPI.hashtagTimeline(hashtagOrId, null, fromId, uptoId, null); break; } case USER: { - listCall = api.accountStatuses(hashtagOrId, fromId, uptoId, null); + listCall = mastodonAPI.accountStatuses(hashtagOrId, fromId, uptoId, null); break; } case FAVOURITES: { - listCall = api.favourites(fromId, uptoId, null); + listCall = mastodonAPI.favourites(fromId, uptoId, null); break; } } @@ -265,6 +275,10 @@ public class TimelineFragment extends SFragment implements sendFetchTimelineRequest(null, null); } + public void removePostsByUser(String accountId) { + adapter.removeAllByAccountId(accountId); + } + private static boolean findStatus(List statuses, String id) { for (Status status : statuses) { if (status.id.equals(id)) { @@ -282,6 +296,11 @@ public class TimelineFragment extends SFragment implements } else { adapter.update(statuses); } + if (statuses.size() == 0 && adapter.getItemCount() == 1) { + adapter.setFooterState(TimelineAdapter.FooterState.EMPTY); + } else if(fromId != null) { + adapter.setFooterState(TimelineAdapter.FooterState.END); + } swipeRefreshLayout.setRefreshing(false); } @@ -299,6 +318,14 @@ public class TimelineFragment extends SFragment implements } } + @Override + public void onSuccessfulStatus() { + if (kind == Kind.HOME || kind == Kind.PUBLIC_FEDERATED || kind == Kind.PUBLIC_LOCAL) { + onRefresh(); + } + super.onSuccessfulStatus(); + } + public void onReply(int position) { super.reply(adapter.getItem(position)); } @@ -339,4 +366,11 @@ public class TimelineFragment extends SFragment implements } super.viewAccount(id); } + + @Override + public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { + if(key.equals("fabHide")) { + hideFab = sharedPreferences.getBoolean("fabHide", false); + } + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/TimelinePagerAdapter.java b/app/src/main/java/com/keylesspalace/tusky/TimelinePagerAdapter.java index af9bc6393..03dc88dc9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TimelinePagerAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/TimelinePagerAdapter.java @@ -18,10 +18,35 @@ package com.keylesspalace.tusky; import android.support.v4.app.Fragment; import android.support.v4.app.FragmentManager; import android.support.v4.app.FragmentPagerAdapter; +import android.view.ViewGroup; + +import java.util.ArrayList; +import java.util.List; class TimelinePagerAdapter extends FragmentPagerAdapter { + private int currentFragmentIndex; + private List registeredFragments; + TimelinePagerAdapter(FragmentManager manager) { super(manager); + currentFragmentIndex = 0; + registeredFragments = new ArrayList<>(); + } + + Fragment getCurrentFragment() { + return registeredFragments.get(currentFragmentIndex); + } + + List getRegisteredFragments() { + return registeredFragments; + } + + @Override + public void setPrimaryItem(ViewGroup container, int position, Object object) { + if (position != currentFragmentIndex) { + currentFragmentIndex = position; + } + super.setPrimaryItem(container, position, object); } @Override @@ -54,4 +79,17 @@ class TimelinePagerAdapter extends FragmentPagerAdapter { public CharSequence getPageTitle(int position) { return null; } + + @Override + public Object instantiateItem(ViewGroup container, int position) { + Fragment fragment = (Fragment) super.instantiateItem(container, position); + registeredFragments.add(fragment); + return fragment; + } + + @Override + public void destroyItem(ViewGroup container, int position, Object object) { + registeredFragments.remove((Fragment) object); + super.destroyItem(container, position, object); + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java new file mode 100644 index 000000000..6fc59398d --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java @@ -0,0 +1,49 @@ +/* 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.Application; +import android.net.Uri; + +import com.squareup.picasso.Picasso; +import com.jakewharton.picasso.OkHttp3Downloader; + +public class TuskyApplication extends Application { + @Override + public void onCreate() { + // Initialize Picasso configuration + Picasso.Builder builder = new Picasso.Builder(this); + builder.downloader(new OkHttp3Downloader(this)); + if (BuildConfig.DEBUG) { + builder.listener(new Picasso.Listener() { + @Override + public void onImageLoadFailed(Picasso picasso, Uri uri, Exception exception) { + exception.printStackTrace(); + } + }); + } + + try { + Picasso.setSingletonInstance(builder.build()); + } catch (IllegalStateException e) { + throw new RuntimeException(e); + } + + if (BuildConfig.DEBUG) { + Picasso.with(this).setLoggingEnabled(true); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/ViewMediaFragment.java b/app/src/main/java/com/keylesspalace/tusky/ViewMediaFragment.java index 67517a47a..fed8cbebd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ViewMediaFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/ViewMediaFragment.java @@ -15,9 +15,21 @@ package com.keylesspalace.tusky; +import android.app.AlertDialog; +import android.app.DownloadManager; +import android.content.Context; +import android.content.DialogInterface; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.os.Build; import android.os.Bundle; +import android.os.Environment; +import android.support.annotation.NonNull; import android.support.annotation.Nullable; +import android.support.annotation.StringRes; +import android.support.design.widget.Snackbar; import android.support.v4.app.DialogFragment; +import android.support.v4.content.ContextCompat; import android.view.LayoutInflater; import android.view.MotionEvent; import android.view.View; @@ -27,6 +39,8 @@ import android.view.WindowManager; import com.squareup.picasso.Callback; import com.squareup.picasso.Picasso; +import java.io.File; + import butterknife.BindView; import butterknife.ButterKnife; import uk.co.senab.photoview.PhotoView; @@ -35,6 +49,9 @@ import uk.co.senab.photoview.PhotoViewAttacher; public class ViewMediaFragment extends DialogFragment { private PhotoViewAttacher attacher; + private DownloadManager downloadManager; + + private static final int PERMISSIONS_REQUEST_WRITE_EXTERNAL_STORAGE = 1; @BindView(R.id.view_media_image) PhotoView photoView; @@ -99,6 +116,25 @@ public class ViewMediaFragment extends DialogFragment { } }); + attacher.setOnLongClickListener(new View.OnLongClickListener() { + @Override + public boolean onLongClick(View v) { + + AlertDialog downloadDialog = new AlertDialog.Builder(getContext()).create(); + + downloadDialog.setButton(AlertDialog.BUTTON_NEUTRAL, getString(R.string.dialog_download_image), + new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int which) { + dialog.dismiss(); + + downloadImage(); + } + }); + downloadDialog.show(); + return false; + } + }); + Picasso.with(getContext()) .load(url) .into(photoView, new Callback() { @@ -121,4 +157,63 @@ public class ViewMediaFragment extends DialogFragment { attacher.cleanup(); super.onDestroyView(); } + + private void downloadImage(){ + + //Permission stuff + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN && + ContextCompat.checkSelfPermission(this.getContext(), android.Manifest.permission.WRITE_EXTERNAL_STORAGE) + != PackageManager.PERMISSION_GRANTED) { + android.support.v4.app.ActivityCompat.requestPermissions(getActivity(), + new String[] { android.Manifest.permission.WRITE_EXTERNAL_STORAGE }, + PERMISSIONS_REQUEST_WRITE_EXTERNAL_STORAGE); + } else { + + + //download stuff + String url = getArguments().getString("url"); + Uri uri = Uri.parse(url); + + String filename = new File(url).getName(); + + downloadManager = (DownloadManager) getContext().getSystemService(Context.DOWNLOAD_SERVICE); + + DownloadManager.Request request = new DownloadManager.Request(uri); + request.allowScanningByMediaScanner(); + request.setDestinationInExternalPublicDir(Environment.DIRECTORY_PICTURES, getString(R.string.app_name) + "/" + filename); + + downloadManager.enqueue(request); + } + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String permissions[], + @NonNull int[] grantResults) { + switch (requestCode) { + case PERMISSIONS_REQUEST_WRITE_EXTERNAL_STORAGE: { + if (grantResults.length > 0 + && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + downloadImage(); + } else { + doErrorDialog(R.string.error_media_download_permission, R.string.action_retry, + new View.OnClickListener() { + @Override + public void onClick(View v) { + downloadImage(); + } + }); + } + break; + } + } + } + + private void doErrorDialog(@StringRes int descriptionId, @StringRes int actionId, + View.OnClickListener listener) { + Snackbar bar = Snackbar.make(getView(), getString(descriptionId), + Snackbar.LENGTH_SHORT); + bar.setAction(actionId, listener); + bar.show(); + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/ViewTagActivity.java b/app/src/main/java/com/keylesspalace/tusky/ViewTagActivity.java index 0536330c5..9bce54a14 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ViewTagActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/ViewTagActivity.java @@ -26,7 +26,9 @@ import android.view.MenuItem; import butterknife.BindView; import butterknife.ButterKnife; -public class ViewTagActivity extends BaseActivity { +public class ViewTagActivity extends BaseActivity implements SFragment.OnUserRemovedListener { + private Fragment timelineFragment; + @BindView(R.id.toolbar) Toolbar toolbar; @Override @@ -51,6 +53,8 @@ public class ViewTagActivity extends BaseActivity { Fragment fragment = TimelineFragment.newInstance(TimelineFragment.Kind.TAG, hashtag); fragmentTransaction.add(R.id.fragment_container, fragment); fragmentTransaction.commit(); + + timelineFragment = fragment; } @Override @@ -63,4 +67,10 @@ public class ViewTagActivity extends BaseActivity { } return super.onOptionsItemSelected(item); } + + @Override + public void onUserRemoved(String accountId) { + StatusRemoveListener listener = (StatusRemoveListener) timelineFragment; + listener.removePostsByUser(accountId); + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/ViewThreadActivity.java b/app/src/main/java/com/keylesspalace/tusky/ViewThreadActivity.java index cee1d2445..9dcd5efac 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ViewThreadActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/ViewThreadActivity.java @@ -24,7 +24,9 @@ import android.support.v7.widget.Toolbar; import android.view.Menu; import android.view.MenuItem; -public class ViewThreadActivity extends BaseActivity { +public class ViewThreadActivity extends BaseActivity implements SFragment.OnUserRemovedListener { + Fragment viewThreadFragment; + @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -44,6 +46,8 @@ public class ViewThreadActivity extends BaseActivity { Fragment fragment = ViewThreadFragment.newInstance(id); fragmentTransaction.add(R.id.fragment_container, fragment); fragmentTransaction.commit(); + + viewThreadFragment = fragment; } @Override @@ -62,4 +66,12 @@ public class ViewThreadActivity extends BaseActivity { } return super.onOptionsItemSelected(item); } + + @Override + public void onUserRemoved(String accountId) { + if (viewThreadFragment instanceof StatusRemoveListener) { + StatusRemoveListener listener = (StatusRemoveListener) viewThreadFragment; + listener.removePostsByUser(accountId); + } + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/ViewThreadFragment.java b/app/src/main/java/com/keylesspalace/tusky/ViewThreadFragment.java index 4101b44a1..0cf476005 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ViewThreadFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/ViewThreadFragment.java @@ -21,6 +21,7 @@ import android.os.Bundle; import android.support.annotation.Nullable; import android.support.design.widget.Snackbar; import android.support.v4.content.ContextCompat; +import android.support.v4.widget.SwipeRefreshLayout; import android.support.v7.widget.DividerItemDecoration; import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.RecyclerView; @@ -34,9 +35,11 @@ import com.keylesspalace.tusky.entity.StatusContext; import retrofit2.Call; import retrofit2.Callback; -public class ViewThreadFragment extends SFragment implements StatusActionListener { +public class ViewThreadFragment extends SFragment implements + SwipeRefreshLayout.OnRefreshListener, StatusActionListener, StatusRemoveListener { private static final String TAG = "ViewThreadFragment"; + private SwipeRefreshLayout swipeRefreshLayout; private RecyclerView recyclerView; private ThreadAdapter adapter; private String thisThreadsStatusId; @@ -56,6 +59,9 @@ public class ViewThreadFragment extends SFragment implements StatusActionListene View rootView = inflater.inflate(R.layout.fragment_view_thread, container, false); Context context = getContext(); + swipeRefreshLayout = (SwipeRefreshLayout) rootView.findViewById(R.id.swipe_refresh_layout); + swipeRefreshLayout.setOnRefreshListener(this); + recyclerView = (RecyclerView) rootView.findViewById(R.id.recycler_view); recyclerView.setHasFixedSize(true); LinearLayoutManager layoutManager = new LinearLayoutManager(context); @@ -86,7 +92,7 @@ public class ViewThreadFragment extends SFragment implements StatusActionListene @Override public void onResponse(Call call, retrofit2.Response response) { if (response.isSuccessful()) { - int position = adapter.insertStatus(response.body()); + int position = adapter.setStatus(response.body()); recyclerView.scrollToPosition(position); } else { onThreadRequestFailure(id); @@ -109,10 +115,10 @@ public class ViewThreadFragment extends SFragment implements StatusActionListene @Override public void onResponse(Call call, retrofit2.Response response) { if (response.isSuccessful()) { + swipeRefreshLayout.setRefreshing(false); StatusContext context = response.body(); - adapter.addAncestors(context.ancestors); - adapter.addDescendants(context.descendants); + adapter.setContext(context.ancestors, context.descendants); } else { onThreadRequestFailure(id); } @@ -128,6 +134,7 @@ public class ViewThreadFragment extends SFragment implements StatusActionListene private void onThreadRequestFailure(final String id) { View view = getView(); + swipeRefreshLayout.setRefreshing(false); if (view != null) { Snackbar.make(view, R.string.error_generic, Snackbar.LENGTH_LONG) .setAction(R.string.action_retry, new View.OnClickListener() { @@ -143,6 +150,22 @@ public class ViewThreadFragment extends SFragment implements StatusActionListene } } + @Override + public void removePostsByUser(String accountId) { + adapter.removeAllByAccountId(accountId); + } + + public void onRefresh() { + sendStatusRequest(thisThreadsStatusId); + sendThreadRequest(thisThreadsStatusId); + } + + @Override + public void onSuccessfulStatus() { + onRefresh(); + super.onSuccessfulStatus(); + } + public void onReply(int position) { super.reply(adapter.getItem(position)); } diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Account.java b/app/src/main/java/com/keylesspalace/tusky/entity/Account.java index d76eb7c01..e7be8e810 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Account.java +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Account.java @@ -21,6 +21,7 @@ import android.text.Spanned; import com.arlib.floatingsearchview.suggestions.model.SearchSuggestion; import com.google.gson.annotations.SerializedName; import com.keylesspalace.tusky.HtmlUtils; +import com.keylesspalace.tusky.StringWithEmoji; public class Account implements SearchSuggestion { public String id; @@ -32,7 +33,7 @@ public class Account implements SearchSuggestion { public String username; @SerializedName("display_name") - public String displayName; + public StringWithEmoji displayName; public Spanned note; @@ -70,11 +71,10 @@ public class Account implements SearchSuggestion { } public String getDisplayName() { - if (displayName.length() == 0) { + if (displayName.value.length() == 0) { return localUsername; } - - return displayName; + return displayName.value; } @Override @@ -92,7 +92,7 @@ public class Account implements SearchSuggestion { dest.writeString(id); dest.writeString(localUsername); dest.writeString(username); - dest.writeString(displayName); + dest.writeString(displayName.value); dest.writeString(HtmlUtils.toHtml(note)); dest.writeString(url); dest.writeString(avatar); @@ -111,7 +111,7 @@ public class Account implements SearchSuggestion { id = in.readString(); localUsername = in.readString(); username = in.readString(); - displayName = in.readString(); + displayName = new StringWithEmoji(in.readString()); note = HtmlUtils.fromHtml(in.readString()); url = in.readString(); avatar = in.readString(); diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Profile.java b/app/src/main/java/com/keylesspalace/tusky/entity/Profile.java new file mode 100644 index 000000000..dca63311d --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Profile.java @@ -0,0 +1,19 @@ +package com.keylesspalace.tusky.entity; + +import com.google.gson.annotations.SerializedName; + +public class Profile { + @SerializedName("display_name") + public String displayName; + + @SerializedName("note") + public String note; + + /** Encoded in Base-64 */ + @SerializedName("avatar") + public String avatar; + + /** Encoded in Base-64 */ + @SerializedName("header") + public String header; +} diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Status.java b/app/src/main/java/com/keylesspalace/tusky/entity/Status.java index c793e239f..4e147358a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Status.java +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Status.java @@ -146,5 +146,8 @@ public class Status { @SerializedName("acct") public String username; + + @SerializedName("username") + public String localUsername; } } diff --git a/app/src/main/res/color/drawer_visibility_panel_item.xml b/app/src/main/res/color/drawer_visibility_panel_item.xml new file mode 100644 index 000000000..9849dfa9a --- /dev/null +++ b/app/src/main/res/color/drawer_visibility_panel_item.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_camera_24dp.xml b/app/src/main/res/drawable/ic_camera_24dp.xml new file mode 100644 index 000000000..dfccca424 --- /dev/null +++ b/app/src/main/res/drawable/ic_camera_24dp.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_check_24dp.xml b/app/src/main/res/drawable/ic_check_24dp.xml new file mode 100644 index 000000000..6541ee3e3 --- /dev/null +++ b/app/src/main/res/drawable/ic_check_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_check_in_box_24dp.xml b/app/src/main/res/drawable/ic_check_in_box_24dp.xml new file mode 100644 index 000000000..f73e39df6 --- /dev/null +++ b/app/src/main/res/drawable/ic_check_in_box_24dp.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/src/main/res/drawable/ic_email_24dp.xml b/app/src/main/res/drawable/ic_email_24dp.xml new file mode 100644 index 000000000..a050d6f8f --- /dev/null +++ b/app/src/main/res/drawable/ic_email_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_local_24dp.xml b/app/src/main/res/drawable/ic_local_24dp.xml index 0d95dd5e1..295300743 100644 --- a/app/src/main/res/drawable/ic_local_24dp.xml +++ b/app/src/main/res/drawable/ic_local_24dp.xml @@ -1,27 +1,7 @@ - - - - - - - - + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_lock_open_24dp.xml b/app/src/main/res/drawable/ic_lock_open_24dp.xml new file mode 100644 index 000000000..72d7d1233 --- /dev/null +++ b/app/src/main/res/drawable/ic_lock_open_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_lock_outline_24dp.xml b/app/src/main/res/drawable/ic_lock_outline_24dp.xml new file mode 100644 index 000000000..a01457060 --- /dev/null +++ b/app/src/main/res/drawable/ic_lock_outline_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_mute_24dp.xml b/app/src/main/res/drawable/ic_mute_24dp.xml new file mode 100644 index 000000000..801dc9340 --- /dev/null +++ b/app/src/main/res/drawable/ic_mute_24dp.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/ic_reject_24dp.xml b/app/src/main/res/drawable/ic_reject_24dp.xml new file mode 100644 index 000000000..d11cc5c9c --- /dev/null +++ b/app/src/main/res/drawable/ic_reject_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_unmute_24dp.xml b/app/src/main/res/drawable/ic_unmute_24dp.xml new file mode 100644 index 000000000..7d4aadbdb --- /dev/null +++ b/app/src/main/res/drawable/ic_unmute_24dp.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/app/src/main/res/drawable/tusky_logo.png b/app/src/main/res/drawable/tusky_logo.png new file mode 100644 index 000000000..742f3e438 Binary files /dev/null and b/app/src/main/res/drawable/tusky_logo.png differ diff --git a/app/src/main/res/layout/activity_blocks.xml b/app/src/main/res/layout/activity_account_list.xml similarity index 90% rename from app/src/main/res/layout/activity_blocks.xml rename to app/src/main/res/layout/activity_account_list.xml index a305a19fe..c5d72f85e 100644 --- a/app/src/main/res/layout/activity_blocks.xml +++ b/app/src/main/res/layout/activity_account_list.xml @@ -5,7 +5,7 @@ android:id="@+id/activity_view_thread" android:layout_width="match_parent" android:layout_height="match_parent" - tools:context="com.keylesspalace.tusky.BlocksActivity"> + tools:context="com.keylesspalace.tusky.AccountListActivity"> + + + + + + + + - - - - - - - + + + + + + + + + + + + + + + + +