From 1429dfc7b5fa479a7aa52558a2d219d4de575d18 Mon Sep 17 00:00:00 2001 From: Vavassor Date: Mon, 30 Jan 2017 23:51:02 -0500 Subject: [PATCH] Account profiles no longer partial! --- app/build.gradle | 2 +- app/src/main/AndroidManifest.xml | 2 +- .../java/com/keylesspalace/tusky/Account.java | 76 ++++++ .../tusky/AccountActionListener.java | 20 ++ .../keylesspalace/tusky/AccountActivity.java | 250 ++++++++++++++---- .../keylesspalace/tusky/AccountAdapter.java | 158 +++++++++++ .../keylesspalace/tusky/AccountFragment.java | 199 +++++++++++++- .../tusky/AccountPagerAdapter.java | 19 +- .../keylesspalace/tusky/FooterViewHolder.java | 7 + .../com/keylesspalace/tusky/MainActivity.java | 103 +++++++- .../tusky/NotificationsAdapter.java | 1 + ...vice.java => PullNotificationService.java} | 4 +- .../com/keylesspalace/tusky/SFragment.java | 13 +- .../java/com/keylesspalace/tusky/Status.java | 8 +- .../keylesspalace/tusky/StatusViewHolder.java | 7 +- .../keylesspalace/tusky/TimelineAdapter.java | 1 + .../keylesspalace/tusky/TimelineFragment.java | 6 +- app/src/main/res/layout/activity_account.xml | 1 + app/src/main/res/layout/fragment_account.xml | 6 + app/src/main/res/layout/item_account.xml | 48 ++++ app/src/main/res/layout/item_footer.xml | 2 +- app/src/main/res/layout/tab_account.xml | 30 +++ app/src/main/res/menu/account_toolbar.xml | 4 + app/src/main/res/values/dimens.xml | 2 + app/src/main/res/values/strings.xml | 12 +- 25 files changed, 895 insertions(+), 86 deletions(-) create mode 100644 app/src/main/java/com/keylesspalace/tusky/Account.java create mode 100644 app/src/main/java/com/keylesspalace/tusky/AccountActionListener.java create mode 100644 app/src/main/java/com/keylesspalace/tusky/AccountAdapter.java rename app/src/main/java/com/keylesspalace/tusky/{NotificationService.java => PullNotificationService.java} (98%) create mode 100644 app/src/main/res/layout/fragment_account.xml create mode 100644 app/src/main/res/layout/item_account.xml create mode 100644 app/src/main/res/layout/tab_account.xml 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 @@