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