initial commit

This commit is contained in:
Vavassor 2017-01-02 18:30:27 -05:00
commit bba1b37fd8
34 changed files with 1403 additions and 0 deletions

1
app/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

31
app/build.gradle Normal file
View File

@ -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'
}

17
app/proguard-rules.pro vendored Normal file
View File

@ -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 *;
#}

View File

@ -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 <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@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());
}
}

View File

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.keylesspalace.tusky">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:name=".SplashActivity"
android:theme="@style/SplashTheme">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity android:name=".LoginActivity">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="@string/oauth_scheme" android:host="@string/oauth_redirect_host" />
</intent-filter>
</activity>
<activity android:name=".MainActivity" />
</application>
</manifest>

View File

@ -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);
}

View File

@ -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);
}

View 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);
}
}
}
}

View 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.");
}
}
}
}

View 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);
}
}
}
}

View File

@ -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();
}
}

View 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);
}
}

View 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);
}
}
}

View File

@ -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;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 B

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/gray" />
<item>
<bitmap
android:gravity="center"
android:src="@mipmap/ic_launcher" />
</item>
</layer-list>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<size android:height="1dp" />
<solid android:color="#ff000000" />
</shape>

View File

@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.keylesspalace.tusky.LoginActivity">
<android.support.v7.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="?attr/colorPrimary"
android:elevation="4dp"
android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
app:popupTheme="@style/ThemeOverlay.AppCompat.Light" />
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:orientation="vertical"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true">
<EditText
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPersonName"
android:text="Domain"
android:ems="10"
android:id="@+id/edit_text_domain" />
<Button
android:text="LOG IN"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/button_login"
android:layout_centerHorizontal="false"
android:layout_centerInParent="false" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/text_error" />
</LinearLayout>
</RelativeLayout>
</LinearLayout>

View File

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/activity_main"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingBottom="@dimen/activity_vertical_margin"
android:paddingLeft="@dimen/activity_horizontal_margin"
android:paddingRight="@dimen/activity_horizontal_margin"
android:paddingTop="@dimen/activity_vertical_margin"
tools:context="com.keylesspalace.tusky.MainActivity"
android:orientation="vertical">
<android.support.v7.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="?attr/colorPrimary"
android:elevation="4dp"
android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
app:popupTheme="@style/ThemeOverlay.AppCompat.Light"/>
<android.support.v4.widget.SwipeRefreshLayout
android:id="@+id/swipe_refresh_layout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<android.support.v7.widget.RecyclerView
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</android.support.v4.widget.SwipeRefreshLayout>
</LinearLayout>

View File

@ -0,0 +1,79 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/status_boosted"
android:layout_alignParentTop="true"
android:layout_toRightOf="@+id/status_boosted_icon"
android:layout_toEndOf="@+id/status_boosted_icon"
android:visibility="gone" />
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:srcCompat="@drawable/boost_icon"
android:id="@+id/status_boosted_icon"
android:adjustViewBounds="false"
android:cropToPadding="false"
android:layout_alignRight="@+id/status_avatar"
android:visibility="gone"
android:paddingRight="@dimen/status_avatar_padding"
android:paddingTop="@dimen/status_boost_icon_vertical_padding"
android:paddingBottom="@dimen/status_boost_icon_vertical_padding"
android:layout_alignParentTop="true" />
<com.android.volley.toolbox.NetworkImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/status_avatar"
android:layout_alignParentRight="false"
android:layout_alignParentTop="false"
android:layout_alignParentLeft="false"
android:layout_alignParentStart="false"
android:layout_below="@+id/status_boosted"
android:padding="@dimen/status_avatar_padding" />
<LinearLayout
android:orientation="horizontal"
android:layout_height="wrap_content"
android:layout_toRightOf="@+id/status_avatar"
android:layout_toEndOf="@+id/status_avatar"
android:id="@+id/status_name_bar"
android:layout_below="@+id/status_boosted_icon"
android:layout_width="wrap_content">
<TextView
android:id="@+id/status_display_name"
android:layout_height="wrap_content"
android:layout_width="wrap_content"
android:textAppearance="@android:style/TextAppearance.DeviceDefault.Small"
android:textStyle="normal|bold" />
<TextView
android:id="@+id/status_username"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="@dimen/status_username_left_margin" />
<TextView
android:id="@+id/status_since_created"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="@dimen/status_since_created_left_margin" />
</LinearLayout>
<TextView
android:id="@+id/status_content"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_toRightOf="@+id/status_avatar"
android:layout_toEndOf="@+id/status_avatar"
android:layout_below="@+id/status_name_bar" />
</RelativeLayout>

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<menu
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/tools">
<item
android:id="@+id/action_logout"
android:title="@string/action_logout"
app:showAsAction="never" />
</menu>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -0,0 +1,6 @@
<resources>
<!-- Example customization of dimensions originally defined in res/values/dimens.xml
(such as screen margins) for screens with more than 820dp of available width. This
would include 7" and 10" devices in landscape (~960dp and ~1280dp respectively). -->
<dimen name="activity_horizontal_margin">0dp</dimen>
</resources>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">#3F51B5</color>
<color name="colorPrimaryDark">#303F9F</color>
<color name="colorAccent">#FF4081</color>
<color name="gray">#4F4F4F</color>
</resources>

View File

@ -0,0 +1,8 @@
<resources>
<dimen name="activity_horizontal_margin">0dp</dimen>
<dimen name="activity_vertical_margin">0dp</dimen>
<dimen name="status_username_left_margin">4dp</dimen>
<dimen name="status_since_created_left_margin">4dp</dimen>
<dimen name="status_avatar_padding">8dp</dimen>
<dimen name="status_boost_icon_vertical_padding">5dp</dimen>
</resources>

View File

@ -0,0 +1,19 @@
<resources>
<string name="app_name">Tusky</string>
<string name="oauth_scheme">com.keylesspalace.tusky</string>
<string name="oauth_redirect_host">oauth2redirect</string>
<string name="preferences_file_key">com.keylesspalace.tusky.PREFERENCES</string>
<string name="endpoint_authorize">/oauth/authorize</string>
<string name="endpoint_token">/oauth/token</string>
<string name="endpoint_apps">/api/v1/apps</string>
<string name="endpoint_timelines_home">/api/v1/timelines/home</string>
<string name="error_fetching_timeline">Tusky failed to fetch the timeline.</string>
<string name="status_username_format">\@%s</string>
<string name="status_boosted_format">%s boosted</string>
<string name="action_logout">Log Out</string>
</resources>

View File

@ -0,0 +1,14 @@
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
<style name="SplashTheme" parent="Theme.AppCompat.Light.NoActionBar">
<item name="android:windowBackground">@drawable/splash_background</item>
</style>
</resources>

View File

@ -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 <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
public class ExampleUnitTest {
@Test
public void addition_isCorrect() throws Exception {
assertEquals(4, 2 + 2);
}
}