diff --git a/app/build.gradle b/app/build.gradle
index 3d0cdce2..0202e99b 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -28,6 +28,6 @@ dependencies {
compile 'com.android.support:appcompat-v7:25.1.0'
compile 'com.android.support:recyclerview-v7:25.1.0'
compile 'com.android.volley:volley:1.0.0'
- testCompile 'junit:junit:4.12'
compile 'com.android.support:design:25.1.0'
+ testCompile 'junit:junit:4.12'
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index b16c9ce8..5f6751ce 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -37,7 +37,7 @@
diff --git a/app/src/main/java/com/keylesspalace/tusky/Account.java b/app/src/main/java/com/keylesspalace/tusky/Account.java
new file mode 100644
index 00000000..8cb45b37
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/Account.java
@@ -0,0 +1,76 @@
+/* Copyright 2017 Andrew Dawson
+ *
+ * This file is part of Tusky.
+ *
+ * Tusky is free software: you can redistribute it and/or modify it under the terms of the GNU
+ * General Public License as published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
+ * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
+ * Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with Tusky. If not, see
+ * . */
+
+package com.keylesspalace.tusky;
+
+import android.text.Spanned;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class Account {
+ public String id;
+ public String username;
+ public String displayName;
+ public Spanned note;
+ public String url;
+ public String avatar;
+ public String header;
+ public String followersCount;
+ public String followingCount;
+ public String statusesCount;
+
+ public static Account parse(JSONObject object) throws JSONException {
+ Account account = new Account();
+ account.id = object.getString("id");
+ account.username = object.getString("acct");
+ account.displayName = object.getString("display_name");
+ if (account.displayName.isEmpty()) {
+ account.displayName = object.getString("username");
+ }
+ account.note = HtmlUtils.fromHtml(object.getString("note"));
+ account.url = object.getString("url");
+ String avatarUrl = object.getString("avatar");
+ if (!avatarUrl.equals("/avatars/original/missing.png")) {
+ account.avatar = avatarUrl;
+ } else {
+ account.avatar = "";
+ }
+ String headerUrl = object.getString("header");
+ if (!headerUrl.equals("/headers/original/missing.png")) {
+ account.header = headerUrl;
+ } else {
+ account.header = "";
+ }
+ account.followersCount = object.getString("followers_count");
+ account.followingCount = object.getString("following_count");
+ account.statusesCount = object.getString("statuses_count");
+ return account;
+ }
+
+ public static List parse(JSONArray array) throws JSONException {
+ List accounts = new ArrayList<>();
+ for (int i = 0; i < array.length(); i++) {
+ JSONObject object = array.getJSONObject(i);
+ Account account = parse(object);
+ accounts.add(account);
+ }
+ return accounts;
+ }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/AccountActionListener.java b/app/src/main/java/com/keylesspalace/tusky/AccountActionListener.java
new file mode 100644
index 00000000..fb1a5453
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/AccountActionListener.java
@@ -0,0 +1,20 @@
+/* Copyright 2017 Andrew Dawson
+ *
+ * This file is part of Tusky.
+ *
+ * Tusky is free software: you can redistribute it and/or modify it under the terms of the GNU
+ * General Public License as published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
+ * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
+ * Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with Tusky. If not, see
+ * . */
+
+package com.keylesspalace.tusky;
+
+public interface AccountActionListener {
+ void onViewAccount(String id);
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/AccountActivity.java b/app/src/main/java/com/keylesspalace/tusky/AccountActivity.java
index 46a819ec..61bab4fd 100644
--- a/app/src/main/java/com/keylesspalace/tusky/AccountActivity.java
+++ b/app/src/main/java/com/keylesspalace/tusky/AccountActivity.java
@@ -18,16 +18,20 @@ package com.keylesspalace.tusky;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
+import android.net.Uri;
import android.os.Bundle;
import android.support.annotation.Nullable;
+import android.support.design.widget.Snackbar;
import android.support.design.widget.TabLayout;
import android.support.v4.view.ViewPager;
import android.support.v7.app.ActionBar;
import android.support.v7.app.AppCompatActivity;
import android.support.v7.widget.Toolbar;
import android.text.Spanned;
+import android.util.Log;
import android.view.Menu;
import android.view.MenuItem;
+import android.view.View;
import android.widget.TextView;
import com.android.volley.AuthFailureError;
@@ -47,10 +51,16 @@ import java.util.HashMap;
import java.util.Map;
public class AccountActivity extends AppCompatActivity {
+ private static final String TAG = "AccountActivity"; // logging tag
+
private String domain;
private String accessToken;
+ private String accountId;
private boolean following = false;
private boolean blocking = false;
+ private boolean isSelf = false;
+ private String openInWebUrl;
+ private TabLayout tabLayout;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
@@ -58,17 +68,13 @@ public class AccountActivity extends AppCompatActivity {
setContentView(R.layout.activity_account);
Intent intent = getIntent();
- String username = intent.getStringExtra("username");
- String id = intent.getStringExtra("id");
- TextView accountName = (TextView) findViewById(R.id.account_username);
- accountName.setText(username);
+ accountId = intent.getStringExtra("id");
SharedPreferences preferences = getSharedPreferences(
getString(R.string.preferences_file_key), Context.MODE_PRIVATE);
domain = preferences.getString("domain", null);
accessToken = preferences.getString("accessToken", null);
- assert(domain != null);
- assert(accessToken != null);
+ String loggedInAccountId = preferences.getString("loggedInAccountId", null);
Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
setSupportActionBar(toolbar);
@@ -79,35 +85,50 @@ public class AccountActivity extends AppCompatActivity {
avatar.setErrorImageResId(R.drawable.avatar_error);
header.setDefaultImageResId(R.drawable.account_header_default);
- obtainAccount(id);
- obtainRelationships(id);
+ obtainAccount();
+ if (!accountId.equals(loggedInAccountId)) {
+ obtainRelationships();
+ } else {
+ /* Cause the options menu to update and instead show an options menu for when the
+ * account being shown is their own account. */
+ isSelf = true;
+ invalidateOptionsMenu();
+ }
// Setup the tabs and timeline pager.
- AccountPagerAdapter adapter = new AccountPagerAdapter(getSupportFragmentManager(), id);
+ AccountPagerAdapter adapter = new AccountPagerAdapter(
+ getSupportFragmentManager(), this, accountId);
String[] pageTitles = {
- getString(R.string.title_statuses),
- getString(R.string.title_follows),
- getString(R.string.title_followers)
+ getString(R.string.title_statuses),
+ getString(R.string.title_follows),
+ getString(R.string.title_followers)
};
adapter.setPageTitles(pageTitles);
ViewPager viewPager = (ViewPager) findViewById(R.id.pager);
viewPager.setAdapter(adapter);
- TabLayout tabLayout = (TabLayout) findViewById(R.id.tab_layout);
+ tabLayout = (TabLayout) findViewById(R.id.tab_layout);
tabLayout.setupWithViewPager(viewPager);
+ for (int i = 0; i < tabLayout.getTabCount(); i++) {
+ TabLayout.Tab tab = tabLayout.getTabAt(i);
+ tab.setCustomView(adapter.getTabView(i));
+ }
}
- private void obtainAccount(String id) {
- String endpoint = String.format(getString(R.string.endpoint_accounts), id);
+ private void obtainAccount() {
+ String endpoint = String.format(getString(R.string.endpoint_accounts), accountId);
String url = "https://" + domain + endpoint;
JsonObjectRequest request = new JsonObjectRequest(Request.Method.GET, url, null,
new Response.Listener() {
@Override
public void onResponse(JSONObject response) {
+ Account account;
try {
- onObtainAccountSuccess(response);
+ account = Account.parse(response);
} catch (JSONException e) {
onObtainAccountFailure();
+ return;
}
+ onObtainAccountSuccess(account);
}
},
new Response.ErrorListener() {
@@ -126,7 +147,7 @@ public class AccountActivity extends AppCompatActivity {
VolleySingleton.getInstance(this).addToRequestQueue(request);
}
- private void onObtainAccountSuccess(JSONObject response) throws JSONException {
+ private void onObtainAccountSuccess(Account account) {
TextView username = (TextView) findViewById(R.id.account_username);
TextView displayName = (TextView) findViewById(R.id.account_display_name);
TextView note = (TextView) findViewById(R.id.account_note);
@@ -134,36 +155,52 @@ public class AccountActivity extends AppCompatActivity {
NetworkImageView header = (NetworkImageView) findViewById(R.id.account_header);
String usernameFormatted = String.format(
- getString(R.string.status_username_format), response.getString("acct"));
+ getString(R.string.status_username_format), account.username);
username.setText(usernameFormatted);
- String displayNameString = response.getString("display_name");
- displayName.setText(displayNameString);
+ displayName.setText(account.displayName);
ActionBar actionBar = getSupportActionBar();
if (actionBar != null) {
- actionBar.setTitle(displayNameString);
+ actionBar.setTitle(account.displayName);
}
- String noteHtml = response.getString("note");
- Spanned noteSpanned = HtmlUtils.fromHtml(noteHtml);
- note.setText(noteSpanned);
+ note.setText(account.note);
+ note.setLinksClickable(true);
ImageLoader imageLoader = VolleySingleton.getInstance(this).getImageLoader();
- avatar.setImageUrl(response.getString("avatar"), imageLoader);
- String headerUrl = response.getString("header");
- if (!headerUrl.isEmpty() && !headerUrl.equals("/headers/original/missing.png")) {
- header.setImageUrl(headerUrl, imageLoader);
+ if (!account.avatar.isEmpty()) {
+ avatar.setImageUrl(account.avatar, imageLoader);
+ }
+ if (!account.header.isEmpty()) {
+ header.setImageUrl(account.header, imageLoader);
+ }
+
+ openInWebUrl = account.url;
+
+ // Add counts to the tabs in the TabLayout.
+ String[] counts = {
+ account.statusesCount,
+ account.followingCount,
+ account.followersCount,
+ };
+ for (int i = 0; i < tabLayout.getTabCount(); i++) {
+ TabLayout.Tab tab = tabLayout.getTabAt(i);
+ if (tab != null) {
+ View view = tab.getCustomView();
+ TextView total = (TextView) view.findViewById(R.id.total);
+ total.setText(counts[i]);
+ }
}
}
private void onObtainAccountFailure() {
//TODO: help
- assert(false);
+ Log.e(TAG, "Failed to obtain that account.");
}
- private void obtainRelationships(String id) {
+ private void obtainRelationships() {
String endpoint = getString(R.string.endpoint_relationships);
- String url = String.format("https://%s%s?id=%s", domain, endpoint, id);
+ String url = String.format("https://%s%s?id=%s", domain, endpoint, accountId);
JsonArrayRequest request = new JsonArrayRequest(url,
new Response.Listener() {
@Override
@@ -207,7 +244,7 @@ public class AccountActivity extends AppCompatActivity {
private void onObtainRelationshipsFailure() {
//TODO: help
- assert(false);
+ Log.e(TAG, "Could not obtain relationships?");
}
@Override
@@ -218,30 +255,139 @@ public class AccountActivity extends AppCompatActivity {
@Override
public boolean onPrepareOptionsMenu(Menu menu) {
- MenuItem follow = menu.findItem(R.id.action_follow);
- String title;
- if (following) {
- title = getString(R.string.action_unfollow);
- } else {
- title = getString(R.string.action_follow);
- }
- follow.setTitle(title);
- MenuItem block = menu.findItem(R.id.action_block);
- if (blocking) {
- title = getString(R.string.action_unblock);
+ if (!isSelf) {
+ MenuItem follow = menu.findItem(R.id.action_follow);
+ String title;
+ if (following) {
+ title = getString(R.string.action_unfollow);
+ } else {
+ title = getString(R.string.action_follow);
+ }
+ follow.setTitle(title);
+ MenuItem block = menu.findItem(R.id.action_block);
+ if (blocking) {
+ title = getString(R.string.action_unblock);
+ } else {
+ title = getString(R.string.action_block);
+ }
+ block.setTitle(title);
} else {
- title = getString(R.string.action_block);
+ // It shouldn't be possible to block or follow yourself.
+ menu.removeItem(R.id.action_follow);
+ menu.removeItem(R.id.action_block);
}
- block.setTitle(title);
return super.onPrepareOptionsMenu(menu);
}
- private void follow() {
+ private void postRequest(String endpoint, Response.Listener listener,
+ Response.ErrorListener errorListener) {
+ String url = "https://" + domain + endpoint;
+ JsonObjectRequest request = new JsonObjectRequest(Request.Method.POST, url, null, listener,
+ errorListener) {
+ @Override
+ public Map getHeaders() throws AuthFailureError {
+ Map headers = new HashMap<>();
+ headers.put("Authorization", "Bearer " + accessToken);
+ return headers;
+ }
+ };
+ VolleySingleton.getInstance(this).addToRequestQueue(request);
+ }
+
+ private void follow(final String id) {
+ int endpointId;
+ if (following) {
+ endpointId = R.string.endpoint_unfollow;
+ } else {
+ endpointId = R.string.endpoint_follow;
+ }
+ postRequest(String.format(getString(endpointId), id),
+ new Response.Listener() {
+ @Override
+ public void onResponse(JSONObject response) {
+ boolean followingValue;
+ try {
+ followingValue = response.getBoolean("following");
+ } catch (JSONException e) {
+ onFollowFailure(id);
+ return;
+ }
+ following = followingValue;
+ invalidateOptionsMenu();
+ }
+ },
+ new Response.ErrorListener() {
+ @Override
+ public void onErrorResponse(VolleyError error) {
+ onFollowFailure(id);
+ }
+ });
+ }
+ private void onFollowFailure(final String id) {
+ int messageId;
+ if (following) {
+ messageId = R.string.error_unfollowing;
+ } else {
+ messageId = R.string.error_following;
+ }
+ View.OnClickListener listener = new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ follow(id);
+ }
+ };
+ Snackbar.make(findViewById(R.id.activity_account), messageId, Snackbar.LENGTH_LONG)
+ .setAction(R.string.action_retry, listener)
+ .show();
}
- private void block() {
+ private void block(final String id) {
+ int endpointId;
+ if (blocking) {
+ endpointId = R.string.endpoint_unblock;
+ } else {
+ endpointId = R.string.endpoint_block;
+ }
+ postRequest(String.format(getString(endpointId), id),
+ new Response.Listener() {
+ @Override
+ public void onResponse(JSONObject response) {
+ boolean blockingValue;
+ try {
+ blockingValue = response.getBoolean("blocking");
+ } catch (JSONException e) {
+ onBlockFailure(id);
+ return;
+ }
+ blocking = blockingValue;
+ invalidateOptionsMenu();
+ }
+ },
+ new Response.ErrorListener() {
+ @Override
+ public void onErrorResponse(VolleyError error) {
+ onBlockFailure(id);
+ }
+ });
+ }
+ private void onBlockFailure(final String id) {
+ int messageId;
+ if (blocking) {
+ messageId = R.string.error_unblocking;
+ } else {
+ messageId = R.string.error_blocking;
+ }
+ View.OnClickListener listener = new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ block(id);
+ }
+ };
+ Snackbar.make(findViewById(R.id.activity_account), messageId, Snackbar.LENGTH_LONG)
+ .setAction(R.string.action_retry, listener)
+ .show();
}
@Override
@@ -253,12 +399,18 @@ public class AccountActivity extends AppCompatActivity {
startActivity(intent);
return true;
}
+ case R.id.action_open_in_web: {
+ Uri uri = Uri.parse(openInWebUrl);
+ Intent intent = new Intent(Intent.ACTION_VIEW, uri);
+ startActivity(intent);
+ return true;
+ }
case R.id.action_follow: {
- follow();
+ follow(accountId);
return true;
}
case R.id.action_block: {
- block();
+ block(accountId);
return true;
}
}
diff --git a/app/src/main/java/com/keylesspalace/tusky/AccountAdapter.java b/app/src/main/java/com/keylesspalace/tusky/AccountAdapter.java
new file mode 100644
index 00000000..efc0a02b
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/AccountAdapter.java
@@ -0,0 +1,158 @@
+/* Copyright 2017 Andrew Dawson
+ *
+ * This file is part of Tusky.
+ *
+ * Tusky is free software: you can redistribute it and/or modify it under the terms of the GNU
+ * General Public License as published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
+ * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
+ * Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with Tusky. If not, see
+ * . */
+
+package com.keylesspalace.tusky;
+
+import android.content.Context;
+import android.support.v7.widget.RecyclerView;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import com.android.volley.toolbox.ImageLoader;
+import com.android.volley.toolbox.NetworkImageView;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class AccountAdapter extends RecyclerView.Adapter {
+ private static final int VIEW_TYPE_ACCOUNT = 0;
+ private static final int VIEW_TYPE_FOOTER = 1;
+
+ private List accounts;
+ private AccountActionListener accountActionListener;
+ private FooterActionListener footerActionListener;
+
+ public AccountAdapter(AccountActionListener accountActionListener,
+ FooterActionListener footerActionListener) {
+ super();
+ accounts = new ArrayList<>();
+ this.accountActionListener = accountActionListener;
+ this.footerActionListener = footerActionListener;
+ }
+
+ @Override
+ public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
+ switch (viewType) {
+ default:
+ case VIEW_TYPE_ACCOUNT: {
+ View view = LayoutInflater.from(parent.getContext())
+ .inflate(R.layout.item_account, parent, false);
+ return new AccountViewHolder(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 < accounts.size()) {
+ AccountViewHolder holder = (AccountViewHolder) viewHolder;
+ holder.setupWithAccount(accounts.get(position));
+ holder.setupActionListener(accountActionListener);
+ } else {
+ FooterViewHolder holder = (FooterViewHolder) viewHolder;
+ holder.setupButton(footerActionListener);
+ holder.setRetryMessage(R.string.footer_retry_accounts);
+ }
+ }
+
+ @Override
+ public int getItemCount() {
+ return accounts.size() + 1;
+ }
+
+ @Override
+ public int getItemViewType(int position) {
+ if (position == accounts.size()) {
+ return VIEW_TYPE_FOOTER;
+ } else {
+ return VIEW_TYPE_ACCOUNT;
+ }
+ }
+
+ public void update(List newAccounts) {
+ if (accounts == null || accounts.isEmpty()) {
+ accounts = newAccounts;
+ } else {
+ int index = newAccounts.indexOf(accounts.get(0));
+ if (index == -1) {
+ accounts.addAll(0, newAccounts);
+ } else {
+ accounts.addAll(0, newAccounts.subList(0, index));
+ }
+ }
+ notifyDataSetChanged();
+ }
+
+ public void addItems(List newAccounts) {
+ int end = accounts.size();
+ accounts.addAll(newAccounts);
+ notifyItemRangeInserted(end, newAccounts.size());
+ }
+
+ public Account getItem(int position) {
+ if (position >= 0 && position < accounts.size()) {
+ return accounts.get(position);
+ }
+ return null;
+ }
+
+ private static class AccountViewHolder extends RecyclerView.ViewHolder {
+ private View container;
+ private TextView username;
+ private TextView displayName;
+ private TextView note;
+ private NetworkImageView avatar;
+ private String id;
+
+ public AccountViewHolder(View itemView) {
+ super(itemView);
+ container = itemView.findViewById(R.id.account_container);
+ username = (TextView) itemView.findViewById(R.id.account_username);
+ displayName = (TextView) itemView.findViewById(R.id.account_display_name);
+ note = (TextView) itemView.findViewById(R.id.account_note);
+ avatar = (NetworkImageView) itemView.findViewById(R.id.account_avatar);
+ avatar.setDefaultImageResId(R.drawable.avatar_default);
+ avatar.setErrorImageResId(R.drawable.avatar_error);
+ }
+
+ public void setupWithAccount(Account account) {
+ id = account.id;
+ String format = username.getContext().getString(R.string.status_username_format);
+ String formattedUsername = String.format(format, account.username);
+ username.setText(formattedUsername);
+ displayName.setText(account.displayName);
+ note.setText(account.note);
+ Context context = avatar.getContext();
+ ImageLoader imageLoader = VolleySingleton.getInstance(context).getImageLoader();
+ avatar.setImageUrl(account.avatar, imageLoader);
+ }
+
+ public void setupActionListener(final AccountActionListener listener) {
+ container.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ listener.onViewAccount(id);
+ }
+ });
+ }
+ }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/AccountFragment.java b/app/src/main/java/com/keylesspalace/tusky/AccountFragment.java
index 2f8cbbb5..99d03642 100644
--- a/app/src/main/java/com/keylesspalace/tusky/AccountFragment.java
+++ b/app/src/main/java/com/keylesspalace/tusky/AccountFragment.java
@@ -15,31 +15,224 @@
package com.keylesspalace.tusky;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.graphics.drawable.Drawable;
import android.os.Bundle;
+import android.provider.Contacts;
import android.support.annotation.Nullable;
+import android.support.design.widget.TabLayout;
import android.support.v4.app.Fragment;
+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;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
-public class AccountFragment extends Fragment {
+import com.android.volley.AuthFailureError;
+import com.android.volley.Response;
+import com.android.volley.VolleyError;
+import com.android.volley.toolbox.JsonArrayRequest;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class AccountFragment extends Fragment implements AccountActionListener,
+ FooterActionListener {
public enum Type {
FOLLOWS,
FOLLOWERS,
}
- public static AccountFragment newInstance(Type type) {
+ private Type type;
+ private String accountId;
+ private String domain;
+ private String accessToken;
+ private RecyclerView recyclerView;
+ private LinearLayoutManager layoutManager;
+ private EndlessOnScrollListener scrollListener;
+ private AccountAdapter adapter;
+ private TabLayout.OnTabSelectedListener onTabSelectedListener;
+
+ public static AccountFragment newInstance(Type type, String accountId) {
Bundle arguments = new Bundle();
AccountFragment fragment = new AccountFragment();
arguments.putString("type", type.name());
+ arguments.putString("accountId", accountId);
fragment.setArguments(arguments);
return fragment;
}
+ @Override
+ public void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ Bundle arguments = getArguments();
+ type = Type.valueOf(arguments.getString("type"));
+ accountId = arguments.getString("accountId");
+
+ SharedPreferences preferences = getContext().getSharedPreferences(
+ getString(R.string.preferences_file_key), Context.MODE_PRIVATE);
+ domain = preferences.getString("domain", null);
+ accessToken = preferences.getString("accessToken", null);
+ }
+
@Nullable
@Override
public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
- return super.onCreateView(inflater, container, savedInstanceState);
+
+ View rootView = inflater.inflate(R.layout.fragment_account, container, false);
+
+ Context context = getContext();
+ recyclerView = (RecyclerView) rootView.findViewById(R.id.recycler_view);
+ recyclerView.setHasFixedSize(true);
+ layoutManager = new LinearLayoutManager(context);
+ recyclerView.setLayoutManager(layoutManager);
+ DividerItemDecoration divider = new DividerItemDecoration(
+ context, layoutManager.getOrientation());
+ Drawable drawable = ContextCompat.getDrawable(context, R.drawable.status_divider);
+ divider.setDrawable(drawable);
+ recyclerView.addItemDecoration(divider);
+ scrollListener = new EndlessOnScrollListener(layoutManager) {
+ @Override
+ public void onLoadMore(int page, int totalItemsCount, RecyclerView view) {
+ AccountAdapter adapter = (AccountAdapter) view.getAdapter();
+ Account account = adapter.getItem(adapter.getItemCount() - 2);
+ if (account != null) {
+ fetchAccounts(account.id);
+ } else {
+ fetchAccounts();
+ }
+ }
+ };
+ recyclerView.addOnScrollListener(scrollListener);
+ adapter = new AccountAdapter(this, this);
+ recyclerView.setAdapter(adapter);
+
+ TabLayout layout = (TabLayout) getActivity().findViewById(R.id.tab_layout);
+ onTabSelectedListener = new TabLayout.OnTabSelectedListener() {
+ @Override
+ public void onTabSelected(TabLayout.Tab tab) {}
+
+ @Override
+ public void onTabUnselected(TabLayout.Tab tab) {}
+
+ @Override
+ public void onTabReselected(TabLayout.Tab tab) {
+ jumpToTop();
+ }
+ };
+ layout.addOnTabSelectedListener(onTabSelectedListener);
+
+ return rootView;
+ }
+
+ @Override
+ public void onDestroyView() {
+ TabLayout tabLayout = (TabLayout) getActivity().findViewById(R.id.tab_layout);
+ tabLayout.removeOnTabSelectedListener(onTabSelectedListener);
+ super.onDestroyView();
+ }
+
+ private void fetchAccounts(final String fromId) {
+ int endpointId;
+ switch (type) {
+ default:
+ case FOLLOWS: {
+ endpointId = R.string.endpoint_following;
+ break;
+ }
+ case FOLLOWERS: {
+ endpointId = R.string.endpoint_followers;
+ break;
+ }
+ }
+ String endpoint = String.format(getString(endpointId), accountId);
+ String url = "https://" + domain + endpoint;
+ if (fromId != null) {
+ url += "?max_id=" + fromId;
+ }
+ JsonArrayRequest request = new JsonArrayRequest(url,
+ new Response.Listener() {
+ @Override
+ public void onResponse(JSONArray response) {
+ List accounts;
+ try {
+ accounts = Account.parse(response);
+ } catch (JSONException e) {
+ onFetchAccountsFailure();
+ return;
+ }
+ onFetchAccountsSuccess(accounts, fromId != null);
+ }
+ },
+ new Response.ErrorListener() {
+ @Override
+ public void onErrorResponse(VolleyError error) {
+ onFetchAccountsFailure();
+ }
+ }) {
+ @Override
+ public Map getHeaders() throws AuthFailureError {
+ Map headers = new HashMap<>();
+ headers.put("Authorization", "Bearer " + accessToken);
+ return headers;
+ }
+ };
+ VolleySingleton.getInstance(getContext()).addToRequestQueue(request);
+ }
+
+ private void fetchAccounts() {
+ fetchAccounts(null);
+ }
+
+ private void onFetchAccountsSuccess(List accounts, boolean added) {
+ if (added) {
+ adapter.addItems(accounts);
+ } else {
+ adapter.update(accounts);
+ }
+ showFetchAccountsRetry(false);
+ }
+
+ private void onFetchAccountsFailure() {
+ showFetchAccountsRetry(true);
+ }
+
+ private void showFetchAccountsRetry(boolean show) {
+ RecyclerView.ViewHolder viewHolder =
+ recyclerView.findViewHolderForAdapterPosition(adapter.getItemCount() - 1);
+ if (viewHolder != null) {
+ FooterViewHolder holder = (FooterViewHolder) viewHolder;
+ holder.showRetry(show);
+ }
+ }
+
+ public void onLoadMore() {
+ Account account = adapter.getItem(adapter.getItemCount() - 2);
+ if (account != null) {
+ fetchAccounts(account.id);
+ } else {
+ fetchAccounts();
+ }
+ }
+
+ public void onViewAccount(String id) {
+ Intent intent = new Intent(getContext(), AccountActivity.class);
+ intent.putExtra("id", id);
+ startActivity(intent);
+ }
+
+ private void jumpToTop() {
+ layoutManager.scrollToPositionWithOffset(0, 0);
+ scrollListener.reset();
}
}
diff --git a/app/src/main/java/com/keylesspalace/tusky/AccountPagerAdapter.java b/app/src/main/java/com/keylesspalace/tusky/AccountPagerAdapter.java
index 0ec0f1b7..fade04ec 100644
--- a/app/src/main/java/com/keylesspalace/tusky/AccountPagerAdapter.java
+++ b/app/src/main/java/com/keylesspalace/tusky/AccountPagerAdapter.java
@@ -15,16 +15,22 @@
package com.keylesspalace.tusky;
+import android.content.Context;
import android.support.v4.app.Fragment;
import android.support.v4.app.FragmentManager;
import android.support.v4.app.FragmentPagerAdapter;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.TextView;
public class AccountPagerAdapter extends FragmentPagerAdapter {
+ private Context context;
private String accountId;
private String[] pageTitles;
- public AccountPagerAdapter(FragmentManager manager, String accountId) {
+ public AccountPagerAdapter(FragmentManager manager, Context context, String accountId) {
super(manager);
+ this.context = context;
this.accountId = accountId;
}
@@ -39,10 +45,10 @@ public class AccountPagerAdapter extends FragmentPagerAdapter {
return TimelineFragment.newInstance(TimelineFragment.Kind.USER, accountId);
}
case 1: {
- return AccountFragment.newInstance(AccountFragment.Type.FOLLOWS);
+ return AccountFragment.newInstance(AccountFragment.Type.FOLLOWS, accountId);
}
case 2: {
- return AccountFragment.newInstance(AccountFragment.Type.FOLLOWERS);
+ return AccountFragment.newInstance(AccountFragment.Type.FOLLOWERS, accountId);
}
default: {
return null;
@@ -59,4 +65,11 @@ public class AccountPagerAdapter extends FragmentPagerAdapter {
public CharSequence getPageTitle(int position) {
return pageTitles[position];
}
+
+ public View getTabView(int position) {
+ View view = LayoutInflater.from(context).inflate(R.layout.tab_account, null);
+ TextView title = (TextView) view.findViewById(R.id.title);
+ title.setText(pageTitles[position]);
+ return view;
+ }
}
diff --git a/app/src/main/java/com/keylesspalace/tusky/FooterViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/FooterViewHolder.java
index 989e5ffa..cb68cb95 100644
--- a/app/src/main/java/com/keylesspalace/tusky/FooterViewHolder.java
+++ b/app/src/main/java/com/keylesspalace/tusky/FooterViewHolder.java
@@ -20,15 +20,18 @@ import android.view.View;
import android.widget.Button;
import android.widget.LinearLayout;
import android.widget.ProgressBar;
+import android.widget.TextView;
public class FooterViewHolder extends RecyclerView.ViewHolder {
private LinearLayout retryBar;
+ private TextView retryMessage;
private Button retry;
private ProgressBar progressBar;
public FooterViewHolder(View itemView) {
super(itemView);
retryBar = (LinearLayout) itemView.findViewById(R.id.footer_retry_bar);
+ retryMessage = (TextView) itemView.findViewById(R.id.footer_retry_message);
retry = (Button) itemView.findViewById(R.id.footer_retry_button);
progressBar = (ProgressBar) itemView.findViewById(R.id.footer_progress_bar);
progressBar.setIndeterminate(true);
@@ -43,6 +46,10 @@ public class FooterViewHolder extends RecyclerView.ViewHolder {
});
}
+ public void setRetryMessage(int messageId) {
+ retryMessage.setText(messageId);
+ }
+
public void showRetry(boolean show) {
if (!show) {
retryBar.setVisibility(View.GONE);
diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.java b/app/src/main/java/com/keylesspalace/tusky/MainActivity.java
index 83167d0c..a2a7216e 100644
--- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.java
+++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.java
@@ -26,6 +26,7 @@ import android.support.v4.view.ViewPager;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.support.v7.widget.Toolbar;
+import android.util.Log;
import android.view.Menu;
import android.view.MenuItem;
@@ -42,6 +43,8 @@ import java.util.HashMap;
import java.util.Map;
public class MainActivity extends AppCompatActivity {
+ private static final String TAG = "MainActivity"; // logging tag
+
private AlarmManager alarmManager;
private PendingIntent serviceAlarmIntent;
private boolean notificationServiceEnabled;
@@ -75,12 +78,12 @@ public class MainActivity extends AppCompatActivity {
// Retrieve notification update preference.
SharedPreferences preferences = getSharedPreferences(
getString(R.string.preferences_file_key), Context.MODE_PRIVATE);
- notificationServiceEnabled = preferences.getBoolean("notificationService", true);
+ notificationServiceEnabled = preferences.getBoolean("notificationService", false);
long notificationCheckInterval =
preferences.getLong("notificationCheckInterval", 5 * 60 * 1000);
- // Start up the NotificationsService.
+ // Start up the PullNotificationsService.
alarmManager = (AlarmManager) getSystemService(Context.ALARM_SERVICE);
- Intent intent = new Intent(this, NotificationService.class);
+ Intent intent = new Intent(this, PullNotificationService.class);
final int SERVICE_REQUEST_CODE = 8574603; // This number is arbitrary.
serviceAlarmIntent = PendingIntent.getService(this, SERVICE_REQUEST_CODE, intent,
PendingIntent.FLAG_UPDATE_CURRENT);
@@ -90,6 +93,20 @@ public class MainActivity extends AppCompatActivity {
} else {
alarmManager.cancel(serviceAlarmIntent);
}
+
+ /* @Unused: for Firebase Push Notifications
+ Log.d(TAG, "token " + FirebaseInstanceId.getInstance().getToken());
+
+ // Check if it's necessary to register for push notifications for this instance.
+ boolean registered = preferences.getBoolean("firebaseRegistered", false);
+ if (!registered) {
+ String registrationId = preferences.getString("firebaseRegistrationId", null);
+ if (registrationId == null) {
+ registrationId = FirebaseInstanceId.getInstance().getToken();
+ }
+ sendRegistrationToServer(registrationId, true);
+ }
+ */
}
private void fetchUserInfo() {
@@ -109,14 +126,16 @@ public class MainActivity extends AppCompatActivity {
new Response.Listener() {
@Override
public void onResponse(JSONObject response) {
+ String username;
+ String id;
try {
- String id = response.getString("id");
- String username = response.getString("acct");
- onFetchUserInfoSuccess(id, username);
+ id = response.getString("id");
+ username = response.getString("acct");
} catch (JSONException e) {
- //TODO: Help
- assert (false);
+ onFetchUserInfoFailure();
+ return;
}
+ onFetchUserInfoSuccess(id, username);
}
},
new Response.ErrorListener() {
@@ -149,8 +168,73 @@ public class MainActivity extends AppCompatActivity {
private void onFetchUserInfoFailure() {
//TODO: help
- assert(false);
+ Log.e(TAG, "Failed to fetch the logged-in user's info.");
+ }
+
+ /* @Unused: For Firebase push notifications, useless for now.
+ private void sendRegistrationToServer(String token, final boolean register) {
+ SharedPreferences preferences = getSharedPreferences(
+ getString(R.string.preferences_file_key), Context.MODE_PRIVATE);
+ String domain = preferences.getString("domain", null);
+ final String accessToken = preferences.getString("accessToken", null);
+
+ String endpoint;
+ if (register) {
+ endpoint = getString(R.string.endpoint_devices_register);
+ } else {
+ endpoint = getString(R.string.endpoint_devices_unregister);
+ }
+ String url = "https://" + domain + endpoint;
+ JSONObject formData = new JSONObject();
+ try {
+ formData.put("registration_id", token);
+ } catch (JSONException e) {
+ onSendRegistrationToServerFailure();
+ return;
+ }
+ JsonObjectRequest request = new JsonObjectRequest(Request.Method.POST, url, formData,
+ new Response.Listener() {
+ @Override
+ public void onResponse(JSONObject response) {
+ onSendRegistrationToServerSuccess(response, register);
+ }
+ },
+ new Response.ErrorListener() {
+ @Override
+ public void onErrorResponse(VolleyError error) {
+ onSendRegistrationToServerFailure();
+ }
+ }) {
+ @Override
+ public Map getHeaders() throws AuthFailureError {
+ Map headers = new HashMap<>();
+ headers.put("Authorization", "Bearer " + accessToken);
+ return headers;
+ }
+ };
+ VolleySingleton.getInstance(this).addToRequestQueue(request);
+ }
+
+ private void onSendRegistrationToServerSuccess(JSONObject response, boolean register) {
+ String registeredWord;
+ if (register) {
+ registeredWord = "registration";
+ } else {
+ registeredWord = "unregistration";
+ }
+ Log.d(TAG, String.format("Firebase %s is confirmed with the Mastodon instance. %s",
+ registeredWord, response.toString()));
+ SharedPreferences preferences = getSharedPreferences(
+ getString(R.string.preferences_file_key), Context.MODE_PRIVATE);
+ SharedPreferences.Editor editor = preferences.edit();
+ editor.putBoolean("firebaseRegistered", register);
+ editor.apply();
+ }
+
+ private void onSendRegistrationToServerFailure() {
+ Log.d(TAG, "Firebase registration with the Mastodon instance failed");
}
+ */
private void compose() {
Intent intent = new Intent(this, ComposeActivity.class);
@@ -160,7 +244,6 @@ public class MainActivity extends AppCompatActivity {
private void viewProfile() {
Intent intent = new Intent(this, AccountActivity.class);
intent.putExtra("id", loggedInAccountId);
- intent.putExtra("username", loggedInAccountUsername);
startActivity(intent);
}
diff --git a/app/src/main/java/com/keylesspalace/tusky/NotificationsAdapter.java b/app/src/main/java/com/keylesspalace/tusky/NotificationsAdapter.java
index b776fd0f..871cde80 100644
--- a/app/src/main/java/com/keylesspalace/tusky/NotificationsAdapter.java
+++ b/app/src/main/java/com/keylesspalace/tusky/NotificationsAdapter.java
@@ -103,6 +103,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter implements Adapte
} else {
FooterViewHolder holder = (FooterViewHolder) viewHolder;
holder.setupButton(footerListener);
+ holder.setRetryMessage(R.string.footer_retry_notifications);
}
}
diff --git a/app/src/main/java/com/keylesspalace/tusky/NotificationService.java b/app/src/main/java/com/keylesspalace/tusky/PullNotificationService.java
similarity index 98%
rename from app/src/main/java/com/keylesspalace/tusky/NotificationService.java
rename to app/src/main/java/com/keylesspalace/tusky/PullNotificationService.java
index 5c6189ac..51f08e74 100644
--- a/app/src/main/java/com/keylesspalace/tusky/NotificationService.java
+++ b/app/src/main/java/com/keylesspalace/tusky/PullNotificationService.java
@@ -42,10 +42,10 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
-public class NotificationService extends IntentService {
+public class PullNotificationService extends IntentService {
private final int NOTIFY_ID = 6; // This is an arbitrary number.
- public NotificationService() {
+ public PullNotificationService() {
super("Tusky Notification Service");
}
diff --git a/app/src/main/java/com/keylesspalace/tusky/SFragment.java b/app/src/main/java/com/keylesspalace/tusky/SFragment.java
index e41ec81a..bd187e25 100644
--- a/app/src/main/java/com/keylesspalace/tusky/SFragment.java
+++ b/app/src/main/java/com/keylesspalace/tusky/SFragment.java
@@ -24,6 +24,7 @@ import android.support.v4.app.Fragment;
import android.support.v4.app.FragmentManager;
import android.support.v7.widget.PopupMenu;
import android.support.v7.widget.RecyclerView;
+import android.util.Log;
import android.view.MenuItem;
import android.view.View;
@@ -47,6 +48,8 @@ import java.util.Map;
* 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 Fragment {
+ private static final String TAG = "SFragment"; // logging tag
+
protected String domain;
protected String accessToken;
protected String loggedInAccountId;
@@ -62,10 +65,6 @@ public class SFragment extends Fragment {
accessToken = preferences.getString("accessToken", null);
loggedInAccountId = preferences.getString("loggedInAccountId", null);
loggedInUsername = preferences.getString("loggedInAccountUsername", null);
- assert(domain != null);
- assert(accessToken != null);
- assert(loggedInAccountId != null);
- assert(loggedInUsername != null);
}
protected void sendRequest(
@@ -84,7 +83,7 @@ public class SFragment extends Fragment {
new Response.ErrorListener() {
@Override
public void onErrorResponse(VolleyError error) {
- System.err.println(error.getMessage());
+ Log.e(TAG, error.getMessage());
}
}) {
@Override
@@ -105,8 +104,8 @@ public class SFragment extends Fragment {
String inReplyToId = status.getId();
Status.Mention[] mentions = status.getMentions();
List mentionedUsernames = new ArrayList<>();
- for (int i = 0; i < mentions.length; i++) {
- mentionedUsernames.add(mentions[i].getUsername());
+ for (Status.Mention mention : mentions) {
+ mentionedUsernames.add(mention.getUsername());
}
mentionedUsernames.add(status.getUsername());
mentionedUsernames.remove(loggedInUsername);
diff --git a/app/src/main/java/com/keylesspalace/tusky/Status.java b/app/src/main/java/com/keylesspalace/tusky/Status.java
index 227787ee..b81e99da 100644
--- a/app/src/main/java/com/keylesspalace/tusky/Status.java
+++ b/app/src/main/java/com/keylesspalace/tusky/Status.java
@@ -198,7 +198,13 @@ public class Status {
displayName = account.getString("username");
}
String username = account.getString("acct");
- String avatar = account.getString("avatar");
+ String avatarUrl = account.getString("avatar");
+ String avatar;
+ if (!avatarUrl.equals("/avatars/original/missing.png")) {
+ avatar = avatarUrl;
+ } else {
+ avatar = "";
+ }
JSONArray mentionsArray = object.getJSONArray("mentions");
Mention[] mentions = null;
diff --git a/app/src/main/java/com/keylesspalace/tusky/StatusViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/StatusViewHolder.java
index 1f891242..8c273263 100644
--- a/app/src/main/java/com/keylesspalace/tusky/StatusViewHolder.java
+++ b/app/src/main/java/com/keylesspalace/tusky/StatusViewHolder.java
@@ -68,6 +68,8 @@ public class StatusViewHolder extends RecyclerView.ViewHolder {
sinceCreated = (TextView) itemView.findViewById(R.id.status_since_created);
content = (TextView) itemView.findViewById(R.id.status_content);
avatar = (NetworkImageView) itemView.findViewById(R.id.status_avatar);
+ avatar.setDefaultImageResId(R.drawable.avatar_default);
+ avatar.setErrorImageResId(R.drawable.avatar_error);
boostedIcon = (ImageView) itemView.findViewById(R.id.status_boosted_icon);
boostedByUsername = (TextView) itemView.findViewById(R.id.status_boosted);
replyButton = (ImageButton) itemView.findViewById(R.id.status_reply);
@@ -147,11 +149,12 @@ public class StatusViewHolder extends RecyclerView.ViewHolder {
}
public void setAvatar(String url) {
+ if (url.isEmpty()) {
+ return;
+ }
Context context = avatar.getContext();
ImageLoader imageLoader = VolleySingleton.getInstance(context).getImageLoader();
avatar.setImageUrl(url, imageLoader);
- avatar.setDefaultImageResId(R.drawable.avatar_default);
- avatar.setErrorImageResId(R.drawable.avatar_error);
}
public void setCreatedAt(@Nullable Date createdAt) {
diff --git a/app/src/main/java/com/keylesspalace/tusky/TimelineAdapter.java b/app/src/main/java/com/keylesspalace/tusky/TimelineAdapter.java
index a8323af9..96295db0 100644
--- a/app/src/main/java/com/keylesspalace/tusky/TimelineAdapter.java
+++ b/app/src/main/java/com/keylesspalace/tusky/TimelineAdapter.java
@@ -66,6 +66,7 @@ public class TimelineAdapter extends RecyclerView.Adapter implements AdapterItem
} else {
FooterViewHolder holder = (FooterViewHolder) viewHolder;
holder.setupButton(footerListener);
+ holder.setRetryMessage(R.string.footer_retry_statuses);
}
}
diff --git a/app/src/main/java/com/keylesspalace/tusky/TimelineFragment.java b/app/src/main/java/com/keylesspalace/tusky/TimelineFragment.java
index f6c375e4..c799efc6 100644
--- a/app/src/main/java/com/keylesspalace/tusky/TimelineFragment.java
+++ b/app/src/main/java/com/keylesspalace/tusky/TimelineFragment.java
@@ -123,12 +123,10 @@ public class TimelineFragment extends SFragment implements
TabLayout layout = (TabLayout) getActivity().findViewById(R.id.tab_layout);
onTabSelectedListener = new TabLayout.OnTabSelectedListener() {
@Override
- public void onTabSelected(TabLayout.Tab tab) {
- }
+ public void onTabSelected(TabLayout.Tab tab) {}
@Override
- public void onTabUnselected(TabLayout.Tab tab) {
- }
+ public void onTabUnselected(TabLayout.Tab tab) {}
@Override
public void onTabReselected(TabLayout.Tab tab) {
diff --git a/app/src/main/res/layout/activity_account.xml b/app/src/main/res/layout/activity_account.xml
index 5c19d7c5..5169d596 100644
--- a/app/src/main/res/layout/activity_account.xml
+++ b/app/src/main/res/layout/activity_account.xml
@@ -5,6 +5,7 @@
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent"
+ android:id="@+id/activity_account"
android:fitsSystemWindows="true">
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/item_account.xml b/app/src/main/res/layout/item_account.xml
new file mode 100644
index 00000000..64e99b01
--- /dev/null
+++ b/app/src/main/res/layout/item_account.xml
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/item_footer.xml b/app/src/main/res/layout/item_footer.xml
index 5c517303..c07c8e96 100644
--- a/app/src/main/res/layout/item_footer.xml
+++ b/app/src/main/res/layout/item_footer.xml
@@ -20,7 +20,7 @@