Merge branch 'master' into feature/about-page

This commit is contained in:
serage.betelmal 2017-04-30 21:09:09 +01:00
commit 9b86c93e76
99 changed files with 4321 additions and 893 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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<List<Account>> 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<List<Account>> 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<Relationship> callback = new Callback<Relationship>() {
@Override
public void onResponse(Call<Relationship> call, Response<Relationship> response) {
if (response.isSuccessful()) {
onMuteSuccess(mute, id, position);
} else {
onMuteFailure(mute, id);
}
}
@Override
public void onFailure(Call<Relationship> call, Throwable t) {
onMuteFailure(mute, id);
}
};
Call<Relationship> 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<Relationship> call, Response<Relationship> 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<Relationship> callback = new Callback<Relationship>() {
@Override
public void onResponse(Call<Relationship> call, Response<Relationship> response) {
if (response.isSuccessful()) {
onRespondToFollowRequestSuccess(position);
} else {
onRespondToFollowRequestFailure(accept, accountId);
}
}
@Override
public void onFailure(Call<Relationship> call, Throwable t) {
onRespondToFollowRequestFailure(accept, accountId);
}
};
Call<Relationship> 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() {

View file

@ -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<Fragment> 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<Fragment> getRegisteredFragments() {
return registeredFragments;
}
}

View file

@ -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<ResponseBody>() {
@Override
public void onResponse(Call<ResponseBody> call, retrofit2.Response<ResponseBody> 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<ResponseBody>() {
@Override
public void onResponse(Call<ResponseBody> call, retrofit2.Response<ResponseBody> response) {
Log.d(TAG, "Enable push notifications response: " + response.message());
}
@Override
public void onFailure(Call<ResponseBody> call, Throwable t) {
Log.d(TAG, "Enable push notifications failed: " + t.getMessage());
}
});
@Override
public void onFailure(Call<ResponseBody> 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<ResponseBody>() {
@Override
public void onResponse(Call<ResponseBody> call, retrofit2.Response<ResponseBody> response) {
Log.d(TAG, "Disable push notifications response: " + response.message());
}
if (BuildConfig.USES_PUSH_NOTIFICATIONS) {
tuskyAPI.unregister(getBaseUrl(), getAccessToken()).enqueue(new Callback<ResponseBody>() {
@Override
public void onResponse(Call<ResponseBody> call, retrofit2.Response<ResponseBody> response) {
Log.d(TAG, "Disable push notifications response: " + response.message());
}
@Override
public void onFailure(Call<ResponseBody> call, Throwable t) {
Log.d(TAG, "Disable push notifications failed: " + t.getMessage());
}
});
@Override
public void onFailure(Call<ResponseBody> 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);
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<Uri, Void, Boolean> {
this.listener = listener;
}
@Nullable
private static Bitmap reorientBitmap(Bitmap bitmap, int orientation) {
Matrix matrix = new Matrix();
switch (orientation) {

View file

@ -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 <http://www.gnu.org/licenses>. */
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<Account>() {
@Override
public void onResponse(Call<Account> call, Response<Account> 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<Account> 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<Account>() {
@Override
public void onResponse(Call<Account> call, Response<Account> response) {
if (!response.isSuccessful()) {
onSaveFailure();
return;
}
getPrivatePreferences().edit()
.putBoolean("refreshProfileHeader", true)
.apply();
finish();
}
@Override
public void onFailure(Call<Account> 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<Bitmap> 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<Uri, Void, Boolean> {
private ContentResolver contentResolver;
private int resizeWidth;
private int resizeHeight;
private Listener listener;
private List<Bitmap> 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<Bitmap> contentList);
void onFailure();
}
}
}

View file

@ -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 <http://www.gnu.org/licenses>. */
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);
}
});
}
}
}

View file

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

View file

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

View file

@ -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 <http://www.gnu.org/licenses>. */
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());
}
}

View file

@ -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 <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky;
interface LinkListener {
void onViewTag(String tag);
void onViewAccount(String id);
}

View file

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

View file

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

View file

