commit bba1b37fd8baf71174fc982053e1cbd3c3353889
Author: Vavassor
Date: Mon Jan 2 18:30:27 2017 -0500
initial commit
diff --git a/app/.gitignore b/app/.gitignore
new file mode 100644
index 00000000..796b96d1
--- /dev/null
+++ b/app/.gitignore
@@ -0,0 +1 @@
+/build
diff --git a/app/build.gradle b/app/build.gradle
new file mode 100644
index 00000000..34769365
--- /dev/null
+++ b/app/build.gradle
@@ -0,0 +1,31 @@
+apply plugin: 'com.android.application'
+
+android {
+ compileSdkVersion 25
+ buildToolsVersion "25.0.2"
+ defaultConfig {
+ applicationId "com.keylesspalace.tusky"
+ minSdkVersion 15
+ targetSdkVersion 25
+ versionCode 1
+ versionName "1.0"
+ testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
+ }
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+ }
+ }
+}
+
+dependencies {
+ compile fileTree(dir: 'libs', include: ['*.jar'])
+ androidTestCompile('com.android.support.test.espresso:espresso-core:2.2.2', {
+ exclude group: 'com.android.support', module: 'support-annotations'
+ })
+ 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'
+}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 00000000..cf653532
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1,17 @@
+# Add project specific ProGuard rules here.
+# By default, the flags in this file are appended to flags specified
+# in /home/andrew/Android/Sdk/tools/proguard/proguard-android.txt
+# You can edit the include path and order by changing the proguardFiles
+# directive in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# Add any project specific keep options here:
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
diff --git a/app/src/androidTest/java/com/keylesspalace/tusky/ExampleInstrumentedTest.java b/app/src/androidTest/java/com/keylesspalace/tusky/ExampleInstrumentedTest.java
new file mode 100644
index 00000000..2af4bb53
--- /dev/null
+++ b/app/src/androidTest/java/com/keylesspalace/tusky/ExampleInstrumentedTest.java
@@ -0,0 +1,26 @@
+package com.keylesspalace.tusky;
+
+import android.content.Context;
+import android.support.test.InstrumentationRegistry;
+import android.support.test.runner.AndroidJUnit4;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import static org.junit.Assert.*;
+
+/**
+ * Instrumentation test, which will execute on an Android device.
+ *
+ * @see Testing documentation
+ */
+@RunWith(AndroidJUnit4.class)
+public class ExampleInstrumentedTest {
+ @Test
+ public void useAppContext() throws Exception {
+ // Context of the app under test.
+ Context appContext = InstrumentationRegistry.getTargetContext();
+
+ assertEquals("com.keylesspalace.tusky", appContext.getPackageName());
+ }
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..d8bfc20a
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/com/keylesspalace/tusky/EndlessOnScrollListener.java b/app/src/main/java/com/keylesspalace/tusky/EndlessOnScrollListener.java
new file mode 100644
index 00000000..27d0f82d
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/EndlessOnScrollListener.java
@@ -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);
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/FetchTimelineListener.java b/app/src/main/java/com/keylesspalace/tusky/FetchTimelineListener.java
new file mode 100644
index 00000000..0c612814
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/FetchTimelineListener.java
@@ -0,0 +1,9 @@
+package com.keylesspalace.tusky;
+
+import java.io.IOException;
+import java.util.List;
+
+public interface FetchTimelineListener {
+ void onFetchTimelineSuccess(List statuses, boolean added);
+ void onFetchTimelineFailure(IOException e);
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/FetchTimelineTask.java b/app/src/main/java/com/keylesspalace/tusky/FetchTimelineTask.java
new file mode 100644
index 00000000..abc7959f
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/FetchTimelineTask.java
@@ -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 {
+ private Context context;
+ private FetchTimelineListener fetchTimelineListener;
+ private String domain;
+ private String accessToken;
+ private String fromId;
+ private List 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
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 parameters)
+ throws UnsupportedEncodingException {
+ StringBuilder s = new StringBuilder();
+ String between = "";
+ for (Map.Entry 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 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);
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/LoginActivity.java b/app/src/main/java/com/keylesspalace/tusky/LoginActivity.java
new file mode 100644
index 00000000..898591df
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/LoginActivity.java
@@ -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 parameters)
+ throws UnsupportedEncodingException {
+ StringBuilder s = new StringBuilder();
+ String between = "";
+ for (Map.Entry 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 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() {
+ @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() {
+ @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.");
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.java b/app/src/main/java/com/keylesspalace/tusky/MainActivity.java
new file mode 100644
index 00000000..5ad3998b
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.java
@@ -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 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);
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/SplashActivity.java b/app/src/main/java/com/keylesspalace/tusky/SplashActivity.java
new file mode 100644
index 00000000..2764f42f
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/SplashActivity.java
@@ -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();
+ }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/Status.java b/app/src/main/java/com/keylesspalace/tusky/Status.java
new file mode 100644
index 00000000..4eef6ea3
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/Status.java
@@ -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);
+ }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/TimelineAdapter.java b/app/src/main/java/com/keylesspalace/tusky/TimelineAdapter.java
new file mode 100644
index 00000000..b641fdce
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/TimelineAdapter.java
@@ -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 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 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 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);
+ }
+ }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/VolleySingleton.java b/app/src/main/java/com/keylesspalace/tusky/VolleySingleton.java
new file mode 100644
index 00000000..abfbc009
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/VolleySingleton.java
@@ -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 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 void addToRequestQueue(Request request) {
+ getRequestQueue().add(request);
+ }
+
+ public ImageLoader getImageLoader() {
+ return imageLoader;
+ }
+}
diff --git a/app/src/main/res/drawable/avatar_default.png b/app/src/main/res/drawable/avatar_default.png
new file mode 100644
index 00000000..18d7300c
Binary files /dev/null and b/app/src/main/res/drawable/avatar_default.png differ
diff --git a/app/src/main/res/drawable/avatar_error.png b/app/src/main/res/drawable/avatar_error.png
new file mode 100644
index 00000000..d693c0c3
Binary files /dev/null and b/app/src/main/res/drawable/avatar_error.png differ
diff --git a/app/src/main/res/drawable/boost_icon.png b/app/src/main/res/drawable/boost_icon.png
new file mode 100644
index 00000000..65427712
Binary files /dev/null and b/app/src/main/res/drawable/boost_icon.png differ
diff --git a/app/src/main/res/drawable/splash_background.xml b/app/src/main/res/drawable/splash_background.xml
new file mode 100644
index 00000000..fe6792bf
--- /dev/null
+++ b/app/src/main/res/drawable/splash_background.xml
@@ -0,0 +1,9 @@
+
+
+
+ -
+
+
+
diff --git a/app/src/main/res/drawable/status_divider.xml b/app/src/main/res/drawable/status_divider.xml
new file mode 100644
index 00000000..bc17cadf
--- /dev/null
+++ b/app/src/main/res/drawable/status_divider.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_login.xml b/app/src/main/res/layout/activity_login.xml
new file mode 100644
index 00000000..557ac476
--- /dev/null
+++ b/app/src/main/res/layout/activity_login.xml
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
new file mode 100644
index 00000000..6dfc9ffe
--- /dev/null
+++ b/app/src/main/res/layout/activity_main.xml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/item_status.xml b/app/src/main/res/layout/item_status.xml
new file mode 100644
index 00000000..f8b43b34
--- /dev/null
+++ b/app/src/main/res/layout/item_status.xml
@@ -0,0 +1,79 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/menu/main_toolbar.xml b/app/src/main/res/menu/main_toolbar.xml
new file mode 100644
index 00000000..1c49f725
--- /dev/null
+++ b/app/src/main/res/menu/main_toolbar.xml
@@ -0,0 +1,11 @@
+
+
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 00000000..cde69bcc
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 00000000..c133a0cb
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 00000000..bfa42f0e
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 00000000..324e72cd
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 00000000..aee44e13
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/values-w820dp/dimens.xml b/app/src/main/res/values-w820dp/dimens.xml
new file mode 100644
index 00000000..a013a735
--- /dev/null
+++ b/app/src/main/res/values-w820dp/dimens.xml
@@ -0,0 +1,6 @@
+
+
+ 0dp
+
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
new file mode 100644
index 00000000..5f1f7994
--- /dev/null
+++ b/app/src/main/res/values/colors.xml
@@ -0,0 +1,7 @@
+
+
+ #3F51B5
+ #303F9F
+ #FF4081
+ #4F4F4F
+
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
new file mode 100644
index 00000000..4f478227
--- /dev/null
+++ b/app/src/main/res/values/dimens.xml
@@ -0,0 +1,8 @@
+
+ 0dp
+ 0dp
+ 4dp
+ 4dp
+ 8dp
+ 5dp
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 00000000..6924fe5f
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,19 @@
+
+ Tusky
+
+ com.keylesspalace.tusky
+ oauth2redirect
+ com.keylesspalace.tusky.PREFERENCES
+
+ /oauth/authorize
+ /oauth/token
+ /api/v1/apps
+ /api/v1/timelines/home
+
+ Tusky failed to fetch the timeline.
+
+ \@%s
+ %s boosted
+
+ Log Out
+
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
new file mode 100644
index 00000000..7a1a9cf2
--- /dev/null
+++ b/app/src/main/res/values/styles.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/test/java/com/keylesspalace/tusky/ExampleUnitTest.java b/app/src/test/java/com/keylesspalace/tusky/ExampleUnitTest.java
new file mode 100644
index 00000000..3a2c941a
--- /dev/null
+++ b/app/src/test/java/com/keylesspalace/tusky/ExampleUnitTest.java
@@ -0,0 +1,17 @@
+package com.keylesspalace.tusky;
+
+import org.junit.Test;
+
+import static org.junit.Assert.*;
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * @see Testing documentation
+ */
+public class ExampleUnitTest {
+ @Test
+ public void addition_isCorrect() throws Exception {
+ assertEquals(4, 2 + 2);
+ }
+}
\ No newline at end of file