Ditch HttpClient for OkHttp, redo captcha

Replaced all calls that required the old HttpClient with the equivalent OkHttp equivalent.
All request are now made with the User-Agent Clover/version
The captcha is now done through a WebView, loading up the code for recaptcha v2 with loadDataWithBaseURL see CaptchaLayout for details.
captchafix2
Floens 10 years ago
parent 57db717afb
commit 899834f849
  1. BIN
      Clover/app/libs/httpclientandroidlib-1.2.1.jar
  2. 41
      Clover/app/src/main/assets/captcha/captcha.html
  3. 13
      Clover/app/src/main/java/com/android/volley/toolbox/HurlStack.java
  4. 42
      Clover/app/src/main/java/com/android/volley/toolbox/Volley.java
  5. 11
      Clover/app/src/main/java/org/floens/chan/ChanApplication.java
  6. 8
      Clover/app/src/main/java/org/floens/chan/chan/ChanUrls.java
  7. 611
      Clover/app/src/main/java/org/floens/chan/core/manager/ReplyManager.java
  8. 2
      Clover/app/src/main/java/org/floens/chan/core/manager/ThreadManager.java
  9. 28
      Clover/app/src/main/java/org/floens/chan/core/model/Pass.java
  10. 1
      Clover/app/src/main/java/org/floens/chan/core/model/Reply.java
  11. 8
      Clover/app/src/main/java/org/floens/chan/ui/activity/PassSettingsActivity.java
  12. 94
      Clover/app/src/main/java/org/floens/chan/ui/fragment/ReplyFragment.java
  13. 130
      Clover/app/src/main/java/org/floens/chan/ui/layout/CaptchaLayout.java
  14. 10
      Clover/app/src/main/java/org/floens/chan/utils/FileCache.java
  15. 31
      Clover/app/src/main/java/org/floens/chan/utils/IOUtils.java
  16. 48
      Clover/app/src/main/res/layout/reply_captcha.xml
  17. 8
      Clover/app/src/main/res/layout/reply_view.xml
  18. 2
      Clover/app/src/main/res/values/strings.xml

@ -0,0 +1,41 @@
<!DOCTYPE html>
<html>
<head>
<meta name=viewport content="width=device-width, initial-scale=1">
<style type="text/css">
body.light {
background: #ffffff;
}
body.dark {
background: #000000;
}
#loadingCaptcha {
font-family: sans-serif;
font-size: 20px;
}
</style>
<script src='https://www.google.com/recaptcha/api.js?onload=globalOnCaptchaLoaded&render=explicit'></script>
</head>
<body class="__theme__">
<div id="captcha-loading">Loading captcha...</div>
<div id="captcha-container"></div>
<script type="text/javascript">
window.globalOnCaptchaEntered = function(res) {
CaptchaCallback.onCaptchaEntered(res);
}
window.globalOnCaptchaLoaded = function() {
grecaptcha.render('captcha-container', {
'sitekey': '__site_key__',
'theme': '__theme__',
'callback': globalOnCaptchaEntered
});
CaptchaCallback.onCaptchaLoaded();
document.getElementById('captcha-loading').style.display = 'none';
}
</script>
</body>
</html>

