@ -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' |
||||||
|
} |
||||||
@ -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 *; |
||||||
|
#} |
||||||
@ -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()); |
||||||
|
} |
||||||
|
} |
||||||
@ -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> |
||||||
@ -0,0 +1,47 @@ |
|||||||
|
package com.keylesspalace.tusky; |
||||||
|
|
||||||
|
import android.support.v7.widget.LinearLayoutManager; |
||||||
|
import android.support.v7.widget.RecyclerView; |
||||||
|
|
||||||
|
public abstract class EndlessOnScrollListener extends RecyclerView.OnScrollListener { |
||||||
|
private int visibleThreshold = 15; |
||||||
|
private int currentPage = 0; |
||||||
|
private int previousTotalItemCount = 0; |
||||||
|
private boolean loading = true; |
||||||
|
private int startingPageIndex = 0; |
||||||
|
private LinearLayoutManager layoutManager; |
||||||
|
|
||||||
|
public EndlessOnScrollListener(LinearLayoutManager layoutManager) { |
||||||
|
this.layoutManager = layoutManager; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public void onScrolled(RecyclerView view, int dx, int dy) { |
||||||
|
int totalItemCount = layoutManager.getItemCount(); |
||||||
|
int lastVisibleItemPosition = layoutManager.findLastVisibleItemPosition(); |
||||||
|
if (totalItemCount < previousTotalItemCount) { |
||||||
|
currentPage = startingPageIndex; |
||||||
|
previousTotalItemCount = totalItemCount; |
||||||
|
if (totalItemCount == 0) { |
||||||
|
loading = true; |
||||||
|
} |
||||||
|
} |
||||||
|
if (loading && totalItemCount > previousTotalItemCount) { |
||||||
|
loading = false; |
||||||
|
previousTotalItemCount = totalItemCount; |
||||||
|
} |
||||||
|
if (!loading && lastVisibleItemPosition + visibleThreshold > totalItemCount) { |
||||||
|
currentPage++; |
||||||
|
onLoadMore(currentPage, totalItemCount, view); |
||||||
|
loading = true; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public void reset() { |
||||||
|
currentPage = startingPageIndex; |
||||||
|
previousTotalItemCount = 0; |
||||||
|
loading = true; |
||||||
|
} |
||||||
|
|
||||||
|
public abstract void onLoadMore(int page, int totalItemsCount, RecyclerView view); |
||||||
|
} |
||||||
@ -0,0 +1,9 @@ |
|||||||
|
package com.keylesspalace.tusky; |
||||||
|
|
||||||
|
import java.io.IOException; |
||||||
|
import java.util.List; |
||||||
|
|
||||||
|
public interface FetchTimelineListener { |
||||||
|
void onFetchTimelineSuccess(List<Status> statuses, boolean added); |
||||||
|
void onFetchTimelineFailure(IOException e); |
||||||
|
} |
||||||
@ -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); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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."); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,132 @@ |
|||||||
|
package com.keylesspalace.tusky; |
||||||
|
|
||||||
|
import android.content.Context; |
||||||
|
import android.content.Intent; |
||||||
|
import android.content.SharedPreferences; |
||||||
|
import android.graphics.drawable.Drawable; |
||||||
|
import android.support.v4.content.ContextCompat; |
||||||
|
import android.support.v4.widget.SwipeRefreshLayout; |
||||||
|
import android.support.v7.app.AppCompatActivity; |
||||||
|
import android.os.Bundle; |
||||||
|
import android.support.v7.widget.DividerItemDecoration; |
||||||
|
import android.support.v7.widget.LinearLayoutManager; |
||||||
|
import android.support.v7.widget.RecyclerView; |
||||||
|
import android.support.v7.widget.Toolbar; |
||||||
|
import android.view.Menu; |
||||||
|
import android.view.MenuItem; |
||||||
|
import android.widget.Toast; |
||||||
|
|
||||||
|
import java.io.IOException; |
||||||
|
import java.util.List; |
||||||
|
|
||||||
|
public class MainActivity extends AppCompatActivity implements FetchTimelineListener, |
||||||
|
SwipeRefreshLayout.OnRefreshListener { |
||||||
|
|
||||||
|
private String domain = null; |
||||||
|
private String accessToken = null; |
||||||
|
private SwipeRefreshLayout swipeRefreshLayout; |
||||||
|
private RecyclerView recyclerView; |
||||||
|
private TimelineAdapter adapter; |
||||||
|
private LinearLayoutManager layoutManager; |
||||||
|
private EndlessOnScrollListener scrollListener; |
||||||
|
|
||||||
|
@Override |
||||||
|
protected void onCreate(Bundle savedInstanceState) { |
||||||
|
super.onCreate(savedInstanceState); |
||||||
|
setContentView(R.layout.activity_main); |
||||||
|
Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); |
||||||
|
setSupportActionBar(toolbar); |
||||||
|
|
||||||
|
SharedPreferences preferences = getSharedPreferences( |
||||||
|
getString(R.string.preferences_file_key), Context.MODE_PRIVATE); |
||||||
|
domain = preferences.getString("domain", null); |
||||||
|
accessToken = preferences.getString("accessToken", null); |
||||||
|
assert(domain != null); |
||||||
|
assert(accessToken != null); |
||||||
|
|
||||||
|
// Setup the SwipeRefreshLayout.
|
||||||
|
swipeRefreshLayout = (SwipeRefreshLayout) findViewById(R.id.swipe_refresh_layout); |
||||||
|
swipeRefreshLayout.setOnRefreshListener(this); |
||||||
|
// Setup the RecyclerView.
|
||||||
|
recyclerView = (RecyclerView) findViewById(R.id.recycler_view); |
||||||
|
recyclerView.setHasFixedSize(true); |
||||||
|
layoutManager = new LinearLayoutManager(this); |
||||||
|
recyclerView.setLayoutManager(layoutManager); |
||||||
|
DividerItemDecoration divider = new DividerItemDecoration( |
||||||
|
this, layoutManager.getOrientation()); |
||||||
|
Drawable drawable = ContextCompat.getDrawable(this, R.drawable.status_divider); |
||||||
|
divider.setDrawable(drawable); |
||||||
|
recyclerView.addItemDecoration(divider); |
||||||
|
scrollListener = new EndlessOnScrollListener(layoutManager) { |
||||||
|
@Override |
||||||
|
public void onLoadMore(int page, int totalItemsCount, RecyclerView view) { |
||||||
|
TimelineAdapter adapter = (TimelineAdapter) view.getAdapter(); |
||||||
|
String fromId = adapter.getItem(adapter.getItemCount() - 1).getId(); |
||||||
|
sendFetchTimelineRequest(fromId); |
||||||
|
} |
||||||
|
}; |
||||||
|
recyclerView.addOnScrollListener(scrollListener); |
||||||
|
adapter = new TimelineAdapter(); |
||||||
|
recyclerView.setAdapter(adapter); |
||||||
|
|
||||||
|
sendFetchTimelineRequest(); |
||||||
|
} |
||||||
|
|
||||||
|
private void sendFetchTimelineRequest(String fromId) { |
||||||
|
new FetchTimelineTask(this, this, domain, accessToken, fromId).execute(); |
||||||
|
} |
||||||
|
|
||||||
|
private void sendFetchTimelineRequest() { |
||||||
|
sendFetchTimelineRequest(null); |
||||||
|
} |
||||||
|
|
||||||
|
public void onFetchTimelineSuccess(List<Status> statuses, boolean added) { |
||||||
|
if (added) { |
||||||
|
adapter.addItems(statuses); |
||||||
|
} else { |
||||||
|
adapter.update(statuses); |
||||||
|
} |
||||||
|
swipeRefreshLayout.setRefreshing(false); |
||||||
|
} |
||||||
|
|
||||||
|
public void onFetchTimelineFailure(IOException exception) { |
||||||
|
Toast.makeText(this, R.string.error_fetching_timeline, Toast.LENGTH_SHORT).show(); |
||||||
|
swipeRefreshLayout.setRefreshing(false); |
||||||
|
} |
||||||
|
|
||||||
|
public void onRefresh() { |
||||||
|
sendFetchTimelineRequest(); |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
private void logOut() { |
||||||
|
SharedPreferences preferences = getSharedPreferences( |
||||||
|
getString(R.string.preferences_file_key), Context.MODE_PRIVATE); |
||||||
|
SharedPreferences.Editor editor = preferences.edit(); |
||||||
|
editor.remove("domain"); |
||||||
|
editor.remove("accessToken"); |
||||||
|
editor.apply(); |
||||||
|
Intent intent = new Intent(this, SplashActivity.class); |
||||||
|
startActivity(intent); |
||||||
|
finish(); |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public boolean onCreateOptionsMenu(Menu menu) { |
||||||
|
getMenuInflater().inflate(R.menu.main_toolbar, menu); |
||||||
|
return super.onCreateOptionsMenu(menu); |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public boolean onOptionsItemSelected(MenuItem item) { |
||||||
|
switch (item.getItemId()) { |
||||||
|
case R.id.action_logout: { |
||||||
|
logOut(); |
||||||
|
return true; |
||||||
|
} |
||||||
|
default: { |
||||||
|
return super.onOptionsItemSelected(item); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,28 @@ |
|||||||
|
package com.keylesspalace.tusky; |
||||||
|
|
||||||
|
import android.content.Context; |
||||||
|
import android.content.Intent; |
||||||
|
import android.content.SharedPreferences; |
||||||
|
import android.os.Bundle; |
||||||
|
import android.support.v7.app.AppCompatActivity; |
||||||
|
|
||||||
|
public class SplashActivity extends AppCompatActivity { |
||||||
|
@Override |
||||||
|
protected void onCreate(Bundle savedInstanceState) { |
||||||
|
super.onCreate(savedInstanceState); |
||||||
|
/* Determine whether the user is currently logged in, and if so go ahead and load the |
||||||
|
* timeline. Otherwise, start the activity_login screen. */ |
||||||
|
SharedPreferences preferences = getSharedPreferences( |
||||||
|
getString(R.string.preferences_file_key), Context.MODE_PRIVATE); |
||||||
|
String domain = preferences.getString("domain", null); |
||||||
|
String accessToken = preferences.getString("accessToken", null); |
||||||
|
Intent intent; |
||||||
|
if (domain != null && accessToken != null) { |
||||||
|
intent = new Intent(this, MainActivity.class); |
||||||
|
} else { |
||||||
|
intent = new Intent(this, LoginActivity.class); |
||||||
|
} |
||||||
|
startActivity(intent); |
||||||
|
finish(); |
||||||
|
} |
||||||
|
} |
||||||
@ -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); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,193 @@ |
|||||||
|
package com.keylesspalace.tusky; |
||||||
|
|
||||||
|
import android.content.Context; |
||||||
|
import android.graphics.Bitmap; |
||||||
|
import android.support.v7.widget.RecyclerView; |
||||||
|
import android.text.Spanned; |
||||||
|
import android.view.LayoutInflater; |
||||||
|
import android.view.View; |
||||||
|
import android.view.ViewGroup; |
||||||
|
import android.widget.ImageView; |
||||||
|
import android.widget.TextView; |
||||||
|
|
||||||
|
import com.android.volley.toolbox.ImageLoader; |
||||||
|
import com.android.volley.toolbox.NetworkImageView; |
||||||
|
|
||||||
|
import java.util.ArrayList; |
||||||
|
import java.util.Date; |
||||||
|
import java.util.List; |
||||||
|
|
||||||
|
public class TimelineAdapter extends RecyclerView.Adapter { |
||||||
|
private List<Status> statuses = new ArrayList<>(); |
||||||
|
|
||||||
|
/* |
||||||
|
TootActionListener listener; |
||||||
|
|
||||||
|
public TimelineAdapter(TootActionListener listener) { |
||||||
|
super(); |
||||||
|
this.listener = listener; |
||||||
|
} |
||||||
|
*/ |
||||||
|
|
||||||
|
@Override |
||||||
|
public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup viewGroup, int viewType) { |
||||||
|
View v = LayoutInflater.from(viewGroup.getContext()) |
||||||
|
.inflate(R.layout.item_status, viewGroup, false); |
||||||
|
return new ViewHolder(v); |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public void onBindViewHolder(RecyclerView.ViewHolder viewHolder, int position) { |
||||||
|
ViewHolder holder = (ViewHolder) viewHolder; |
||||||
|
Status status = statuses.get(position); |
||||||
|
holder.setDisplayName(status.getDisplayName()); |
||||||
|
holder.setUsername(status.getUsername()); |
||||||
|
holder.setCreatedAt(status.getCreatedAt()); |
||||||
|
holder.setContent(status.getContent()); |
||||||
|
holder.setAvatar(status.getAvatar()); |
||||||
|
holder.setContent(status.getContent()); |
||||||
|
String rebloggedByUsername = status.getRebloggedByUsername(); |
||||||
|
if (rebloggedByUsername == null) { |
||||||
|
holder.hideReblogged(); |
||||||
|
} else { |
||||||
|
holder.setRebloggedByUsername(rebloggedByUsername); |
||||||
|
} |
||||||
|
// holder.initButtons(mListener, position);
|
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public int getItemCount() { |
||||||
|
return statuses.size(); |
||||||
|
} |
||||||
|
|
||||||
|
public int update(List<Status> new_statuses) { |
||||||
|
int scrollToPosition; |
||||||
|
if (statuses == null || statuses.isEmpty()) { |
||||||
|
statuses = new_statuses; |
||||||
|
scrollToPosition = 0; |
||||||
|
} else { |
||||||
|
int index = new_statuses.indexOf(statuses.get(0)); |
||||||
|
if (index == -1) { |
||||||
|
statuses.addAll(0, new_statuses); |
||||||
|
scrollToPosition = 0; |
||||||
|
} else { |
||||||
|
statuses.addAll(0, new_statuses.subList(0, index)); |
||||||
|
scrollToPosition = index; |
||||||
|
} |
||||||
|
} |
||||||
|
notifyDataSetChanged(); |
||||||
|
return scrollToPosition; |
||||||
|
} |
||||||
|
|
||||||
|
public void addItems(List<Status> new_statuses) { |
||||||
|
int end = statuses.size(); |
||||||
|
statuses.addAll(new_statuses); |
||||||
|
notifyItemRangeInserted(end, new_statuses.size()); |
||||||
|
} |
||||||
|
|
||||||
|
public Status getItem(int position) { |
||||||
|
return statuses.get(position); |
||||||
|
} |
||||||
|
|
||||||
|
public static class ViewHolder extends RecyclerView.ViewHolder { |
||||||
|
private TextView displayName; |
||||||
|
private TextView username; |
||||||
|
private TextView sinceCreated; |
||||||
|
private TextView content; |
||||||
|
private NetworkImageView avatar; |
||||||
|
private ImageView boostedIcon; |
||||||
|
private TextView boostedByUsername; |
||||||
|
|
||||||
|
public ViewHolder(View itemView) { |
||||||
|
super(itemView); |
||||||
|
displayName = (TextView) itemView.findViewById(R.id.status_display_name); |
||||||
|
username = (TextView) itemView.findViewById(R.id.status_username); |
||||||
|
sinceCreated = (TextView) itemView.findViewById(R.id.status_since_created); |
||||||
|
content = (TextView) itemView.findViewById(R.id.status_content); |
||||||
|
avatar = (NetworkImageView) itemView.findViewById(R.id.status_avatar); |
||||||
|
boostedIcon = (ImageView) itemView.findViewById(R.id.status_boosted_icon); |
||||||
|
boostedByUsername = (TextView) itemView.findViewById(R.id.status_boosted); |
||||||
|
/* |
||||||
|
mReplyButton = (ImageButton) itemView.findViewById(R.id.reply); |
||||||
|
mRetweetButton = (ImageButton) itemView.findViewById(R.id.retweet); |
||||||
|
mFavoriteButton = (ImageButton) itemView.findViewById(R.id.favorite); |
||||||
|
*/ |
||||||
|
} |
||||||
|
|
||||||
|
public void setDisplayName(String name) { |
||||||
|
displayName.setText(name); |
||||||
|
} |
||||||
|
|
||||||
|
public void setUsername(String name) { |
||||||
|
Context context = username.getContext(); |
||||||
|
String format = context.getString(R.string.status_username_format); |
||||||
|
String usernameText = String.format(format, name); |
||||||
|
username.setText(usernameText); |
||||||
|
} |
||||||
|
|
||||||
|
public void setContent(Spanned content) { |
||||||
|
this.content.setText(content); |
||||||
|
} |
||||||
|
|
||||||
|
public void setAvatar(String url) { |
||||||
|
Context context = avatar.getContext(); |
||||||
|
ImageLoader imageLoader = VolleySingleton.getInstance(context).getImageLoader(); |
||||||
|
avatar.setImageUrl(url, imageLoader); |
||||||
|
avatar.setDefaultImageResId(R.drawable.avatar_default); |
||||||
|
avatar.setErrorImageResId(R.drawable.avatar_error); |
||||||
|
} |
||||||
|
|
||||||
|
/* This is a rough duplicate of android.text.format.DateUtils.getRelativeTimeSpanString, |
||||||
|
* but even with the FORMAT_ABBREV_RELATIVE flag it wasn't abbreviating enough. */ |
||||||
|
private String getRelativeTimeSpanString(long then, long now) { |
||||||
|
final long MINUTE = 60; |
||||||
|
final long HOUR = 60 * MINUTE; |
||||||
|
final long DAY = 24 * HOUR; |
||||||
|
final long YEAR = 365 * DAY; |
||||||
|
long span = (now - then) / 1000; |
||||||
|
String prefix = ""; |
||||||
|
if (span < 0) { |
||||||
|
prefix = "in "; |
||||||
|
span = -span; |
||||||
|
} |
||||||
|
String unit; |
||||||
|
if (span < MINUTE) { |
||||||
|
unit = "s"; |
||||||
|
} else if (span < HOUR) { |
||||||
|
span /= MINUTE; |
||||||
|
unit = "m"; |
||||||
|
} else if (span < DAY) { |
||||||
|
span /= HOUR; |
||||||
|
unit = "h"; |
||||||
|
} else if (span < YEAR) { |
||||||
|
span /= DAY; |
||||||
|
unit = "d"; |
||||||
|
} else { |
||||||
|
span /= YEAR; |
||||||
|
unit = "y"; |
||||||
|
} |
||||||
|
return prefix + span + unit; |
||||||
|
} |
||||||
|
|
||||||
|
public void setCreatedAt(Date createdAt) { |
||||||
|
long then = createdAt.getTime(); |
||||||
|
long now = new Date().getTime(); |
||||||
|
String since = getRelativeTimeSpanString(then, now); |
||||||
|
sinceCreated.setText(since); |
||||||
|
} |
||||||
|
|
||||||
|
public void setRebloggedByUsername(String name) { |
||||||
|
Context context = boostedByUsername.getContext(); |
||||||
|
String format = context.getString(R.string.status_boosted_format); |
||||||
|
String boostedText = String.format(format, name); |
||||||
|
boostedByUsername.setText(boostedText); |
||||||
|
boostedIcon.setVisibility(View.VISIBLE); |
||||||
|
boostedByUsername.setVisibility(View.VISIBLE); |
||||||
|
} |
||||||
|
|
||||||
|
public void hideReblogged() { |
||||||
|
boostedIcon.setVisibility(View.GONE); |
||||||
|
boostedByUsername.setVisibility(View.GONE); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,60 @@ |
|||||||
|
package com.keylesspalace.tusky; |
||||||
|
|
||||||
|
import android.content.Context; |
||||||
|
import android.graphics.Bitmap; |
||||||
|
import android.support.v4.util.LruCache; |
||||||
|
|
||||||
|
import com.android.volley.Request; |
||||||
|
import com.android.volley.RequestQueue; |
||||||
|
import com.android.volley.toolbox.ImageLoader; |
||||||
|
import com.android.volley.toolbox.Volley; |
||||||
|
|
||||||
|
public class VolleySingleton { |
||||||
|
private static VolleySingleton instance; |
||||||
|
private RequestQueue requestQueue; |
||||||
|
private ImageLoader imageLoader; |
||||||
|
private static Context context; |
||||||
|
|
||||||
|
private VolleySingleton(Context context) { |
||||||
|
VolleySingleton.context = context; |
||||||
|
requestQueue = getRequestQueue(); |
||||||
|
imageLoader = new ImageLoader(requestQueue, |
||||||
|
new ImageLoader.ImageCache() { |
||||||
|
private final LruCache<String, Bitmap> cache = new LruCache<>(20); |
||||||
|
|
||||||
|
@Override |
||||||
|
public Bitmap getBitmap(String url) { |
||||||
|
return cache.get(url); |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public void putBitmap(String url, Bitmap bitmap) { |
||||||
|
cache.put(url, bitmap); |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
public static synchronized VolleySingleton getInstance(Context context) { |
||||||
|
if (instance == null) { |
||||||
|
instance = new VolleySingleton(context); |
||||||
|
} |
||||||
|
return instance; |
||||||
|
} |
||||||
|
|
||||||
|
public RequestQueue getRequestQueue() { |
||||||
|
if (requestQueue == null) { |
||||||
|
/* getApplicationContext() is key, it keeps you from leaking the |
||||||
|
* Activity or BroadcastReceiver if someone passes one in. */ |
||||||
|
requestQueue= Volley.newRequestQueue(context.getApplicationContext()); |
||||||
|
} |
||||||
|
return requestQueue; |
||||||
|
} |
||||||
|
|
||||||
|
public <T> void addToRequestQueue(Request<T> request) { |
||||||
|
getRequestQueue().add(request); |
||||||
|
} |
||||||
|
|
||||||
|
public ImageLoader getImageLoader() { |
||||||
|
return imageLoader; |
||||||
|
} |
||||||
|
} |
||||||
|
After Width: | Height: | Size: 208 B |
|
After Width: | Height: | Size: 356 B |
|
After Width: | Height: | Size: 221 B |
@ -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> |
||||||
@ -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> |
||||||
@ -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> |
||||||
@ -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> |
||||||
@ -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> |
||||||
@ -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> |
||||||
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 10 KiB |
@ -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> |
||||||
@ -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> |
||||||
@ -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> |
||||||
@ -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> |
||||||
@ -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> |
||||||
@ -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); |
||||||
|
} |
||||||
|
} |
||||||