@ -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<Account> accountVerifyCredentials();
@PATCH("api/v1/accounts/update_credentials")
Call<Account> accountUpdateCredentials(
@Field("display_name") String displayName,
@Field("note") String note,
@Field("avatar") String avatar,
@Field("header") String header);
Call<Account> accountUpdateCredentials(@Body Profile profile);
@GET("api/v1/accounts/search")
Call<List<Account>> searchAccounts(
@Query("q") String q,

View file

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

View file

@ -1,83 +0,0 @@
/* Copyright 2017 Andrew Dawson
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>.
*
* 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 com.google.firebase.iid.FirebaseInstanceId;
import com.google.firebase.iid.FirebaseInstanceIdService;
import okhttp3.ResponseBody;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
import retrofit2.Retrofit;
public class MyFirebaseInstanceIdService extends FirebaseInstanceIdService {
private static final String TAG = "MyFirebaseInstanceIdService";
private TuskyAPI tuskyAPI;
protected void createTuskyAPI() {
Retrofit retrofit = new Retrofit.Builder()
.baseUrl(getString(R.string.tusky_api_url))
.client(OkHttpUtils.getCompatibleClient())
.build();
tuskyAPI = retrofit.create(TuskyAPI.class);
}
@Override
public void onTokenRefresh() {
createTuskyAPI();
String refreshedToken = FirebaseInstanceId.getInstance().getToken();
SharedPreferences preferences = getSharedPreferences(getString(R.string.preferences_file_key), Context.MODE_PRIVATE);
String accessToken = preferences.getString("accessToken", null);
String domain = preferences.getString("domain", null);
if (accessToken != null && domain != null) {
tuskyAPI.unregister("https://" + domain, accessToken).enqueue(new Callback<ResponseBody>() {
@Override
public void onResponse(Call<ResponseBody> call, Response<ResponseBody> response) {
Log.d(TAG, response.message());
}
@Override
public void onFailure(Call<ResponseBody> call, Throwable t) {
Log.d(TAG, t.getMessage());
}
});
tuskyAPI.register("https://" + domain, accessToken, refreshedToken).enqueue(new Callback<ResponseBody>() {
@Override
public void onResponse(Call<ResponseBody> call, Response<ResponseBody> response) {
Log.d(TAG, response.message());
}
@Override
public void onFailure(Call<ResponseBody> call, Throwable t) {
Log.d(TAG, t.getMessage());
}
});
}
}
}

View file

@ -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 <http://www.gnu.org/licenses>.
*
* 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<Notification>() {
@Override
public void onResponse(Call<Notification> call, Response<Notification> response) {
if (response.isSuccessful()) {
buildNotification(response.body());
}
}
@Override
public void onFailure(Call<Notification> 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;
}
}

View file

@ -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 <http://www.gnu.org/licenses>. */
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;
}
}

View file

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

View file

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

View file

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

View file

@ -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<Status> cb = new Callback<Status>() {
@ -101,16 +122,14 @@ public class SFragment extends BaseFragment {
}
@Override
public void onFailure(Call<Status> call, Throwable t) {
}
public void onFailure(Call<Status> call, Throwable t) {}
};
Call<Status> 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<Status> call, Throwable t) {
}
public void onFailure(Call<Status> call, Throwable t) {}
};
Call<Status> 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<Relationship> call = getApi().blockAccount(id);
private void mute(String id) {
Call<Relationship> call = mastodonAPI.muteAccount(id);
call.enqueue(new Callback<Relationship>() {
@Override
public void onResponse(Call<Relationship> call, retrofit2.Response<Relationship> response) {
}
public void onResponse(Call<Relationship> call, Response<Relationship> response) {}
@Override
public void onFailure(Call<Relationship> call, Throwable t) {
}
public void onFailure(Call<Relationship> call, Throwable t) {}
});
callList.add(call);
userRemovedListener.onUserRemoved(id);
}
private void block(String id) {
Call<Relationship> call = mastodonAPI.blockAccount(id);
call.enqueue(new Callback<Relationship>() {
@Override
public void onResponse(Call<Relationship> call, retrofit2.Response<Relationship> response) {}
@Override
public void onFailure(Call<Relationship> call, Throwable t) {}
});
callList.add(call);
userRemovedListener.onUserRemoved(id);
}
private void delete(String id) {
Call<ResponseBody> call = getApi().deleteStatus(id);
Call<ResponseBody> call = mastodonAPI.deleteStatus(id);
call.enqueue(new Callback<ResponseBody>() {
@Override
public void onResponse(Call<ResponseBody> call, retrofit2.Response<ResponseBody> response) {
}
public void onResponse(Call<ResponseBody> call, retrofit2.Response<ResponseBody> response) {}
@Override
public void onFailure(Call<ResponseBody> call, Throwable t) {
}
public void onFailure(Call<ResponseBody> 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: {

View file

@ -25,7 +25,7 @@ import com.google.gson.JsonParseException;
import java.lang.reflect.Type;
class SpannedTypeAdapter implements JsonDeserializer<Spanned> {
public class SpannedTypeAdapter implements JsonDeserializer<Spanned> {
@Override
public Spanned deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
return HtmlUtils.fromHtml(Emojione.shortnameToUnicode(json.getAsString(), false));

View file

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

View file

@ -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 <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky;
interface StatusRemoveListener {
void removePostsByUser(String accountId);
}

View file

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

View file

@ -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 <http://www.gnu.org/licenses>. */
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;
}
}

View file

@ -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 <http://www.gnu.org/licenses>. */
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<StringWithEmoji> {
@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("");
}
}
}

View file

@ -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<Status> ancestors) {
void setContext(List<Status> ancestors, List<Status> 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<Status> 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());

View file

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

View file

@ -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<List<Status>> 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<List<Status>> cb = new Callback<List<Status>>() {
@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<Status> 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);
}
}
}

View file

@ -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<Fragment> registeredFragments;
TimelinePagerAdapter(FragmentManager manager) {
super(manager);
currentFragmentIndex = 0;
registeredFragments = new ArrayList<>();
}
Fragment getCurrentFragment() {
return registeredFragments.get(currentFragmentIndex);
}
List<Fragment> 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);
}
}

View file

@ -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 <http://www.gnu.org/licenses>. */
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);
}
}
}

View file

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

View file

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

View file

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

View file

@ -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<Status> call, retrofit2.Response<Status> 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<StatusContext> call, retrofit2.Response<StatusContext> 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));
}

View file

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

View file

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

View file

@ -146,5 +146,8 @@ public class Status {
@SerializedName("acct")
public String username;
@SerializedName("username")
public String localUsername;
}
}