@ -49,6 +49,7 @@ import javax.net.ssl.SSLSocketFactory;
public class HurlStack implements HttpStack {
private static final String HEADER_CONTENT_TYPE = "Content-Type";
private final String mUserAgent;
/**
* An interface for transforming URLs before use.
@ -64,22 +65,23 @@ public class HurlStack implements HttpStack {
private final UrlRewriter mUrlRewriter;
private final SSLSocketFactory mSslSocketFactory;
public HurlStack() {
this(null);
public HurlStack(String userAgent) {
this(userAgent, null);
}
/**
* @param urlRewriter Rewriter to use for request URLs
*/
public HurlStack(UrlRewriter urlRewriter) {
this(urlRewriter, null);
public HurlStack(String userAgent, UrlRewriter urlRewriter) {
this(userAgent, urlRewriter, null);
}
/**
* @param urlRewriter Rewriter to use for request URLs
* @param sslSocketFactory SSL factory to use for HTTPS connections
*/
public HurlStack(UrlRewriter urlRewriter, SSLSocketFactory sslSocketFactory) {
public HurlStack(String userAgent, UrlRewriter urlRewriter, SSLSocketFactory sslSocketFactory) {
mUserAgent = userAgent;
mUrlRewriter = urlRewriter;
mSslSocketFactory = sslSocketFactory;
}
@ -91,6 +93,7 @@ public class HurlStack implements HttpStack {
HashMap<String, String> map = new HashMap<String, String>();
map.putAll(request.getHeaders());
map.putAll(additionalHeaders);
map.put("User-Agent", mUserAgent);
if (mUrlRewriter != null) {
String rewritten = mUrlRewriter.rewriteUrl(url);
if (rewritten == null) {

@ -17,8 +17,6 @@
package com.android.volley.toolbox;
import android.content.Context;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager.NameNotFoundException;
import android.net.http.AndroidHttpClient;
import android.os.Build;
@ -33,38 +31,14 @@ public class Volley {
/** Default on-disk cache directory. */
public static final String DEFAULT_CACHE_DIR = "volley";
/**
* Creates a default instance of the worker pool and calls {@link RequestQueue#start()} on it.
*
* @param context A {@link Context} to use for creating the cache dir.
* @param stack An {@link HttpStack} to use for the network, or null for default.
* @return A started {@link RequestQueue} instance.
*/
public static RequestQueue newRequestQueue(Context context, HttpStack stack) {
return newRequestQueue(context, stack, new File(context.getCacheDir(), DEFAULT_CACHE_DIR));
}
public static RequestQueue newRequestQueue(Context context, HttpStack stack, File cacheDir) {
return newRequestQueue(context, stack, cacheDir, -1);
}
public static RequestQueue newRequestQueue(Context context, HttpStack stack, File cacheDir, int diskCacheSize) {
String userAgent = "volley/0";
try {
String packageName = context.getPackageName();
PackageInfo info = context.getPackageManager().getPackageInfo(packageName, 0);
userAgent = packageName + "/" + info.versionCode;
} catch (NameNotFoundException e) {
}
public static RequestQueue newRequestQueue(Context context, String userAgent, HttpStack stack, File cacheDir, int diskCacheSize) {
if (stack == null) {
if (Build.VERSION.SDK_INT >= 9) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) {
// Use a socket factory that removes sslv3
stack = new HurlStack(null, new NoSSLv3Compat.NoSSLv3Factory());
stack = new HurlStack(userAgent, null, new NoSSLv3Compat.NoSSLv3Factory());
} else {
stack = new HurlStack();
stack = new HurlStack(userAgent);
}
} else {
// Prior to Gingerbread, HttpUrlConnection was unreliable.
@ -81,14 +55,4 @@ public class Volley {
return queue;
}
/**
* Creates a default instance of the worker pool and calls {@link RequestQueue#start()} on it.
*
* @param context A {@link Context} to use for creating the cache dir.
* @return A started {@link RequestQueue} instance.
*/
public static RequestQueue newRequestQueue(Context context) {
return newRequestQueue(context, null);
}
}

@ -19,6 +19,7 @@ package org.floens.chan;
import android.app.Application;
import android.content.SharedPreferences;
import android.os.StrictMode;
import android.preference.PreferenceManager;
import android.view.ViewConfiguration;
@ -119,8 +120,8 @@ public class ChanApplication extends Application {
}
if (ChanBuild.DEVELOPER_MODE) {
// StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder().detectAll().penaltyLog().build());
// StrictMode.setVmPolicy(new StrictMode.VmPolicy.Builder().detectAll().penaltyLog().build());
StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder().detectAll().penaltyLog().build());
StrictMode.setVmPolicy(new StrictMode.VmPolicy.Builder().detectAll().penaltyLog().build());
}
ChanUrls.loadScheme(ChanPreferences.getNetworkHttps());
@ -129,7 +130,9 @@ public class ChanApplication extends Application {
File cacheDir = getExternalCacheDir() != null ? getExternalCacheDir() : getCacheDir();
volleyRequestQueue = Volley.newRequestQueue(this, null, new File(cacheDir, Volley.DEFAULT_CACHE_DIR), VOLLEY_CACHE_SIZE);
replyManager = new ReplyManager(this);
volleyRequestQueue = Volley.newRequestQueue(this, replyManager.getUserAgent(), null, new File(cacheDir, Volley.DEFAULT_CACHE_DIR), VOLLEY_CACHE_SIZE);
imageLoader = new ImageLoader(volleyRequestQueue, new BitmapLruImageCache(VOLLEY_LRU_CACHE_SIZE));
fileCache = new FileCache(new File(cacheDir, FILE_CACHE_NAME), FILE_CACHE_DISK_SIZE);
@ -137,7 +140,7 @@ public class ChanApplication extends Application {
databaseManager = new DatabaseManager(this);
boardManager = new BoardManager();
watchManager = new WatchManager(this);
replyManager = new ReplyManager(this);
}
public void activityEnteredForeground() {

@ -38,12 +38,8 @@ public class ChanUrls {
return scheme + "://a.4cdn.org/" + board + "/thread/" + no + ".json";
}
public static String getCaptchaDomain() {
return scheme + "://www.google.com/";
}
public static String getCaptchaFallback() {
return scheme + "://www.google.com/recaptcha/api/fallback?k=6Ldp2bsSAAAAAAJ5uyx_lx34lJeEpTLVkP5k04qc";
public static String getCaptchaSiteKey() {
return "6Ldp2bsSAAAAAAJ5uyx_lx34lJeEpTLVkP5k04qc";
}
public static String getImageUrl(String board, String code, String extension) {

@ -23,6 +23,7 @@ import android.content.pm.PackageManager;
import android.text.TextUtils;
import com.squareup.okhttp.Callback;
import com.squareup.okhttp.FormEncodingBuilder;
import com.squareup.okhttp.MediaType;
import com.squareup.okhttp.MultipartBuilder;
import com.squareup.okhttp.OkHttpClient;
@ -33,58 +34,61 @@ import com.squareup.okhttp.Response;
import org.floens.chan.ChanApplication;
import org.floens.chan.R;
import org.floens.chan.chan.ChanUrls;
import org.floens.chan.core.model.Pass;
import org.floens.chan.core.model.Reply;
import org.floens.chan.core.model.SavedReply;
import org.floens.chan.ui.activity.ImagePickActivity;
import org.floens.chan.utils.Logger;
import org.floens.chan.utils.Utils;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import java.io.File;
import java.io.IOException;
import java.net.HttpCookie;
import java.util.List;
import java.util.Locale;
import java.util.Random;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import ch.boye.httpclientandroidlib.Consts;
import ch.boye.httpclientandroidlib.Header;
import ch.boye.httpclientandroidlib.HeaderElement;
import ch.boye.httpclientandroidlib.HttpResponse;
import ch.boye.httpclientandroidlib.client.HttpClient;
import ch.boye.httpclientandroidlib.client.config.RequestConfig;
import ch.boye.httpclientandroidlib.client.methods.CloseableHttpResponse;
import ch.boye.httpclientandroidlib.client.methods.HttpPost;
import ch.boye.httpclientandroidlib.entity.ContentType;
import ch.boye.httpclientandroidlib.entity.mime.MultipartEntityBuilder;
import ch.boye.httpclientandroidlib.impl.client.CloseableHttpClient;
import ch.boye.httpclientandroidlib.impl.client.HttpClientBuilder;
import ch.boye.httpclientandroidlib.util.EntityUtils;
/**
* To send an reply to 4chan.
*/
public class ReplyManager {
private static final String TAG = "ReplyManager";
private static final Pattern responsePattern = Pattern.compile("<!-- thread:([0-9]+),no:([0-9]+) -->");
private static final int POST_TIMEOUT = 10000;
private static final ContentType TEXT_UTF_8 = ContentType.create(
"text/plain", Consts.UTF_8);
private static final Pattern POST_THREAD_NO_PATTERN = Pattern.compile("<!-- thread:([0-9]+),no:([0-9]+) -->");
private static final int TIMEOUT = 10000;
private final Context context;
private Reply draft;
private FileListener fileListener;
private final Random random = new Random();
private String userAgent;
OkHttpClient client;
public ReplyManager(Context context) {
this.context = context;
draft = new Reply();
client = new OkHttpClient();
client.setConnectTimeout(TIMEOUT, TimeUnit.MILLISECONDS);
client.setReadTimeout(TIMEOUT, TimeUnit.MILLISECONDS);
client.setWriteTimeout(TIMEOUT, TimeUnit.MILLISECONDS);
// User agent is <appname>/<version>
String version = "";
try {
version = context.getPackageManager().getPackageInfo(context.getPackageName(), 0).versionName;
} catch (PackageManager.NameNotFoundException e) {
e.printStackTrace();
}
version = version.toLowerCase(Locale.ENGLISH).replace(" ", "_");
userAgent = context.getString(R.string.app_name) + "/" + version;
}
public String getUserAgent() {
return userAgent;
}
/**
@ -180,75 +184,80 @@ public class ReplyManager {
public abstract void onFileLoading();
}
public void sendPass(Pass pass, final PassListener listener) {
Logger.i(TAG, "Sending pass login request");
HttpPost httpPost = new HttpPost(ChanUrls.getPassUrl());
public void postPass(String token, String pin, final PassListener passListener) {
FormEncodingBuilder formBuilder = new FormEncodingBuilder();
MultipartEntityBuilder entity = MultipartEntityBuilder.create();
formBuilder.add("act", "do_login");
entity.addTextBody("act", "do_login");
formBuilder.add("id", token);
formBuilder.add("pin", pin);
entity.addTextBody("id", pass.token);
entity.addTextBody("pin", pass.pin);
Request.Builder request = new Request.Builder()
.url(ChanUrls.getPassUrl())
.post(formBuilder.build());
// entity.addPart("pwd", new StringBody(reply.password));
httpPost.setEntity(entity.build());
sendHttpPost(httpPost, new HttpPostSendListener() {
makeOkHttpCall(request, new Callback() {
@Override
public void onResponse(String responseString, HttpClient client, HttpResponse response) {
PassResponse e = new PassResponse();
public void onFailure(Request request, IOException e) {
final PassResponse res = new PassResponse();
res.isError = true;
res.message = context.getString(R.string.pass_error);
runUI(new Runnable() {
public void run() {
passListener.onResponse(res);
}
});
}
if (responseString == null || response == null) {
e.isError = true;
e.message = context.getString(R.string.pass_error);
} else {
e.responseData = responseString;
if (responseString.contains("Your device is now authorized")) {
e.message = "Success! Your device is now authorized.";
String passId = null;
Header[] cookieHeaders = response.getHeaders("Set-Cookie");
if (cookieHeaders != null) {
for (Header cookieHeader : cookieHeaders) {
HeaderElement[] elements = cookieHeader.getElements();
if (elements != null) {
for (HeaderElement el : elements) {
if (el != null) {
if (el.getName().equals("pass_id")) {
passId = el.getValue();
}
}
}
@Override
public void onResponse(Response response) throws IOException {
if (!response.isSuccessful()) {
onFailure(response.request(), null);
return;
}
String responseString = response.body().string();
response.body().close();
final PassResponse res = new PassResponse();
if (responseString.contains("Your device is now authorized")) {
List<String> cookies = response.headers("Set-Cookie");
String passId = null;
for (String cookie : cookies) {
try {
List<HttpCookie> parsedList = HttpCookie.parse(cookie);
for (HttpCookie parsed : parsedList) {
if (parsed.getName().equals("pass_id") && !parsed.getValue().equals("0")) {
passId = parsed.getValue();
}
}
} catch (IllegalArgumentException ignored) {
}
if (passId != null) {
e.passId = passId;
} else {
e.isError = true;
e.message = "Could not get pass id";
}
}
if (passId != null) {
res.passId = passId;
res.message = "Success! Your device is now authorized.";
} else {
e.isError = true;
if (responseString.contains("Your Token must be exactly 10 characters")) {
e.message = "Incorrect token";
} else if (responseString.contains("You have left one or more fields blank")) {
e.message = "You have left one or more fields blank";
} else if (responseString.contains("Incorrect Token or PIN")) {
e.message = "Incorrect Token or PIN";
} else {
e.unknownError = true;
}
res.isError = true;
res.message = "Could not get pass id";
}
} else {
res.isError = true;
if (responseString.contains("Your Token must be exactly 10 characters")) {
res.message = "Incorrect token";
} else if (responseString.contains("You have left one or more fields blank")) {
res.message = "You have left one or more fields blank";
} else if (responseString.contains("Incorrect Token or PIN")) {
res.message = "Incorrect Token or PIN";
} else {
res.unknownError = true;
}
}
listener.onResponse(e);
runUI(new Runnable() {
public void run() {
passListener.onResponse(res);
}
});
}
});
}
@ -265,58 +274,68 @@ public class ReplyManager {
public String passId;
}
public void sendDelete(final SavedReply reply, boolean onlyImageDelete, final DeleteListener listener) {
Logger.i(TAG, "Sending delete request: " + reply.board + ", " + reply.no);
HttpPost httpPost = new HttpPost(ChanUrls.getDeleteUrl(reply.board));
MultipartEntityBuilder entity = MultipartEntityBuilder.create();
entity.addTextBody(Integer.toString(reply.no), "delete");
public void postDelete(final SavedReply reply, boolean onlyImageDelete, final DeleteListener listener) {
FormEncodingBuilder formBuilder = new FormEncodingBuilder();
formBuilder.add(Integer.toString(reply.no), "delete");
if (onlyImageDelete) {
entity.addTextBody("onlyimgdel", "on");
formBuilder.add("onlyimgdel", "on");
}
formBuilder.add("mode", "usrdel");
formBuilder.add("pwd", reply.password);
// res not necessary
Request.Builder request = new Request.Builder()
.url(ChanUrls.getDeleteUrl(reply.board))
.post(formBuilder.build());
entity.addTextBody("mode", "usrdel");
entity.addTextBody("pwd", reply.password);
httpPost.setEntity(entity.build());
sendHttpPost(httpPost, new HttpPostSendListener() {
makeOkHttpCall(request, new Callback() {
@Override
public void onResponse(String responseString, HttpClient client, HttpResponse response) {
DeleteResponse e = new DeleteResponse();
if (responseString == null) {
e.isNetworkError = true;
} else {
e.responseData = responseString;
if (responseString.contains("You must wait longer before deleting this post")) {
e.isUserError = true;
e.isTooSoonError = true;
} else if (responseString.contains("Password incorrect")) {
e.isUserError = true;
e.isInvalidPassword = true;
} else if (responseString.contains("You cannot delete a post this old")) {
e.isUserError = true;
e.isTooOldError = true;
} else if (responseString.contains("Updating index")) {
e.isSuccessful = true;
public void onFailure(Request request, IOException e) {
final DeleteResponse res = new DeleteResponse();
res.isNetworkError = true;
runUI(new Runnable() {
@Override
public void run() {
listener.onResponse(res);
}
});
}
@Override
public void onResponse(Response response) throws IOException {
if (!response.isSuccessful()) {
onFailure(response.request(), null);
return;
}
String responseString = response.body().string();
response.body().close();
final DeleteResponse res = new DeleteResponse();
res.responseData = responseString;
if (responseString.contains("You must wait longer before deleting this post")) {
res.isUserError = true;
res.isTooSoonError = true;
} else if (responseString.contains("Password incorrect")) {
res.isUserError = true;
res.isInvalidPassword = true;
} else if (responseString.contains("You cannot delete a post this old")) {
res.isUserError = true;
res.isTooOldError = true;
} else if (responseString.contains("Updating index")) {
res.isSuccessful = true;
}
listener.onResponse(e);
runUI(new Runnable() {
@Override
public void run() {
listener.onResponse(res);
}
});
}
});
}
public static interface DeleteListener {
public interface DeleteListener {
public void onResponse(DeleteResponse response);
}
@ -330,244 +349,139 @@ public class ReplyManager {
public String responseData = "";
}
public void getCaptchaChallenge(final CaptchaChallengeListener listener) {
HttpPost httpPost = new HttpPost(ChanUrls.getCaptchaFallback());
httpPost.addHeader("User-Agent", "Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2272.101 Safari/537.36");
httpPost.addHeader("Referer", "https://boards.4chan.org/");
httpPost.addHeader("Cookie", "NID=67");
HttpPostSendListener postListener = new HttpPostSendListener() {
@Override
public void onResponse(String responseString, HttpClient client, HttpResponse response) {
if (responseString != null) {
Document document = Jsoup.parseBodyFragment(responseString, ChanUrls.getCaptchaDomain());
Elements images = document.select("div.fbc-challenge img");
String imageUrl = images.first() == null ? "" : images.first().absUrl("src");
Elements inputs = document.select("div.fbc-challenge input");
String challenge = "";
for (Element input : inputs) {
if (input.attr("name").equals("c")) {
challenge = input.attr("value");
break;
}
}
if (!TextUtils.isEmpty(imageUrl) && !TextUtils.isEmpty(challenge)) {
listener.onChallenge(imageUrl, challenge);
return;
}
}
listener.onError();
}
};
sendHttpPost(httpPost, postListener);
public void postReply(Reply reply, ReplyListener replyListener) {
if (reply.usePass) {
postReplyInternal(reply, replyListener, null);
} else {
postReplyInternal(reply, replyListener, reply.captchaResponse);
}
}
public interface CaptchaChallengeListener {
public void onChallenge(String imageUrl, String challenge);
private void postReplyInternal(final Reply reply, final ReplyListener replyListener, String captchaHash) {
reply.password = Long.toHexString(random.nextLong());
public void onError();
}
MultipartBuilder formBuilder = new MultipartBuilder();
formBuilder.type(MultipartBuilder.FORM);
private void getCaptchaHash(final CaptchaHashListener listener, String challenge, String response) {
HttpPost httpPost = new HttpPost(ChanUrls.getCaptchaFallback());
formBuilder.addFormDataPart("mode", "regist");
formBuilder.addFormDataPart("pwd", reply.password);
MultipartEntityBuilder entity = MultipartEntityBuilder.create();
if (reply.resto >= 0) {
formBuilder.addFormDataPart("resto", String.valueOf(reply.resto));
}
entity.addTextBody("c", challenge, TEXT_UTF_8);
entity.addTextBody("response", response, TEXT_UTF_8);
formBuilder.addFormDataPart("name", reply.name);
formBuilder.addFormDataPart("email", reply.email);
httpPost.setEntity(entity.build());
if (!TextUtils.isEmpty(reply.subject)) {
formBuilder.addFormDataPart("sub", reply.subject);
}
sendHttpPost(httpPost, new HttpPostSendListener() {
@Override
public void onResponse(String responseString, HttpClient client, HttpResponse response) {
if (responseString != null) {
Document document = Jsoup.parseBodyFragment(responseString);
Elements verificationToken = document.select("div.fbc-verification-token textarea");
String hash = verificationToken.text();
if (hash.length() > 0) {
listener.onHash(hash);
return;
}
}
listener.onHash(null);
}
});
}
formBuilder.addFormDataPart("com", reply.comment);
private interface CaptchaHashListener {
public void onHash(String hash);
}
public String getUserAgent() {
String version = "";
try {
version = context.getPackageManager().getPackageInfo(context.getPackageName(), 0).versionName;
} catch (PackageManager.NameNotFoundException e) {
e.printStackTrace();
if (captchaHash != null) {
formBuilder.addFormDataPart("g-recaptcha-response", captchaHash);
}
version = version.toLowerCase(Locale.ENGLISH).replace(" ", "_");
return "Clover/" + version;
}
public void postReply(Reply reply) {
OkHttpClient client = new OkHttpClient();
MultipartBuilder formBuilder = new MultipartBuilder();
formBuilder.type(MultipartBuilder.FORM);
formBuilder.addFormDataPart("foo", "bar");
if (reply.file != null) {
formBuilder.addFormDataPart("file", "filename", RequestBody.create(
formBuilder.addFormDataPart("upfile", reply.fileName, RequestBody.create(
MediaType.parse("application/octet-stream"), reply.file
));
}
RequestBody form = formBuilder.build();
Request request = new Request.Builder()
.header("User-Agent", getUserAgent())
.url("")
.post(form)
.build();
if (reply.spoilerImage) {
formBuilder.addFormDataPart("spoiler", "on");
}
Request.Builder request = new Request.Builder()
.url(ChanUrls.getReplyUrl(reply.board))
.post(formBuilder.build());
if (reply.usePass) {
request.addHeader("Cookie", "pass_id=" + reply.passId);
}
client.newCall(request).enqueue(new Callback() {
makeOkHttpCall(request, new Callback() {
@Override
public void onFailure(Request request, IOException e) {
e.printStackTrace();
final ReplyResponse res = new ReplyResponse();
res.isNetworkError = true;
runUI(new Runnable() {
public void run() {
replyListener.onResponse(res);
}
});
}
@Override
public void onResponse(Response response) throws IOException {
final ReplyResponse res = new ReplyResponse();
if (response.isSuccessful()) {
Logger.test("Output = " + response.body().string());
onReplyPosted(response.body().string(), reply, res);
response.body().close();
} else {
res.isNetworkError = true;
}
runUI(new Runnable() {
public void run() {
replyListener.onResponse(res);
}
});
}
});
}
/**
* Send an reply off to the server.
*
* @param reply The reply object with all data needed, like captcha and the
* file.
* @param listener The listener, after server response.
*/
public void sendReply(final Reply reply, final ReplyListener listener) {
Logger.i(TAG, "Sending reply request: " + reply.board + ", " + reply.resto);
CaptchaHashListener captchaHashListener = new CaptchaHashListener() {
@Override
public void onHash(String captchaHash) {
if (captchaHash == null && !reply.usePass) {
// Could not find a hash in the response html
ReplyResponse e = new ReplyResponse();
e.isUserError = true;
e.isCaptchaError = true;
listener.onResponse(e);
return;
}
HttpPost httpPost = new HttpPost(ChanUrls.getReplyUrl(reply.board));
MultipartEntityBuilder entity = MultipartEntityBuilder.create();
reply.password = Long.toHexString(random.nextLong());
entity.addTextBody("name", reply.name, TEXT_UTF_8);
entity.addTextBody("email", reply.email, TEXT_UTF_8);
entity.addTextBody("sub", reply.subject, TEXT_UTF_8);
entity.addTextBody("com", reply.comment, TEXT_UTF_8);
if (reply.resto >= 0) {
entity.addTextBody("resto", Integer.toString(reply.resto));
}
if (reply.spoilerImage) {
entity.addTextBody("spoiler", "on");
}
if (!reply.usePass) {
entity.addTextBody("g-recaptcha-response", captchaHash, TEXT_UTF_8);
}
entity.addTextBody("mode", "regist");
entity.addTextBody("pwd", reply.password);
if (reply.usePass) {
httpPost.addHeader("Cookie", "pass_id=" + reply.passId);
}
if (reply.file != null) {
entity.addBinaryBody("upfile", reply.file, ContentType.APPLICATION_OCTET_STREAM, reply.fileName);
private ReplyResponse onReplyPosted(String responseString, Reply reply, ReplyResponse res) {
res.responseData = responseString;
if (res.responseData.contains("No file selected")) {
res.isUserError = true;
res.isFileError = true;
} else if (res.responseData.contains("You forgot to solve the CAPTCHA") || res.responseData.contains("You seem to have mistyped the CAPTCHA")) {
res.isUserError = true;
res.isCaptchaError = true;
} else if (res.responseData.toLowerCase(Locale.ENGLISH).contains("post successful")) {
res.isSuccessful = true;
Matcher matcher = POST_THREAD_NO_PATTERN.matcher(res.responseData);
int threadNo = -1;
int no = -1;
if (matcher.find()) {
try {
threadNo = Integer.parseInt(matcher.group(1));
no = Integer.parseInt(matcher.group(2));
} catch (NumberFormatException err) {
err.printStackTrace();
}
}
httpPost.setEntity(entity.build());
sendHttpPost(httpPost, new HttpPostSendListener() {
@Override
public void onResponse(String responseString, HttpClient client, HttpResponse response) {
ReplyResponse e = new ReplyResponse();
if (responseString == null) {
e.isNetworkError = true;
} else {
e.responseData = responseString;
if (responseString.contains("No file selected")) {
e.isUserError = true;
e.isFileError = true;
} else if (responseString.contains("You forgot to solve the CAPTCHA")
|| responseString.contains("You seem to have mistyped the CAPTCHA")) {
e.isUserError = true;
e.isCaptchaError = true;
} else if (responseString.toLowerCase(Locale.ENGLISH).contains("post successful")) {
e.isSuccessful = true;
}
}
if (e.isSuccessful) {
Matcher matcher = responsePattern.matcher(e.responseData);
int threadNo = -1;
int no = -1;
if (matcher.find()) {
try {
threadNo = Integer.parseInt(matcher.group(1));
no = Integer.parseInt(matcher.group(2));
} catch (NumberFormatException err) {
err.printStackTrace();
}
}
if (threadNo >= 0 && no >= 0) {
SavedReply savedReply = new SavedReply();
savedReply.board = reply.board;
savedReply.no = no;
savedReply.password = reply.password;
ChanApplication.getDatabaseManager().saveReply(savedReply);
if (threadNo >= 0 && no >= 0) {
SavedReply savedReply = new SavedReply();
savedReply.board = reply.board;
savedReply.no = no;
savedReply.password = reply.password;
e.threadNo = threadNo;
e.no = no;
} else {
Logger.w(TAG, "No thread & no in the response");
}
}
ChanApplication.getDatabaseManager().saveReply(savedReply);
listener.onResponse(e);
}
});
res.threadNo = threadNo;
res.no = no;
} else {
Logger.w(TAG, "No thread & no in the response");
}
};
if (reply.usePass) {
captchaHashListener.onHash(null);
} else {
getCaptchaHash(captchaHashListener, reply.captchaChallenge, reply.captchaResponse);
}
return res;
}
private void makeOkHttpCall(Request.Builder requestBuilder, Callback callback) {
requestBuilder.header("User-Agent", getUserAgent());
Request request = requestBuilder.build();
client.newCall(request).enqueue(callback);
}
private void runUI(Runnable runnable) {
Utils.runOnUiThread(runnable);
}
public static interface ReplyListener {
@ -616,55 +530,4 @@ public class ReplyManager {
*/
public int threadNo = -1;
}
/**
* Async task to send an reply to the server. Uses HttpClient. Since Android
* 4.4 there is an updated version of HttpClient, 4.2, given with Android.
* However, that version causes problems with file uploading. Version 4.3 of
* HttpClient has been given with a library, that has another namespace:
* ch.boye.httpclientandroidlib This lib also has some fixes/improvements of
* HttpClient for Android.
*/
private void sendHttpPost(final HttpPost post, final HttpPostSendListener listener) {
new Thread(new Runnable() {
@Override
public void run() {
RequestConfig.Builder requestBuilder = RequestConfig.custom();
requestBuilder = requestBuilder.setConnectTimeout(POST_TIMEOUT);
requestBuilder = requestBuilder.setConnectionRequestTimeout(POST_TIMEOUT);
HttpClientBuilder httpBuilder = HttpClientBuilder.create();
httpBuilder.setDefaultRequestConfig(requestBuilder.build());
final CloseableHttpClient client = httpBuilder.build();
try {
final CloseableHttpResponse response = client.execute(post);
final String responseString = EntityUtils.toString(response.getEntity(), "UTF-8");
Utils.runOnUiThread(new Runnable() {
@Override
public void run() {
listener.onResponse(responseString, client, response);
}
});
} catch (IOException e) {
e.printStackTrace();
Utils.runOnUiThread(new Runnable() {
@Override
public void run() {
listener.onResponse(null, client, null);
}
});
} finally {
try {
client.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}).start();
}
private static interface HttpPostSendListener {
public void onResponse(String responseString, HttpClient client, HttpResponse response);
}
}

@ -547,7 +547,7 @@ public class ThreadManager implements Loader.LoaderListener {
final ProgressDialog dialog = ProgressDialog.show(activity, null, activity.getString(R.string.delete_wait));
ChanApplication.getReplyManager().sendDelete(reply, onlyImageDelete, new DeleteListener() {
ChanApplication.getReplyManager().postDelete(reply, onlyImageDelete, new DeleteListener() {
@Override
public void onResponse(DeleteResponse response) {
dialog.dismiss();

@ -1,28 +0,0 @@
/*
* Clover - 4chan browser https://github.com/Floens/Clover/
* Copyright (C) 2014 Floens
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.floens.chan.core.model;
public class Pass {
public String token = "";
public String pin = "";
public Pass(String token, String pin) {
this.token = token;
this.pin = pin;
}
}

@ -31,7 +31,6 @@ public class Reply {
public int resto = 0;
public File file;
public String fileName = "";
public String captchaChallenge = "";
public String captchaResponse = "";
public String password = "";
public boolean usePass = false;

@ -42,7 +42,6 @@ import org.floens.chan.R;
import org.floens.chan.core.ChanPreferences;
import org.floens.chan.core.manager.ReplyManager;
import org.floens.chan.core.manager.ReplyManager.PassResponse;
import org.floens.chan.core.model.Pass;
import org.floens.chan.utils.ThemeHelper;
import org.floens.chan.utils.Utils;
@ -145,8 +144,7 @@ public class PassSettingsActivity extends Activity implements OnCheckedChangeLis
@Override
public boolean onPreferenceClick(Preference preference) {
if (PassSettingsActivity.instance != null) {
Pass pass = new Pass(ChanPreferences.getPassToken(), ChanPreferences.getPassPin());
onLoginClick(pass);
onLoginClick(ChanPreferences.getPassToken(), ChanPreferences.getPassPin());
}
return true;
}
@ -159,12 +157,12 @@ public class PassSettingsActivity extends Activity implements OnCheckedChangeLis
findPreference("preference_pass_login").setTitle(TextUtils.isEmpty(ChanPreferences.getPassId()) ? R.string.pass_login : R.string.pass_logout);
}
private void onLoginClick(Pass pass) {
private void onLoginClick(String token, String pin) {
if (TextUtils.isEmpty(ChanPreferences.getPassId())) {
// Login
final ProgressDialog dialog = ProgressDialog.show(getActivity(), null, "Logging in");
ChanApplication.getReplyManager().sendPass(pass, new ReplyManager.PassListener() {
ChanApplication.getReplyManager().postPass(token, pin, new ReplyManager.PassListener() {
@Override
public void onResponse(PassResponse response) {
dialog.dismiss();

@ -44,10 +44,9 @@ import android.widget.TextView;
import android.widget.Toast;
import android.widget.ViewFlipper;
import com.android.volley.toolbox.NetworkImageView;
import org.floens.chan.ChanApplication;
import org.floens.chan.R;
import org.floens.chan.chan.ChanUrls;
import org.floens.chan.core.ChanPreferences;
import org.floens.chan.core.manager.ReplyManager;
import org.floens.chan.core.manager.ReplyManager.ReplyResponse;
@ -55,6 +54,7 @@ import org.floens.chan.core.model.Board;
import org.floens.chan.core.model.Loadable;
import org.floens.chan.core.model.Reply;
import org.floens.chan.ui.ViewFlipperAnimations;
import org.floens.chan.ui.layout.CaptchaLayout;
import org.floens.chan.ui.view.LoadView;
import org.floens.chan.utils.ImageDecoder;
import org.floens.chan.utils.Logger;
@ -63,7 +63,7 @@ import org.floens.chan.utils.Utils;
import java.io.File;
public class ReplyFragment extends DialogFragment {
public class ReplyFragment extends DialogFragment implements CaptchaLayout.CaptchaCallback {
private static final String TAG = "ReplyFragment";
private int page = 0;
@ -74,9 +74,7 @@ public class ReplyFragment extends DialogFragment {
private final Reply draft = new Reply();
private boolean shouldSaveDraft = true;
private boolean gotInitialCaptcha = false;
private boolean gettingCaptcha = false;
private String captchaChallenge = "";
private String captchaResponse;
private int defaultTextColor;
private int maxCommentCount;
@ -94,8 +92,7 @@ public class ReplyFragment extends DialogFragment {
private EditText fileNameView;
private CheckBox spoilerImageView;
private LoadView imageViewContainer;
private LoadView captchaContainer;
private TextView captchaInput;
private CaptchaLayout captchaLayout;
private LoadView responseContainer;
private Button insertSpoiler;
private Button insertCode;
@ -204,6 +201,9 @@ public class ReplyFragment extends DialogFragment {
}
});
showCommentCount();
String baseUrl = loadable.isThreadMode() ? ChanUrls.getThreadUrlDesktop(loadable.board, loadable.no) : ChanUrls.getBoardUrlDesktop(loadable.board);
captchaLayout.initCaptcha(baseUrl, ChanUrls.getCaptchaSiteKey(), ThemeHelper.getInstance().getTheme().isLightTheme, this);
} else {
Logger.e(TAG, "Loadable in ReplyFragment was null");
closeReply();
@ -258,14 +258,7 @@ public class ReplyFragment extends DialogFragment {
imageViewContainer = (LoadView) container.findViewById(R.id.reply_image);
responseContainer = (LoadView) container.findViewById(R.id.reply_response);
captchaContainer = (LoadView) container.findViewById(R.id.reply_captcha_container);
captchaContainer.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View view) {
getCaptcha();
}
});
captchaInput = (TextView) container.findViewById(R.id.reply_captcha);
captchaLayout = (CaptchaLayout) container.findViewById(R.id.captcha_layout);
cancelButton = (Button) container.findViewById(R.id.reply_cancel);
cancelButton.setOnClickListener(new OnClickListener() {
@ -347,6 +340,19 @@ public class ReplyFragment extends DialogFragment {
}
}
@Override
public void captchaLoaded(CaptchaLayout captchaLayout) {
}
@Override
public void captchaEntered(CaptchaLayout captchaLayout, String response) {
captchaResponse = response;
if (page == 1) {
flipPage(2);
submit();
}
}
private void insertAtCursor(String before, String after) {
int pos = commentView.getSelectionStart();
String text = commentView.getText().toString();
@ -412,9 +418,11 @@ public class ReplyFragment extends DialogFragment {
cancelButton.setText(R.string.close);
}
if (page == 1 && !gotInitialCaptcha) {
gotInitialCaptcha = true;
getCaptcha();
if (page == 1) {
captchaLayout.load();
submitButton.setEnabled(captchaResponse != null);
} else if (page == 0) {
submitButton.setEnabled(true);
}
}
@ -511,42 +519,6 @@ public class ReplyFragment extends DialogFragment {
loadView.setView(text);
}
private void getCaptcha() {
if (gettingCaptcha)
return;
gettingCaptcha = true;
captchaContainer.setView(null);
captchaInput.setText("");
ChanApplication.getReplyManager().getCaptchaChallenge(new ReplyManager.CaptchaChallengeListener() {
@Override
public void onChallenge(String imageUrl, String challenge) {
gettingCaptcha = false;
if (context != null) {
captchaChallenge = challenge;
NetworkImageView captchaImage = new NetworkImageView(context);
captchaImage.setImageUrl(imageUrl, ChanApplication.getVolleyImageLoader());
captchaContainer.setView(captchaImage);
}
}
@Override
public void onError() {
gettingCaptcha = false;
if (context != null) {
TextView text = new TextView(context);
text.setGravity(Gravity.CENTER);
text.setText(R.string.reply_captcha_load_error);
captchaContainer.setView(text);
}
}
});
}
/**
* Submit button clicked at page 1
*/
@ -561,8 +533,7 @@ public class ReplyFragment extends DialogFragment {
draft.email = emailView.getText().toString();
draft.subject = subjectView.getText().toString();
draft.comment = commentView.getText().toString();
draft.captchaChallenge = captchaChallenge;
draft.captchaResponse = captchaInput.getText().toString();
draft.captchaResponse = captchaResponse;
draft.fileName = "image";
String n = fileNameView.getText().toString();
@ -581,13 +552,12 @@ public class ReplyFragment extends DialogFragment {
Board b = ChanApplication.getBoardManager().getBoardByValue(loadable.board);
draft.spoilerImage = b != null && b.spoilers && spoilerImageView.isChecked();
/*ChanApplication.getReplyManager().sendReply(draft, new ReplyManager.ReplyListener() {
ChanApplication.getReplyManager().postReply(draft, new ReplyManager.ReplyListener() {
@Override
public void onResponse(ReplyResponse response) {
handleSubmitResponse(response);
}
});*/
ChanApplication.getReplyManager().postReply(draft);
});
}
/**
@ -606,13 +576,13 @@ public class ReplyFragment extends DialogFragment {
submitButton.setEnabled(true);
cancelButton.setEnabled(true);
setClosable(true);
captchaResponse = null;
captchaLayout.reset();
if (ChanPreferences.getPassEnabled()) {
flipPage(0);
} else {
flipPage(1);
}
getCaptcha();
captchaInput.setText("");
} else if (response.isSuccessful) {
shouldSaveDraft = false;
Toast.makeText(context, R.string.reply_success, Toast.LENGTH_SHORT).show();

@ -0,0 +1,130 @@
/*
* Clover - 4chan browser https://github.com/Floens/Clover/
* Copyright (C) 2014 Floens
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.floens.chan.ui.layout;
import android.annotation.SuppressLint;
import android.content.Context;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.webkit.JavascriptInterface;
import android.webkit.WebSettings;
import android.webkit.WebView;
import org.floens.chan.ChanApplication;
import org.floens.chan.utils.IOUtils;
import org.floens.chan.utils.Utils;
public class CaptchaLayout extends WebView {
private CaptchaCallback callback;
private boolean loaded = false;
private String baseUrl;
private String siteKey;
private boolean lightTheme;
public CaptchaLayout(Context context) {
super(context);
}
public CaptchaLayout(Context context, AttributeSet attrs) {
super(context, attrs);
}
public CaptchaLayout(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
}
@SuppressLint("SetJavaScriptEnabled")
public void initCaptcha(String baseUrl, String siteKey, boolean lightTheme, CaptchaCallback callback) {
this.callback = callback;
this.baseUrl = baseUrl;
this.siteKey = siteKey;
this.lightTheme = lightTheme;
WebSettings settings = getSettings();
settings.setJavaScriptEnabled(true);
settings.setUserAgentString(ChanApplication.getReplyManager().getUserAgent());
addJavascriptInterface(new CaptchaInterface(this), "CaptchaCallback");
}
public void load() {
if (!loaded) {
loaded = true;
String html = IOUtils.assetAsString(getContext(), "captcha/captcha.html");
html = html.replace("__site_key__", siteKey);
html = html.replace("__theme__", lightTheme ? "light" : "dark");
loadDataWithBaseURL(baseUrl, html, "text/html", "UTF-8", null);
}
}
public void reset() {
if (loaded) {
loadUrl("javascript:grecaptcha.reset()");
} else {
load();
}
}
private void onCaptchaLoaded() {
callback.captchaLoaded(this);
}
private void onCaptchaEntered(String response) {
if (TextUtils.isEmpty(response)) {
reset();
} else {
callback.captchaEntered(this, response);
}
}
public interface CaptchaCallback {
public void captchaLoaded(CaptchaLayout captchaLayout);
public void captchaEntered(CaptchaLayout captchaLayout, String response);
}
public static class CaptchaInterface {
private final CaptchaLayout layout;
public CaptchaInterface(CaptchaLayout layout) {
this.layout = layout;
}
@JavascriptInterface
public void onCaptchaLoaded() {
Utils.runOnUiThread(new Runnable() {
@Override
public void run() {
layout.onCaptchaLoaded();
}
});
}
@JavascriptInterface
public void onCaptchaEntered(final String response) {
Utils.runOnUiThread(new Runnable() {
@Override
public void run() {
layout.onCaptchaEntered(response);
}
});
}
}
}

@ -9,6 +9,8 @@ import com.squareup.okhttp.Response;
import com.squareup.okhttp.ResponseBody;
import com.squareup.okhttp.internal.Util;
import org.floens.chan.ChanApplication;
import java.io.BufferedOutputStream;
import java.io.Closeable;
import java.io.File;
@ -25,8 +27,8 @@ public class FileCache {
private static final String TAG = "FileCache";
private static final ExecutorService executor = Executors.newFixedThreadPool(2);
private OkHttpClient httpClient;
private static String userAgent;
private final File directory;
private final long maxSize;
@ -38,6 +40,7 @@ public class FileCache {
this.maxSize = maxSize;
httpClient = new OkHttpClient();
userAgent = ChanApplication.getReplyManager().getUserAgent();
makeDir();
calculateSize();
@ -271,7 +274,10 @@ public class FileCache {
}
private void execute() throws Exception {
Request request = new Request.Builder().url(url).build();
Request request = new Request.Builder()
.url(url)
.header("User-Agent", FileCache.userAgent)
.build();
call = fileCache.httpClient.newCall(request);
Response response = call.execute();

@ -17,6 +17,9 @@
*/
package org.floens.chan.utils;
import android.content.Context;
import java.io.Closeable;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
@ -26,20 +29,40 @@ import java.io.StringWriter;
import java.io.Writer;
public class IOUtils {
public static String assetAsString(Context context, String assetName) {
String res = null;
try {
res = IOUtils.readString(context.getResources().getAssets().open(assetName));
} catch (IOException ignored) {
}
return res;
}
public static String readString(InputStream is) {
StringWriter sw = new StringWriter();
Reader sr = new InputStreamReader(is);
Writer sw = new StringWriter();
try {
copy(new InputStreamReader(is), sw);
is.close();
sw.close();
copy(sr, sw);
} catch (IOException e) {
e.printStackTrace();
} finally {
IOUtils.closeQuietly(sr);
IOUtils.closeQuietly(sw);
}
return sw.toString();
}
public static void closeQuietly(Closeable closeable) {
if (closeable != null) {
try {
closeable.close();
} catch (IOException ignored) {
}
}
}
/**
* Copies the inputstream to the outputstream and closes both streams.
*

@ -1,48 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!--
Clover - 4chan browser https://github.com/Floens/Clover/
Copyright (C) 2014 Floens
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/reply_captcha_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingBottom="16dp"
android:text="@string/reply_captcha_tap_to_reload"
android:textSize="14sp" />
<org.floens.chan.ui.view.LoadView
android:id="@+id/reply_captcha_container"
android:layout_width="match_parent"
android:layout_height="100dp"
android:layout_gravity="center" />
<EditText
android:id="@+id/reply_captcha"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/reply_captcha"
android:imeOptions="flagNoFullscreen"
android:inputType="textNoSuggestions|textVisiblePassword"
android:minHeight="40dp"
android:textSize="14sp" />
</LinearLayout>

@ -44,12 +44,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- captcha view: -->
<ScrollView
<org.floens.chan.ui.layout.CaptchaLayout
android:id="@+id/captcha_layout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<include layout="@layout/reply_captcha" />
</ScrollView>
android:layout_height="match_parent" />
<!-- response view: -->

@ -133,7 +133,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<string name="reply_submit">Submit</string>
<string name="reply_captcha">Enter the text</string>
<string name="reply_error">Error sending reply</string>
<string name="reply_error_captcha">Wrong captcha</string>
<string name="reply_error_captcha">Invalid captcha</string>
<string name="reply_error_file">No file selected</string>
<string name="reply_success">Post Successful</string>
<string name="reply_captcha_load_error">Failed to load captcha</string>

Loading…
Cancel
Save