initial commit
This commit is contained in:
commit
bba1b37fd8
34 changed files with 1403 additions and 0 deletions
|
@ -0,0 +1,47 @@
|
|||
package com.keylesspalace.tusky;
|
||||
|
||||
import android.support.v7.widget.LinearLayoutManager;
|
||||
import android.support.v7.widget.RecyclerView;
|
||||
|
||||
public abstract class EndlessOnScrollListener extends RecyclerView.OnScrollListener {
|
||||
private int visibleThreshold = 15;
|
||||
private int currentPage = 0;
|
||||
private int previousTotalItemCount = 0;
|
||||
private boolean loading = true;
|
||||
private int startingPageIndex = 0;
|
||||
private LinearLayoutManager layoutManager;
|
||||
|
||||
public EndlessOnScrollListener(LinearLayoutManager layoutManager) {
|
||||
this.layoutManager = layoutManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onScrolled(RecyclerView view, int dx, int dy) {
|
||||
int totalItemCount = layoutManager.getItemCount();
|
||||
int lastVisibleItemPosition = layoutManager.findLastVisibleItemPosition();
|
||||
if (totalItemCount < previousTotalItemCount) {
|
||||
currentPage = startingPageIndex;
|
||||
previousTotalItemCount = totalItemCount;
|
||||
if (totalItemCount == 0) {
|
||||
loading = true;
|
||||
}
|
||||
}
|
||||
if (loading && totalItemCount > previousTotalItemCount) {
|
||||
loading = false;
|
||||
previousTotalItemCount = totalItemCount;
|
||||
}
|
||||
if (!loading && lastVisibleItemPosition + visibleThreshold > totalItemCount) {
|
||||
currentPage++;
|
||||
onLoadMore(currentPage, totalItemCount, view);
|
||||
loading = true;
|
||||
}
|
||||
}
|
||||
|
||||
public void reset() {
|
||||
currentPage = startingPageIndex;
|
||||
previousTotalItemCount = 0;
|
||||
loading = true;
|
||||
}
|
||||
|
||||
public abstract void onLoadMore(int page, int totalItemsCount, RecyclerView view);
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package com.keylesspalace.tusky;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
public interface FetchTimelineListener {
|
||||
void onFetchTimelineSuccess(List<Status> statuses, boolean added);
|
||||
void onFetchTimelineFailure(IOException e);
|
||||
}
|
235
app/src/main/java/com/keylesspalace/tusky/FetchTimelineTask.java
Normal file
235
app/src/main/java/com/keylesspalace/tusky/FetchTimelineTask.java
Normal file
|
@ -0,0 +1,235 @@
|
|||
package com.keylesspalace.tusky;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.BitmapFactory;
|
||||
import android.os.AsyncTask;
|
||||
import android.os.Build;
|
||||
import android.text.Html;
|
||||
import android.text.Spanned;
|
||||
import android.util.JsonReader;
|
||||
import android.util.JsonToken;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.net.URL;
|
||||
import java.net.URLEncoder;
|
||||
import java.text.ParseException;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import javax.net.ssl.HttpsURLConnection;
|
||||
|
||||
public class FetchTimelineTask extends AsyncTask<String, Void, Boolean> {
|
||||
private Context context;
|
||||
private FetchTimelineListener fetchTimelineListener;
|
||||
private String domain;
|
||||
private String accessToken;
|
||||
private String fromId;
|
||||
private List<com.keylesspalace.tusky.Status> statuses;
|
||||
private IOException ioException;
|
||||
|
||||
public FetchTimelineTask(
|
||||
Context context, FetchTimelineListener listener, String domain, String accessToken,
|
||||
String fromId) {
|
||||
super();
|
||||
this.context = context;
|
||||
fetchTimelineListener = listener;
|
||||
this.domain = domain;
|
||||
this.accessToken = accessToken;
|
||||
this.fromId = fromId;
|
||||
}
|
||||
|
||||
private Date parseDate(String dateTime) {
|
||||
Date date;
|
||||
String s = dateTime.replace("Z", "+00:00");
|
||||
SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ");
|
||||
try {
|
||||
date = format.parse(s);
|
||||
} catch (ParseException e) {
|
||||
e.printStackTrace();
|
||||
return null;
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
private CharSequence trimTrailingWhitespace(CharSequence s) {
|
||||
int i = s.length();
|
||||
do {
|
||||
i--;
|
||||
} while (i >= 0 && Character.isWhitespace(s.charAt(i)));
|
||||
return s.subSequence(0, i + 1);
|
||||
}
|
||||
|
||||
private Spanned compatFromHtml(String html) {
|
||||
Spanned result;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
result = Html.fromHtml(html, Html.FROM_HTML_MODE_LEGACY);
|
||||
} else {
|
||||
result = Html.fromHtml(html);
|
||||
}
|
||||
/* Html.fromHtml returns trailing whitespace if the html ends in a </p> tag, which
|
||||
* all status contents do, so it should be trimmed. */
|
||||
return (Spanned) trimTrailingWhitespace(result);
|
||||
}
|
||||
|
||||
private com.keylesspalace.tusky.Status readStatus(JsonReader reader, boolean isReblog)
|
||||
throws IOException {
|
||||
JsonToken check = reader.peek();
|
||||
if (check == JsonToken.NULL) {
|
||||
reader.skipValue();
|
||||
return null;
|
||||
}
|
||||
String id = null;
|
||||
String displayName = null;
|
||||
String username = null;
|
||||
com.keylesspalace.tusky.Status reblog = null;
|
||||
String content = null;
|
||||
String avatar = null;
|
||||
Date createdAt = null;
|
||||
reader.beginObject();
|
||||
while (reader.hasNext()) {
|
||||
String name = reader.nextName();
|
||||
switch (name) {
|
||||
case "id": {
|
||||
id = reader.nextString();
|
||||
break;
|
||||
}
|
||||
case "account": {
|
||||
reader.beginObject();
|
||||
while (reader.hasNext()) {
|
||||
name = reader.nextName();
|
||||
switch (name) {
|
||||
case "acct": {
|
||||
username = reader.nextString();
|
||||
break;
|
||||
}
|
||||
case "display_name": {
|
||||
displayName = reader.nextString();
|
||||
break;
|
||||
}
|
||||
case "avatar": {
|
||||
avatar = reader.nextString();
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
reader.skipValue();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
reader.endObject();
|
||||
break;
|
||||
}
|
||||
case "reblog": {
|
||||
/* This case shouldn't be hit after the first recursion at all. But if this
|
||||
* method is passed unusual data this check will prevent extra recursion */
|
||||
if (!isReblog) {
|
||||
assert(false);
|
||||
reblog = readStatus(reader, true);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "content": {
|
||||
content = reader.nextString();
|
||||
break;
|
||||
}
|
||||
case "created_at": {
|
||||
createdAt = parseDate(reader.nextString());
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
reader.skipValue();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
reader.endObject();
|
||||
assert(username != null);
|
||||
com.keylesspalace.tusky.Status status;
|
||||
if (reblog != null) {
|
||||
status = reblog;
|
||||
status.setRebloggedByUsername(username);
|
||||
} else {
|
||||
assert(content != null);
|
||||
Spanned contentPlus = compatFromHtml(content);
|
||||
status = new com.keylesspalace.tusky.Status(
|
||||
id, displayName, username, contentPlus, avatar, createdAt);
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
private String parametersToQuery(Map<String, String> parameters)
|
||||
throws UnsupportedEncodingException {
|
||||
StringBuilder s = new StringBuilder();
|
||||
String between = "";
|
||||
for (Map.Entry<String, String> entry : parameters.entrySet()) {
|
||||
s.append(between);
|
||||
s.append(URLEncoder.encode(entry.getKey(), "UTF-8"));
|
||||
s.append("=");
|
||||
s.append(URLEncoder.encode(entry.getValue(), "UTF-8"));
|
||||
between = "&";
|
||||
}
|
||||
String urlParameters = s.toString();
|
||||
return "?" + urlParameters;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Boolean doInBackground(String... data) {
|
||||
Boolean successful = true;
|
||||
HttpsURLConnection connection = null;
|
||||
try {
|
||||
String endpoint = context.getString(R.string.endpoint_timelines_home);
|
||||
String query = "";
|
||||
if (fromId != null) {
|
||||
Map<String, String> parameters = new HashMap<>();
|
||||
if (fromId != null) {
|
||||
parameters.put("max_id", fromId);
|
||||
}
|
||||
query = parametersToQuery(parameters);
|
||||
}
|
||||
URL url = new URL("https://" + domain + endpoint + query);
|
||||
connection = (HttpsURLConnection) url.openConnection();
|
||||
connection.setRequestMethod("GET");
|
||||
connection.setRequestProperty("Authorization", "Bearer " + accessToken);
|
||||
connection.connect();
|
||||
|
||||
statuses = new ArrayList<>(20);
|
||||
JsonReader reader = new JsonReader(
|
||||
new InputStreamReader(connection.getInputStream(), "UTF-8"));
|
||||
reader.beginArray();
|
||||
while (reader.hasNext()) {
|
||||
statuses.add(readStatus(reader, false));
|
||||
}
|
||||
reader.endArray();
|
||||
reader.close();
|
||||
} catch (IOException e) {
|
||||
ioException = e;
|
||||
successful = false;
|
||||
} finally {
|
||||
if (connection != null) {
|
||||
connection.disconnect();
|
||||
}
|
||||
}
|
||||
return successful;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPostExecute(Boolean wasSuccessful) {
|
||||
super.onPostExecute(wasSuccessful);
|
||||
if (fetchTimelineListener != null) {
|
||||
if (wasSuccessful) {
|
||||
fetchTimelineListener.onFetchTimelineSuccess(statuses, fromId != null);
|
||||
} else {
|
||||
assert(ioException != null);
|
||||
fetchTimelineListener.onFetchTimelineFailure(ioException);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
250
app/src/main/java/com/keylesspalace/tusky/LoginActivity.java
Normal file
250
app/src/main/java/com/keylesspalace/tusky/LoginActivity.java
Normal file
|
@ -0,0 +1,250 @@
|
|||
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.v7.app.AppCompatActivity;
|
||||
import android.support.v7.widget.Toolbar;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.widget.Button;
|
||||
import android.widget.EditText;
|
||||
import android.widget.TextView;
|
||||
|
||||
import com.android.volley.Request;
|
||||
import com.android.volley.Response;
|
||||
import com.android.volley.VolleyError;
|
||||
import com.android.volley.toolbox.JsonObjectRequest;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.net.URLEncoder;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class LoginActivity extends AppCompatActivity {
|
||||
private SharedPreferences preferences;
|
||||
private String domain;
|
||||
private String clientId;
|
||||
private String clientSecret;
|
||||
|
||||
/**
|
||||
* Chain together the key-value pairs into a query string, for either appending to a URL or
|
||||
* as the content of an HTTP request.
|
||||
*/
|
||||
private String toQueryString(Map<String, String> parameters)
|
||||
throws UnsupportedEncodingException {
|
||||
StringBuilder s = new StringBuilder();
|
||||
String between = "";
|
||||
for (Map.Entry<String, String> entry : parameters.entrySet()) {
|
||||
s.append(between);
|
||||
s.append(URLEncoder.encode(entry.getKey(), "UTF-8"));
|
||||
s.append("=");
|
||||
s.append(URLEncoder.encode(entry.getValue(), "UTF-8"));
|
||||
between = "&";
|
||||
}
|
||||
return s.toString();
|
||||
}
|
||||
|
||||
/** Make sure the user-entered text is just a fully-qualified domain name. */
|
||||
private String validateDomain(String s) {
|
||||
s = s.replaceFirst("http://", "");
|
||||
s = s.replaceFirst("https://", "");
|
||||
return s;
|
||||
}
|
||||
|
||||
private String getOauthRedirectUri() {
|
||||
String scheme = getString(R.string.oauth_scheme);
|
||||
String host = getString(R.string.oauth_redirect_host);
|
||||
return scheme + "://" + host + "/";
|
||||
}
|
||||
|
||||
private void redirectUserToAuthorizeAndLogin() {
|
||||
/* To authorize this app and log in it's necessary to redirect to the domain given,
|
||||
* activity_login there, and the server will redirect back to the app with its response. */
|
||||
String endpoint = getString(R.string.endpoint_authorize);
|
||||
String redirectUri = getOauthRedirectUri();
|
||||
Map<String, String> parameters = new HashMap<>();
|
||||
parameters.put("client_id", clientId);
|
||||
parameters.put("redirect_uri", redirectUri);
|
||||
parameters.put("response_type", "code");
|
||||
String queryParameters;
|
||||
try {
|
||||
queryParameters = toQueryString(parameters);
|
||||
} catch (UnsupportedEncodingException e) {
|
||||
//TODO: No clue how to handle this error case??
|
||||
assert(false);
|
||||
return;
|
||||
}
|
||||
String url = "https://" + domain + endpoint + "?" + queryParameters;
|
||||
Intent viewIntent = new Intent("android.intent.action.VIEW", Uri.parse(url));
|
||||
startActivity(viewIntent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtain the oauth client credentials for this app. This is only necessary the first time the
|
||||
* app is run on a given server instance. So, after the first authentication, they are
|
||||
* saved in SharedPreferences and every subsequent run they are simply fetched from there.
|
||||
*/
|
||||
private void onButtonClick(final EditText editText) {
|
||||
domain = validateDomain(editText.getText().toString());
|
||||
assert(domain != null);
|
||||
/* Attempt to get client credentials from SharedPreferences, and if not present
|
||||
* (such as in the case that the domain has never been accessed before)
|
||||
* authenticate with the server and store the received credentials to use next
|
||||
* time. */
|
||||
clientId = preferences.getString(domain + "/client_id", null);
|
||||
clientSecret = preferences.getString(domain + "/client_secret", null);
|
||||
if (clientId != null && clientSecret != null) {
|
||||
redirectUserToAuthorizeAndLogin();
|
||||
} else {
|
||||
String endpoint = getString(R.string.endpoint_apps);
|
||||
String url = "https://" + domain + endpoint;
|
||||
JSONObject parameters = new JSONObject();
|
||||
try {
|
||||
parameters.put("client_name", getString(R.string.app_name));
|
||||
parameters.put("redirect_uris", getOauthRedirectUri());
|
||||
parameters.put("scopes", "read write follow");
|
||||
} catch (JSONException e) {
|
||||
//TODO: error text????
|
||||
return;
|
||||
}
|
||||
JsonObjectRequest request = new JsonObjectRequest(
|
||||
Request.Method.POST, url, parameters,
|
||||
new Response.Listener<JSONObject>() {
|
||||
@Override
|
||||
public void onResponse(JSONObject response) {
|
||||
try {
|
||||
clientId = response.getString("client_id");
|
||||
clientSecret = response.getString("client_secret");
|
||||
} catch (JSONException e) {
|
||||
//TODO: Heck
|
||||
return;
|
||||
}
|
||||
SharedPreferences.Editor editor = preferences.edit();
|
||||
editor.putString(domain + "/client_id", clientId);
|
||||
editor.putString(domain + "/client_secret", clientSecret);
|
||||
editor.apply();
|
||||
redirectUserToAuthorizeAndLogin();
|
||||
}
|
||||
}, new Response.ErrorListener() {
|
||||
@Override
|
||||
public void onErrorResponse(VolleyError error) {
|
||||
editText.setError(
|
||||
"This app could not obtain authentication from that server " +
|
||||
"instance.");
|
||||
error.printStackTrace();
|
||||
}
|
||||
});
|
||||
VolleySingleton.getInstance(this).addToRequestQueue(request);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_login);
|
||||
Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
|
||||
setSupportActionBar(toolbar);
|
||||
preferences = getSharedPreferences(
|
||||
getString(R.string.preferences_file_key), Context.MODE_PRIVATE);
|
||||
Button button = (Button) findViewById(R.id.button_login);
|
||||
final EditText editText = (EditText) findViewById(R.id.edit_text_domain);
|
||||
button.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
onButtonClick(editText);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPause() {
|
||||
super.onPause();
|
||||
SharedPreferences.Editor editor = preferences.edit();
|
||||
editor.putString("domain", domain);
|
||||
editor.putString("clientId", clientId);
|
||||
editor.putString("clientSecret", clientSecret);
|
||||
editor.commit();
|
||||
}
|
||||
|
||||
private void onLoginSuccess(String accessToken) {
|
||||
SharedPreferences.Editor editor = preferences.edit();
|
||||
editor.putString("accessToken", accessToken);
|
||||
editor.apply();
|
||||
Intent intent = new Intent(this, MainActivity.class);
|
||||
startActivity(intent);
|
||||
finish();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
/* Check if we are resuming during authorization by seeing if the intent contains the
|
||||
* redirect that was given to the server. If so, its response is here! */
|
||||
Uri uri = getIntent().getData();
|
||||
String redirectUri = getOauthRedirectUri();
|
||||
if (uri != null && uri.toString().startsWith(redirectUri)) {
|
||||
// This should either have returned an authorization code or an error.
|
||||
String code = uri.getQueryParameter("code");
|
||||
String error = uri.getQueryParameter("error");
|
||||
final TextView errorText = (TextView) findViewById(R.id.text_error);
|
||||
if (code != null) {
|
||||
/* During the redirect roundtrip this Activity usually dies, which wipes out the
|
||||
* instance variables, so they have to be recovered from where they were saved in
|
||||
* SharedPreferences. */
|
||||
domain = preferences.getString("domain", null);
|
||||
clientId = preferences.getString("clientId", null);
|
||||
clientSecret = preferences.getString("clientSecret", null);
|
||||
/* Since authorization has succeeded, the final step to log in is to exchange
|
||||
* the authorization code for an access token. */
|
||||
JSONObject parameters = new JSONObject();
|
||||
try {
|
||||
parameters.put("client_id", clientId);
|
||||
parameters.put("client_secret", clientSecret);
|
||||
parameters.put("redirect_uri", redirectUri);
|
||||
parameters.put("code", code);
|
||||
parameters.put("grant_type", "authorization_code");
|
||||
} catch (JSONException e) {
|
||||
errorText.setText("Heck.");
|
||||
//TODO: I don't even know how to handle this error state.
|
||||
}
|
||||
String endpoint = getString(R.string.endpoint_token);
|
||||
String url = "https://" + domain + endpoint;
|
||||
JsonObjectRequest request = new JsonObjectRequest(
|
||||
Request.Method.POST, url, parameters,
|
||||
new Response.Listener<JSONObject>() {
|
||||
@Override
|
||||
public void onResponse(JSONObject response) {
|
||||
String accessToken = "";
|
||||
try {
|
||||
accessToken = response.getString("access_token");
|
||||
} catch(JSONException e) {
|
||||
errorText.setText("Heck.");
|
||||
//TODO: I don't even know how to handle this error state.
|
||||
}
|
||||
onLoginSuccess(accessToken);
|
||||
}
|
||||
}, new Response.ErrorListener() {
|
||||
@Override
|
||||
public void onErrorResponse(VolleyError error) {
|
||||
errorText.setText(error.getMessage());
|
||||
}
|
||||
});
|
||||
VolleySingleton.getInstance(this).addToRequestQueue(request);
|
||||
} else if (error != null) {
|
||||
/* Authorization failed. Put the error response where the user can read it and they
|
||||
* can try again. */
|
||||
errorText.setText(error);
|
||||
} else {
|
||||
assert(false);
|
||||
// This case means a junk response was received somehow.
|
||||
errorText.setText("An unidentified authorization error occurred.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
132
app/src/main/java/com/keylesspalace/tusky/MainActivity.java
Normal file
132
app/src/main/java/com/keylesspalace/tusky/MainActivity.java
Normal file
|
@ -0,0 +1,132 @@
|
|||
package com.keylesspalace.tusky;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.support.v4.content.ContextCompat;
|
||||
import android.support.v4.widget.SwipeRefreshLayout;
|
||||
import android.support.v7.app.AppCompatActivity;
|
||||
import android.os.Bundle;
|
||||
import android.support.v7.widget.DividerItemDecoration;
|
||||
import android.support.v7.widget.LinearLayoutManager;
|
||||
import android.support.v7.widget.RecyclerView;
|
||||
import android.support.v7.widget.Toolbar;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuItem;
|
||||
import android.widget.Toast;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
public class MainActivity extends AppCompatActivity implements FetchTimelineListener,
|
||||
SwipeRefreshLayout.OnRefreshListener {
|
||||
|
||||
private String domain = null;
|
||||
private String accessToken = null;
|
||||
private SwipeRefreshLayout swipeRefreshLayout;
|
||||
private RecyclerView recyclerView;
|
||||
private TimelineAdapter adapter;
|
||||
private LinearLayoutManager layoutManager;
|
||||
private EndlessOnScrollListener scrollListener;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_main);
|
||||
Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
|
||||
setSupportActionBar(toolbar);
|
||||
|
||||
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);
|
||||
|
||||
// Setup the SwipeRefreshLayout.
|
||||
swipeRefreshLayout = (SwipeRefreshLayout) findViewById(R.id.swipe_refresh_layout);
|
||||
swipeRefreshLayout.setOnRefreshListener(this);
|
||||
// Setup the RecyclerView.
|
||||
recyclerView = (RecyclerView) findViewById(R.id.recycler_view);
|
||||
recyclerView.setHasFixedSize(true);
|
||||
layoutManager = new LinearLayoutManager(this);
|
||||
recyclerView.setLayoutManager(layoutManager);
|
||||
DividerItemDecoration divider = new DividerItemDecoration(
|
||||
this, layoutManager.getOrientation());
|
||||
Drawable drawable = ContextCompat.getDrawable(this, R.drawable.status_divider);
|
||||
divider.setDrawable(drawable);
|
||||
recyclerView.addItemDecoration(divider);
|
||||
scrollListener = new EndlessOnScrollListener(layoutManager) {
|
||||
@Override
|
||||
public void onLoadMore(int page, int totalItemsCount, RecyclerView view) {
|
||||
TimelineAdapter adapter = (TimelineAdapter) view.getAdapter();
|
||||
String fromId = adapter.getItem(adapter.getItemCount() - 1).getId();
|
||||
sendFetchTimelineRequest(fromId);
|
||||
}
|
||||
};
|
||||
recyclerView.addOnScrollListener(scrollListener);
|
||||
adapter = new TimelineAdapter();
|
||||
recyclerView.setAdapter(adapter);
|
||||
|
||||
sendFetchTimelineRequest();
|
||||
}
|
||||
|
||||
private void sendFetchTimelineRequest(String fromId) {
|
||||
new FetchTimelineTask(this, this, domain, accessToken, fromId).execute();
|
||||
}
|
||||
|
||||
private void sendFetchTimelineRequest() {
|
||||
sendFetchTimelineRequest(null);
|
||||
}
|
||||
|
||||
public void onFetchTimelineSuccess(List<Status> statuses, boolean added) {
|
||||
if (added) {
|
||||
adapter.addItems(statuses);
|
||||
} else {
|
||||
adapter.update(statuses);
|
||||
}
|
||||
swipeRefreshLayout.setRefreshing(false);
|
||||
}
|
||||
|
||||
public void onFetchTimelineFailure(IOException exception) {
|
||||
Toast.makeText(this, R.string.error_fetching_timeline, Toast.LENGTH_SHORT).show();
|
||||
swipeRefreshLayout.setRefreshing(false);
|
||||
}
|
||||
|
||||
public void onRefresh() {
|
||||
sendFetchTimelineRequest();
|
||||
}
|
||||
|
||||
|
||||
private void logOut() {
|
||||
SharedPreferences preferences = getSharedPreferences(
|
||||
getString(R.string.preferences_file_key), Context.MODE_PRIVATE);
|
||||
SharedPreferences.Editor editor = preferences.edit();
|
||||
editor.remove("domain");
|
||||
editor.remove("accessToken");
|
||||
editor.apply();
|
||||
Intent intent = new Intent(this, SplashActivity.class);
|
||||
startActivity(intent);
|
||||
finish();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCreateOptionsMenu(Menu menu) {
|
||||
getMenuInflater().inflate(R.menu.main_toolbar, menu);
|
||||
return super.onCreateOptionsMenu(menu);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(MenuItem item) {
|
||||
switch (item.getItemId()) {
|
||||
case R.id.action_logout: {
|
||||
logOut();
|
||||
return true;
|
||||
}
|
||||
default: {
|
||||
return super.onOptionsItemSelected(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
package com.keylesspalace.tusky;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.os.Bundle;
|
||||
import android.support.v7.app.AppCompatActivity;
|
||||
|
||||
public class SplashActivity extends AppCompatActivity {
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
/* Determine whether the user is currently logged in, and if so go ahead and load the
|
||||
* timeline. Otherwise, start the activity_login screen. */
|
||||
SharedPreferences preferences = getSharedPreferences(
|
||||
getString(R.string.preferences_file_key), Context.MODE_PRIVATE);
|
||||
String domain = preferences.getString("domain", null);
|
||||
String accessToken = preferences.getString("accessToken", null);
|
||||
Intent intent;
|
||||
if (domain != null && accessToken != null) {
|
||||
intent = new Intent(this, MainActivity.class);
|
||||
} else {
|
||||
intent = new Intent(this, LoginActivity.class);
|
||||
}
|
||||
startActivity(intent);
|
||||
finish();
|
||||
}
|
||||
}
|
77
app/src/main/java/com/keylesspalace/tusky/Status.java
Normal file
77
app/src/main/java/com/keylesspalace/tusky/Status.java
Normal file
|
@ -0,0 +1,77 @@
|
|||
package com.keylesspalace.tusky;
|
||||
|
||||
import android.text.Spanned;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
public class Status {
|
||||
private String id;
|
||||
private String displayName;
|
||||
/** the username with the remote domain appended, like @domain.name, if it's a remote account */
|
||||
private String username;
|
||||
/** the main text of the status, marked up with style for links & mentions, etc */
|
||||
private Spanned content;
|
||||
/** the fully-qualified url of the avatar image */
|
||||
private String avatar;
|
||||
private String rebloggedByUsername;
|
||||
/** when the status was initially created */
|
||||
private Date createdAt;
|
||||
|
||||
public Status(String id, String displayName, String username, Spanned content, String avatar,
|
||||
Date createdAt) {
|
||||
this.id = id;
|
||||
this.displayName = displayName;
|
||||
this.username = username;
|
||||
this.content = content;
|
||||
this.avatar = avatar;
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getDisplayName() {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public Spanned getContent() {
|
||||
return content;
|
||||
}
|
||||
|
||||
public String getAvatar() {
|
||||
return avatar;
|
||||
}
|
||||
|
||||
public Date getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public String getRebloggedByUsername() {
|
||||
return rebloggedByUsername;
|
||||
}
|
||||
|
||||
public void setRebloggedByUsername(String name) {
|
||||
rebloggedByUsername = name;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return id.hashCode();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
if (this.id == null) {
|
||||
return this == other;
|
||||
} else if (!(other instanceof Status)) {
|
||||
return false;
|
||||
}
|
||||
Status status = (Status) other;
|
||||
return status.id.equals(this.id);
|
||||
}
|
||||
}
|
193
app/src/main/java/com/keylesspalace/tusky/TimelineAdapter.java
Normal file
193
app/src/main/java/com/keylesspalace/tusky/TimelineAdapter.java
Normal file
|
@ -0,0 +1,193 @@
|
|||
package com.keylesspalace.tusky;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Bitmap;
|
||||
import android.support.v7.widget.RecyclerView;
|
||||
import android.text.Spanned;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import com.android.volley.toolbox.ImageLoader;
|
||||
import com.android.volley.toolbox.NetworkImageView;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
public class TimelineAdapter extends RecyclerView.Adapter {
|
||||
private List<Status> statuses = new ArrayList<>();
|
||||
|
||||
/*
|
||||
TootActionListener listener;
|
||||
|
||||
public TimelineAdapter(TootActionListener listener) {
|
||||
super();
|
||||
this.listener = listener;
|
||||
}
|
||||
*/
|
||||
|
||||
@Override
|
||||
public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup viewGroup, int viewType) {
|
||||
View v = LayoutInflater.from(viewGroup.getContext())
|
||||
.inflate(R.layout.item_status, viewGroup, false);
|
||||
return new ViewHolder(v);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(RecyclerView.ViewHolder viewHolder, int position) {
|
||||
ViewHolder holder = (ViewHolder) viewHolder;
|
||||
Status status = statuses.get(position);
|
||||
holder.setDisplayName(status.getDisplayName());
|
||||
holder.setUsername(status.getUsername());
|
||||
holder.setCreatedAt(status.getCreatedAt());
|
||||
holder.setContent(status.getContent());
|
||||
holder.setAvatar(status.getAvatar());
|
||||
holder.setContent(status.getContent());
|
||||
String rebloggedByUsername = status.getRebloggedByUsername();
|
||||
if (rebloggedByUsername == null) {
|
||||
holder.hideReblogged();
|
||||
} else {
|
||||
holder.setRebloggedByUsername(rebloggedByUsername);
|
||||
}
|
||||
// holder.initButtons(mListener, position);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return statuses.size();
|
||||
}
|
||||
|
||||
public int update(List<Status> new_statuses) {
|
||||
int scrollToPosition;
|
||||
if (statuses == null || statuses.isEmpty()) {
|
||||
statuses = new_statuses;
|
||||
scrollToPosition = 0;
|
||||
} else {
|
||||
int index = new_statuses.indexOf(statuses.get(0));
|
||||
if (index == -1) {
|
||||
statuses.addAll(0, new_statuses);
|
||||
scrollToPosition = 0;
|
||||
} else {
|
||||
statuses.addAll(0, new_statuses.subList(0, index));
|
||||
scrollToPosition = index;
|
||||
}
|
||||
}
|
||||
notifyDataSetChanged();
|
||||
return scrollToPosition;
|
||||
}
|
||||
|
||||
public void addItems(List<Status> new_statuses) {
|
||||
int end = statuses.size();
|
||||
statuses.addAll(new_statuses);
|
||||
notifyItemRangeInserted(end, new_statuses.size());
|
||||
}
|
||||
|
||||
public Status getItem(int position) {
|
||||
return statuses.get(position);
|
||||
}
|
||||
|
||||
public static class ViewHolder extends RecyclerView.ViewHolder {
|
||||
private TextView displayName;
|
||||
private TextView username;
|
||||
private TextView sinceCreated;
|
||||
private TextView content;
|
||||
private NetworkImageView avatar;
|
||||
private ImageView boostedIcon;
|
||||
private TextView boostedByUsername;
|
||||
|
||||
public ViewHolder(View itemView) {
|
||||
super(itemView);
|
||||
displayName = (TextView) itemView.findViewById(R.id.status_display_name);
|
||||
username = (TextView) itemView.findViewById(R.id.status_username);
|
||||
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);
|
||||
boostedIcon = (ImageView) itemView.findViewById(R.id.status_boosted_icon);
|
||||
boostedByUsername = (TextView) itemView.findViewById(R.id.status_boosted);
|
||||
/*
|
||||
mReplyButton = (ImageButton) itemView.findViewById(R.id.reply);
|
||||
mRetweetButton = (ImageButton) itemView.findViewById(R.id.retweet);
|
||||
mFavoriteButton = (ImageButton) itemView.findViewById(R.id.favorite);
|
||||
*/
|
||||
}
|
||||
|
||||
public void setDisplayName(String name) {
|
||||
displayName.setText(name);
|
||||
}
|
||||
|
||||
public void setUsername(String name) {
|
||||
Context context = username.getContext();
|
||||
String format = context.getString(R.string.status_username_format);
|
||||
String usernameText = String.format(format, name);
|
||||
username.setText(usernameText);
|
||||
}
|
||||
|
||||
public void setContent(Spanned content) {
|
||||
this.content.setText(content);
|
||||
}
|
||||
|
||||
public void setAvatar(String url) {
|
||||
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);
|
||||
}
|
||||
|
||||
/* This is a rough duplicate of android.text.format.DateUtils.getRelativeTimeSpanString,
|
||||
* but even with the FORMAT_ABBREV_RELATIVE flag it wasn't abbreviating enough. */
|
||||
private String getRelativeTimeSpanString(long then, long now) {
|
||||
final long MINUTE = 60;
|
||||
final long HOUR = 60 * MINUTE;
|
||||
final long DAY = 24 * HOUR;
|
||||
final long YEAR = 365 * DAY;
|
||||
long span = (now - then) / 1000;
|
||||
String prefix = "";
|
||||
if (span < 0) {
|
||||
prefix = "in ";
|
||||
span = -span;
|
||||
}
|
||||
String unit;
|
||||
if (span < MINUTE) {
|
||||
unit = "s";
|
||||
} else if (span < HOUR) {
|
||||
span /= MINUTE;
|
||||
unit = "m";
|
||||
} else if (span < DAY) {
|
||||
span /= HOUR;
|
||||
unit = "h";
|
||||
} else if (span < YEAR) {
|
||||
span /= DAY;
|
||||
unit = "d";
|
||||
} else {
|
||||
span /= YEAR;
|
||||
unit = "y";
|
||||
}
|
||||
return prefix + span + unit;
|
||||
}
|
||||
|
||||
public void setCreatedAt(Date createdAt) {
|
||||
long then = createdAt.getTime();
|
||||
long now = new Date().getTime();
|
||||
String since = getRelativeTimeSpanString(then, now);
|
||||
sinceCreated.setText(since);
|
||||
}
|
||||
|
||||
public void setRebloggedByUsername(String name) {
|
||||
Context context = boostedByUsername.getContext();
|
||||
String format = context.getString(R.string.status_boosted_format);
|
||||
String boostedText = String.format(format, name);
|
||||
boostedByUsername.setText(boostedText);
|
||||
boostedIcon.setVisibility(View.VISIBLE);
|
||||
boostedByUsername.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
public void hideReblogged() {
|
||||
boostedIcon.setVisibility(View.GONE);
|
||||
boostedByUsername.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
package com.keylesspalace.tusky;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Bitmap;
|
||||
import android.support.v4.util.LruCache;
|
||||
|
||||
import com.android.volley.Request;
|
||||
import com.android.volley.RequestQueue;
|
||||
import com.android.volley.toolbox.ImageLoader;
|
||||
import com.android.volley.toolbox.Volley;
|
||||
|
||||
public class VolleySingleton {
|
||||
private static VolleySingleton instance;
|
||||
private RequestQueue requestQueue;
|
||||
private ImageLoader imageLoader;
|
||||
private static Context context;
|
||||
|
||||
private VolleySingleton(Context context) {
|
||||
VolleySingleton.context = context;
|
||||
requestQueue = getRequestQueue();
|
||||
imageLoader = new ImageLoader(requestQueue,
|
||||
new ImageLoader.ImageCache() {
|
||||
private final LruCache<String, Bitmap> cache = new LruCache<>(20);
|
||||
|
||||
@Override
|
||||
public Bitmap getBitmap(String url) {
|
||||
return cache.get(url);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void putBitmap(String url, Bitmap bitmap) {
|
||||
cache.put(url, bitmap);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static synchronized VolleySingleton getInstance(Context context) {
|
||||
if (instance == null) {
|
||||
instance = new VolleySingleton(context);
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public RequestQueue getRequestQueue() {
|
||||
if (requestQueue == null) {
|
||||
/* getApplicationContext() is key, it keeps you from leaking the
|
||||
* Activity or BroadcastReceiver if someone passes one in. */
|
||||
requestQueue= Volley.newRequestQueue(context.getApplicationContext());
|
||||
}
|
||||
return requestQueue;
|
||||
}
|
||||
|
||||
public <T> void addToRequestQueue(Request<T> request) {
|
||||
getRequestQueue().add(request);
|
||||
}
|
||||
|
||||
public ImageLoader getImageLoader() {
|
||||
return imageLoader;
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue