Merge branch 'dev' into multisite

# Conflicts:
#	Clover/app/build.gradle
#	Clover/app/src/main/java/org/floens/chan/chan/ChanParser.java
#	Clover/app/src/main/java/org/floens/chan/chan/ChanUrls.java
#	Clover/app/src/main/java/org/floens/chan/core/model/Post.java
#	Clover/app/src/main/java/org/floens/chan/core/presenter/ReplyPresenter.java
#	Clover/app/src/main/java/org/floens/chan/core/saver/ImageSaveTask.java
#	Clover/app/src/main/java/org/floens/chan/core/settings/ChanSettings.java
#	Clover/app/src/main/java/org/floens/chan/test/TestActivity.java
#	Clover/app/src/main/java/org/floens/chan/ui/cell/PostCell.java
#	Clover/app/src/main/java/org/floens/chan/ui/layout/ThreadListLayout.java
#	Clover/build.gradle
multisite
Floens 8 years ago
commit f337483a02
  1. 4
      .travis.yml
  2. 14
      CHANGES.txt
  3. 78
      Clover/app/build.gradle
  4. 2
      Clover/app/src/main/AndroidManifest.xml
  5. 28
      Clover/app/src/main/assets/html/licenses.html
  6. 42
      Clover/app/src/main/java/org/floens/chan/chan/ChanParser.java
  7. 28
      Clover/app/src/main/java/org/floens/chan/chan/ImageSearch.java
  8. 122
      Clover/app/src/main/java/org/floens/chan/core/cache/FileCache.java
  9. 9
      Clover/app/src/main/java/org/floens/chan/core/di/ChanGraph.java
  10. 46
      Clover/app/src/main/java/org/floens/chan/core/model/FileItem.java
  11. 34
      Clover/app/src/main/java/org/floens/chan/core/model/FileItems.java
  12. 162
      Clover/app/src/main/java/org/floens/chan/core/net/UpdateApiRequest.java
  13. 2
      Clover/app/src/main/java/org/floens/chan/core/presenter/ReplyPresenter.java
  14. 2
      Clover/app/src/main/java/org/floens/chan/core/presenter/ThreadPresenter.java
  15. 46
      Clover/app/src/main/java/org/floens/chan/core/saver/FileWatcher.java
  16. 16
      Clover/app/src/main/java/org/floens/chan/core/saver/ImageSaveTask.java
  17. 9
      Clover/app/src/main/java/org/floens/chan/core/settings/ChanSettings.java
  18. 49
      Clover/app/src/main/java/org/floens/chan/core/settings/LongSetting.java
  19. 5
      Clover/app/src/main/java/org/floens/chan/core/site/sites/chan4/Chan4.java
  20. 11
      Clover/app/src/main/java/org/floens/chan/core/site/sites/chan4/Chan4ReaderRequest.java
  21. 249
      Clover/app/src/main/java/org/floens/chan/core/update/UpdateManager.java
  22. 303
      Clover/app/src/main/java/org/floens/chan/test/TestActivity.java
  23. 11
      Clover/app/src/main/java/org/floens/chan/ui/activity/StartActivity.java
  24. 23
      Clover/app/src/main/java/org/floens/chan/ui/adapter/FilesAdapter.java
  25. 17
      Clover/app/src/main/java/org/floens/chan/ui/cell/PostCell.java
  26. 11
      Clover/app/src/main/java/org/floens/chan/ui/controller/DeveloperSettingsController.java
  27. 107
      Clover/app/src/main/java/org/floens/chan/ui/controller/LogsController.java
  28. 9
      Clover/app/src/main/java/org/floens/chan/ui/controller/MainSettingsController.java
  29. 42
      Clover/app/src/main/java/org/floens/chan/ui/controller/SaveLocationController.java
  30. 2
      Clover/app/src/main/java/org/floens/chan/ui/helper/PostHelper.java
  31. 118
      Clover/app/src/main/java/org/floens/chan/ui/helper/PreviousVersionHandler.java
  32. 36
      Clover/app/src/main/java/org/floens/chan/ui/helper/RuntimePermissionsHelper.java
  33. 286
      Clover/app/src/main/java/org/floens/chan/ui/helper/VersionHandler.java
  34. 13
      Clover/app/src/main/java/org/floens/chan/ui/layout/FilesLayout.java
  35. 8
      Clover/app/src/main/java/org/floens/chan/ui/layout/ReplyLayout.java
  36. 56
      Clover/app/src/main/java/org/floens/chan/ui/layout/ThreadListLayout.java
  37. 2
      Clover/app/src/main/java/org/floens/chan/ui/theme/ThemeHelper.java
  38. 6
      Clover/app/src/main/java/org/floens/chan/ui/view/FloatingMenu.java
  39. 25
      Clover/app/src/main/java/org/floens/chan/utils/IOUtils.java
  40. 0
      Clover/app/src/main/res/drawable-hdpi/partyhat.png
  41. 111
      Clover/app/src/main/res/values/strings.xml
  42. 73
      Clover/app/src/main/res/values/styles.xml
  43. 2
      Clover/build.gradle
  44. 4
      Clover/gradle/wrapper/gradle-wrapper.properties
  45. 0
      docs/4chan/4chanresponses.txt
  46. 0
      docs/4chan/Intervals.txt
  47. 0
      docs/4chan/boardsjson.txt
  48. 0
      docs/4chan/delform.txt
  49. 0
      docs/4chan/gcaptcha.txt
  50. 0
      docs/4chan/pass.txt
  51. 46
      docs/checkcaptcha.txt
  52. 0
      docs/file_notice.txt
  53. 22
      docs/update_api.json
  54. 29
      docs/update_api.txt

@ -7,7 +7,7 @@ android:
- platform-tools - platform-tools
- tools - tools
- extra-android-m2repository - extra-android-m2repository
- build-tools-24.0.3 - build-tools-25.0.2
- android-24 - android-25
script: cd Clover && ./gradlew build --console plain -x lint script: cd Clover && ./gradlew build --console plain -x lint

@ -1,3 +1,17 @@
New in 2.3.1 (2017-07-15)
- Better link detection.
- More themes.
- Fix image loading taking a long time if the cache is full.
- Fix crashing bug related to the status view.
- Bug fixes.
New in 2.3.0 (2017-03-19)
- Added update checker to notify of new releases.
- Force new captcha for thread making.
- Bug fixes.
New in 2.2.0 (2016-10-03) New in 2.2.0 (2016-10-03)
- Add sliding of threads back. - Add sliding of threads back.
- Rewrote thread watching, it's more stable and works correctly with doze now. - Rewrote thread watching, it's more stable and works correctly with doze now.

@ -1,17 +1,30 @@
apply plugin: 'com.android.application' apply plugin: 'com.android.application'
/**
* Gets the version name from the latest Git tag
*/
def getCommitHash = { ->
def stdout = new ByteArrayOutputStream()
exec {
commandLine 'git', 'rev-parse', '--short', 'HEAD'
standardOutput = stdout
}
return "-" + stdout.toString().trim()
}
android { android {
compileSdkVersion 24 compileSdkVersion 25
// update the travis config when changing this // update the travis config when changing this
buildToolsVersion '24.0.3' buildToolsVersion '25.0.2'
defaultConfig { defaultConfig {
applicationId "org.floens.chan"
minSdkVersion 15 minSdkVersion 15
targetSdkVersion 24 targetSdkVersion 25
versionName "v2.2.0" versionName "v2.3.1"
versionCode 56 // of the format XXYYZZ, where XX is major, YY is minor, ZZ is patch
// (watch out for octal notation, never start with a 0)
versionCode 20301
} }
compileOptions { compileOptions {
@ -54,6 +67,32 @@ android {
is.close() is.close()
} }
defaultPublishConfig "default"
productFlavors {
// The app name refers to the name as displayed on the launcher.
// the flavor name is appended to the name in the settings.
"default" {
applicationId "org.floens.chan"
resValue "string", "app_name", "Clover"
resValue "string", "app_flavor_name", ""
buildConfigField "String", "UPDATE_API_ENDPOINT", "\"https://floens.github.io/Clover/api/update\""
}
dev {
applicationId "org.floens.chan.dev"
resValue "string", "app_name", "Clover dev"
resValue "string", "app_flavor_name", ""
buildConfigField "String", "UPDATE_API_ENDPOINT", "\"\""
}
fdroid {
applicationId "org.floens.chan"
resValue "string", "app_name", "Clover"
resValue "string", "app_flavor_name", "F-Droid"
buildConfigField "String", "UPDATE_API_ENDPOINT", "\"https://floens.github.io/Clover/api/update\""
}
}
buildTypes { buildTypes {
release { release {
if (doSign) { if (doSign) {
@ -64,33 +103,25 @@ android {
} }
debug { debug {
versionNameSuffix " Debug" versionNameSuffix getCommitHash()
// minifyEnabled true // minifyEnabled true
// proguardFiles 'proguard.cfg' // proguardFiles 'proguard.cfg'
} }
} }
productFlavors {
normal {
applicationId = "org.floens.chan"
resValue "string", "app_name", "Clover"
resValue "string", "app_flavor_name", ""
}
}
sourceSets { sourceSets {
beta.java.srcDirs = ['src/release/java'] beta.java.srcDirs = ['src/release/java']
} }
} }
dependencies { dependencies {
compile 'com.android.support:support-v13:24.2.1' compile 'com.android.support:support-v13:25.3.1'
compile 'com.android.support:appcompat-v7:24.2.1' compile 'com.android.support:appcompat-v7:25.3.1'
compile 'com.android.support:recyclerview-v7:24.2.1' compile 'com.android.support:recyclerview-v7:25.3.1'
compile 'com.android.support:cardview-v7:24.2.1' compile 'com.android.support:cardview-v7:25.3.1'
compile 'com.android.support:support-annotations:24.2.1' compile 'com.android.support:support-annotations:25.3.1'
compile 'com.android.support:design:24.2.1' compile 'com.android.support:design:25.3.1'
compile 'com.android.support:customtabs:24.2.1' compile 'com.android.support:customtabs:25.3.1'
compile 'org.jsoup:jsoup:1.9.2' compile 'org.jsoup:jsoup:1.9.2'
compile 'com.j256.ormlite:ormlite-core:4.48' compile 'com.j256.ormlite:ormlite-core:4.48'
@ -99,6 +130,9 @@ dependencies {
compile 'com.davemorrissey.labs:subsampling-scale-image-view:3.5.0' compile 'com.davemorrissey.labs:subsampling-scale-image-view:3.5.0'
compile 'com.squareup.okhttp3:okhttp:3.4.1' compile 'com.squareup.okhttp3:okhttp:3.4.1'
compile 'de.greenrobot:eventbus:2.4.0' compile 'de.greenrobot:eventbus:2.4.0'
compile 'com.google.dagger:dagger:2.2' compile 'com.google.dagger:dagger:2.2'
annotationProcessor 'com.google.dagger:dagger-compiler:2.2' annotationProcessor 'com.google.dagger:dagger-compiler:2.2'
compile 'org.nibor.autolink:autolink:0.6.0'
} }

@ -76,8 +76,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</activity> </activity>
<activity android:name=".test.TestActivity" />
<service <service
android:name=".ui.service.WatchNotifier" android:name=".ui.service.WatchNotifier"
android:exported="false" /> android:exported="false" />

@ -194,5 +194,33 @@ The GIFLIB distribution is Copyright (c) 1997 Eric S. Raymond
</code> </code>
</pre> </pre>
<br> <br>
<h3>autolink-java</h3>
<a href="https://github.com/robinst/autolink-java">https://github.com/robinst/autolink-java</a>
<pre>
<code>
The MIT License (MIT)
Copyright (c) 2015 Robin Stocker
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
</code>
</pre>
<br>
</body> </body>
</html> </html>

@ -46,8 +46,12 @@ import org.jsoup.parser.Parser;
import org.jsoup.select.Elements; import org.jsoup.select.Elements;
import org.jsoup.select.NodeTraversor; import org.jsoup.select.NodeTraversor;
import org.jsoup.select.NodeVisitor; import org.jsoup.select.NodeVisitor;
import org.nibor.autolink.LinkExtractor;
import org.nibor.autolink.LinkSpan;
import org.nibor.autolink.LinkType;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.regex.Matcher; import java.util.regex.Matcher;
@ -68,6 +72,8 @@ public class ChanParser {
@Inject @Inject
DatabaseManager databaseManager; DatabaseManager databaseManager;
private final LinkExtractor linkExtractor = LinkExtractor.builder().linkTypes(EnumSet.of(LinkType.URL)).build();
@Inject @Inject
public ChanParser() { public ChanParser() {
} }
@ -457,40 +463,16 @@ public class ChanParser {
} }
private void detectLinks(Theme theme, Post.Builder post, String text, SpannableString spannable) { private void detectLinks(Theme theme, Post.Builder post, String text, SpannableString spannable) {
int startPos = 0; // use autolink-java lib to detect links
int endPos; final Iterable<LinkSpan> links = linkExtractor.extractLinks(text);
while (true) { for(final LinkSpan link : links) {
startPos = text.indexOf("://", startPos); final String linkText = text.substring(link.getBeginIndex(), link.getEndIndex());
if (startPos < 0) break; final PostLinkable pl = new PostLinkable(theme, linkText, linkText, PostLinkable.Type.LINK);
spannable.setSpan(pl, link.getBeginIndex(), link.getEndIndex(), 0);
// go back to the first space
while (startPos > 0 && !isWhitespace(text.charAt(startPos - 1))) {
startPos--;
}
// find the last non whitespace character
endPos = startPos;
while (endPos < text.length() - 1 && !isWhitespace(text.charAt(endPos + 1))) {
endPos++;
}
// one past
endPos++;
String linkString = text.substring(startPos, endPos);
PostLinkable pl = new PostLinkable(theme, linkString, linkString, PostLinkable.Type.LINK);
spannable.setSpan(pl, startPos, endPos, 0);
post.addLinkable(pl); post.addLinkable(pl);
startPos = endPos;
} }
} }
private boolean isWhitespace(char c) {
return Character.isWhitespace(c) || c == '>'; // consider > as a link separator
}
// Below code taken from org.jsoup.nodes.Element.text(), but it preserves <br> // Below code taken from org.jsoup.nodes.Element.text(), but it preserves <br>
private String getNodeText(Element node) { private String getNodeText(Element node) {
final StringBuilder accum = new StringBuilder(); final StringBuilder accum = new StringBuilder();

@ -85,5 +85,33 @@ public abstract class ImageSearch {
return "http://tineye.com/search/?url=" + imageUrl; return "http://tineye.com/search/?url=" + imageUrl;
} }
}); });
engines.add(new ImageSearch() {
public int getId() {
return 4;
}
public String getName() {
return "WAIT";
}
public String getUrl(String imageUrl) {
return "https://whatanime.ga/?url=" + imageUrl;
}
});
engines.add(new ImageSearch() {
public int getId() {
return 5;
}
public String getName() {
return "Yandex";
}
public String getUrl(String imageUrl) {
return "https://www.yandex.com/images/search?rpt=imageview&img_url=" + imageUrl;
}
});
} }
} }

@ -29,6 +29,7 @@ import java.io.FileOutputStream;
import java.io.InterruptedIOException; import java.io.InterruptedIOException;
import java.io.OutputStream; import java.io.OutputStream;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
@ -36,6 +37,7 @@ import java.util.concurrent.Executors;
import java.util.concurrent.Future; import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
import okhttp3.Call; import okhttp3.Call;
import okhttp3.OkHttpClient; import okhttp3.OkHttpClient;
@ -58,7 +60,8 @@ public class FileCache {
private final File directory; private final File directory;
private final long maxSize; private final long maxSize;
private long size; private AtomicLong size = new AtomicLong();
private AtomicBoolean trimRunning = new AtomicBoolean(false);
private List<FileCacheDownloader> downloaders = new ArrayList<>(); private List<FileCacheDownloader> downloaders = new ArrayList<>();
@ -75,16 +78,8 @@ public class FileCache {
.protocols(Collections.singletonList(Protocol.HTTP_1_1)) .protocols(Collections.singletonList(Protocol.HTTP_1_1))
.build(); .build();
makeDir(); createDirectories();
calculateSize(); recalculateSize();
}
public void logStats() {
Logger.i(TAG, "Cache size = " + size + "/" + maxSize);
Logger.i(TAG, "downloaders.size() = " + downloaders.size());
for (FileCacheDownloader downloader : downloaders) {
Logger.i(TAG, "url = " + downloader.getUrl() + " cancelled = " + downloader.cancelled);
}
} }
public void clearCache() { public void clearCache() {
@ -100,7 +95,7 @@ public class FileCache {
} }
} }
} }
calculateSize(); recalculateSize();
} }
/** /**
@ -151,76 +146,99 @@ public class FileCache {
} }
public File get(String key) { public File get(String key) {
makeDir(); createDirectories();
return new File(directory, Integer.toString(key.hashCode())); return new File(directory, Integer.toString(key.hashCode()));
} }
private void put(File file) { private void createDirectories() {
size += file.length();
trim();
}
private boolean delete(File file) {
size -= file.length();
return file.delete();
}
private void makeDir() {
if (!directory.exists()) { if (!directory.exists()) {
if (!directory.mkdirs()) { if (!directory.mkdirs()) {
Logger.e(TAG, "Unable to create file cache dir " + directory.getAbsolutePath()); Logger.e(TAG, "Unable to create file cache dir " + directory.getAbsolutePath());
} else { } else {
calculateSize(); recalculateSize();
} }
} }
} }
private void fileWasAdded(File file) {
long adjustedSize = size.addAndGet(file.length());
if (adjustedSize > maxSize && trimRunning.compareAndSet(false, true)) {
executor.submit(new Runnable() {
@Override
public void run() {
try {
trim();
} catch (Exception e) {
Logger.e(TAG, "Error trimming", e);
} finally {
trimRunning.set(false);
}
}
});
}
}
// Called on a background thread
private void trim() { private void trim() {
File[] directoryFiles = directory.listFiles();
// Don't try to trim empty directories or just one image in it.
if (directoryFiles == null || directoryFiles.length <= 1) {
return;
}
List<File> files = new ArrayList<>(Arrays.asList(directoryFiles));
int trimmed = 0;
long workingSize = size.get();
int tries = 0; int tries = 0;
while (size > maxSize && tries++ < TRIM_TRIES) { while (workingSize > maxSize && tries++ < TRIM_TRIES) {
File[] files = directory.listFiles(); // Find the oldest file
if (files == null || files.length <= 1) { long oldest = Long.MAX_VALUE;
break; File oldestFile = null;
}
long age = Long.MAX_VALUE;
long last;
File oldest = null;
for (File file : files) { for (File file : files) {
last = file.lastModified(); long modified = file.lastModified();
if (last < age && last != 0L) { if (modified != 0L && modified < oldest) {
age = last; oldest = modified;
oldest = file; oldestFile = file;
} }
} }
if (oldest == null) { if (oldestFile != null) {
Logger.e(TAG, "No files to trim"); Logger.d(TAG, "Delete for trim" + oldestFile.getAbsolutePath());
break; workingSize -= oldestFile.length();
} else { trimmed++;
Logger.d(TAG, "Deleting " + oldest.getAbsolutePath()); files.remove(oldestFile);
if (!delete(oldest)) {
Logger.e(TAG, "Cannot delete cache file while trimming"); if (!oldestFile.delete()) {
calculateSize(); Logger.e(TAG, "Failed to delete cache file for trim");
break; break;
} }
} else {
Logger.e(TAG, "No files to trim");
break;
} }
}
calculateSize(); if (trimmed > 0) {
recalculateSize();
} }
} }
private void calculateSize() { // Called on a background thread
size = 0; private void recalculateSize() {
long calculatedSize = 0;
File[] files = directory.listFiles(); File[] files = directory.listFiles();
if (files != null) { if (files != null) {
for (File file : files) { for (File file : files) {
size += file.length(); calculatedSize += file.length();
} }
} }
size.set(calculatedSize);
} }
private void removeFromDownloaders(FileCacheDownloader downloader) { private void removeFromDownloaders(FileCacheDownloader downloader) {
@ -365,7 +383,7 @@ public class FileCache {
post(new Runnable() { post(new Runnable() {
@Override @Override
public void run() { public void run() {
fileCache.put(output); fileCache.fileWasAdded(output);
removeFromDownloadersList(); removeFromDownloadersList();
for (DownloadedCallback callback : callbacks) { for (DownloadedCallback callback : callbacks) {
callback.onProgress(0, 0, true); callback.onProgress(0, 0, true);

@ -8,18 +8,19 @@ import org.floens.chan.chan.ChanLoader;
import org.floens.chan.chan.ChanParser; import org.floens.chan.chan.ChanParser;
import org.floens.chan.core.cache.FileCache; import org.floens.chan.core.cache.FileCache;
import org.floens.chan.core.database.DatabaseManager; import org.floens.chan.core.database.DatabaseManager;
import org.floens.chan.core.manager.ReplyManager;
import org.floens.chan.core.manager.BoardManager; import org.floens.chan.core.manager.BoardManager;
import org.floens.chan.core.manager.FilterEngine; import org.floens.chan.core.manager.FilterEngine;
import org.floens.chan.core.manager.ReplyManager;
import org.floens.chan.core.manager.WatchManager; import org.floens.chan.core.manager.WatchManager;
import org.floens.chan.core.site.http.HttpCallManager;
import org.floens.chan.core.site.sites.chan4.Chan4ReaderRequest;
import org.floens.chan.core.presenter.ImageViewerPresenter; import org.floens.chan.core.presenter.ImageViewerPresenter;
import org.floens.chan.core.presenter.ReplyPresenter; import org.floens.chan.core.presenter.ReplyPresenter;
import org.floens.chan.core.presenter.ThreadPresenter; import org.floens.chan.core.presenter.ThreadPresenter;
import org.floens.chan.core.receiver.WatchUpdateReceiver; import org.floens.chan.core.receiver.WatchUpdateReceiver;
import org.floens.chan.core.saver.ImageSaveTask; import org.floens.chan.core.saver.ImageSaveTask;
import org.floens.chan.core.site.http.HttpCallManager;
import org.floens.chan.core.site.sites.chan4.Chan4; import org.floens.chan.core.site.sites.chan4.Chan4;
import org.floens.chan.core.site.sites.chan4.Chan4ReaderRequest;
import org.floens.chan.core.update.UpdateManager;
import org.floens.chan.ui.activity.StartActivity; import org.floens.chan.ui.activity.StartActivity;
import org.floens.chan.ui.adapter.DrawerAdapter; import org.floens.chan.ui.adapter.DrawerAdapter;
import org.floens.chan.ui.adapter.PostsFilter; import org.floens.chan.ui.adapter.PostsFilter;
@ -124,5 +125,7 @@ public interface ChanGraph {
void inject(WatchManager.PinWatcher pinWatcher); void inject(WatchManager.PinWatcher pinWatcher);
void inject(UpdateManager updateManager);
void inject(Chan4 chan4); void inject(Chan4 chan4);
} }

@ -1,46 +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;
import org.floens.chan.core.saver.StorageHelper;
import java.io.File;
public class FileItem {
public File file;
public FileItem(File file) {
this.file = file;
}
public boolean isFile() {
return file.isFile();
}
public boolean isFolder() {
return file.isDirectory();
}
public boolean canNavigate() {
return StorageHelper.canNavigate(file);
}
public boolean canOpen() {
return StorageHelper.canOpen(file);
}
}

@ -1,34 +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;
import java.io.File;
import java.util.List;
public class FileItems {
public final File path;
public final List<FileItem> fileItems;
public final boolean canNavigateUp;
public FileItems(File path, List<FileItem> fileItems, boolean canNavigateUp) {
this.path = path;
this.fileItems = fileItems;
this.canNavigateUp = canNavigateUp;
}
}

@ -0,0 +1,162 @@
/*
* 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.net;
import android.util.JsonReader;
import com.android.volley.Response;
import org.floens.chan.BuildConfig;
import java.io.IOException;
import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import okhttp3.HttpUrl;
public class UpdateApiRequest extends JsonReaderRequest<UpdateApiRequest.UpdateApiResponse> {
public static final String TYPE_UPDATE = "update";
private static final int API_VERSION = 1;
private String forFlavor;
public UpdateApiRequest(Response.Listener<UpdateApiResponse> listener, Response.ErrorListener errorListener) {
super(BuildConfig.UPDATE_API_ENDPOINT, listener, errorListener);
forFlavor = BuildConfig.FLAVOR;
}
@Override
public UpdateApiResponse readJson(JsonReader reader) throws Exception {
reader.beginObject();
UpdateApiResponse response = new UpdateApiResponse();
int apiVersion;
out:
while (reader.hasNext()) {
switch (reader.nextName()) {
case "api_version":
apiVersion = reader.nextInt();
if (apiVersion > API_VERSION) {
response.newerApiVersion = true;
while (reader.hasNext()) reader.skipValue();
break out;
}
break;
case "messages":
reader.beginArray();
while (reader.hasNext()) {
response.messages.add(readMessage(reader));
}
reader.endArray();
break;
case "check_interval":
response.checkIntervalMs = reader.nextLong();
break;
default:
reader.skipValue();
break;
}
}
reader.endObject();
return response;
}
private UpdateApiMessage readMessage(JsonReader reader) throws IOException {
reader.beginObject();
UpdateApiMessage message = new UpdateApiMessage();
while (reader.hasNext()) {
switch (reader.nextName()) {
case "type":
message.type = reader.nextString();
break;
case "code":
message.code = reader.nextInt();
break;
case "date":
DateFormat format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US);
try {
message.date = format.parse(reader.nextString());
} catch (ParseException ignore) {
}
break;
case "message_html":
message.messageHtml = reader.nextString();
break;
case "apk":
reader.beginObject();
while (reader.hasNext()) {
if (reader.nextName().equals(forFlavor)) {
reader.beginObject();
while (reader.hasNext()) {
switch (reader.nextName()) {
case "url":
message.apkUrl = HttpUrl.parse(reader.nextString());
break;
default:
reader.skipValue();
break;
}
}
reader.endObject();
} else {
reader.skipValue();
}
}
reader.endObject();
break;
default:
reader.skipValue();
break;
}
}
reader.endObject();
return message;
}
public static class UpdateApiResponse {
public boolean newerApiVersion;
public List<UpdateApiMessage> messages = new ArrayList<>();
public long checkIntervalMs;
}
public static class UpdateApiMessage {
public String type;
public int code;
public Date date;
public String messageHtml;
public HttpUrl apkUrl;
}
}

@ -415,6 +415,8 @@ public class ReplyPresenter implements CaptchaCallback, ImagePickDelegate.ImageP
void setPage(Page page, boolean animate); void setPage(Page page, boolean animate);
void setCaptchaVersion(boolean newCaptcha);
void initCaptcha(String baseUrl, String siteKey, CaptchaCallback callback); void initCaptcha(String baseUrl, String siteKey, CaptchaCallback callback);
void resetCaptcha(); void resetCaptcha();

@ -585,7 +585,7 @@ public class ThreadPresenter implements ChanLoader.ChanLoaderCallback, PostAdapt
@Override @Override
public ChanThread getChanThread() { public ChanThread getChanThread() {
return chanLoader.getThread(); return chanLoader == null ? null : chanLoader.getThread();
} }
@Override @Override

@ -17,12 +17,10 @@
*/ */
package org.floens.chan.core.saver; package org.floens.chan.core.saver;
import android.os.Environment;
import android.os.FileObserver; import android.os.FileObserver;
import android.util.Log; import android.util.Log;
import org.floens.chan.core.model.FileItem;
import org.floens.chan.core.model.FileItems;
import java.io.File; import java.io.File;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
@ -54,6 +52,11 @@ public class FileWatcher {
public void initialize() { public void initialize() {
initialized = true; initialized = true;
if (!StorageHelper.canNavigate(startingPath)) {
startingPath = Environment.getExternalStorageDirectory();
}
navigateTo(startingPath); navigateTo(startingPath);
} }
@ -126,4 +129,41 @@ public class FileWatcher {
public interface FileWatcherCallback { public interface FileWatcherCallback {
void onFiles(FileItems fileItems); void onFiles(FileItems fileItems);
} }
public static class FileItem {
public File file;
public FileItem(File file) {
this.file = file;
}
public boolean isFile() {
return file.isFile();
}
public boolean isFolder() {
return file.isDirectory();
}
public boolean canNavigate() {
return StorageHelper.canNavigate(file);
}
public boolean canOpen() {
return StorageHelper.canOpen(file);
}
}
public static class FileItems {
public final File path;
public final List<FileItem> fileItems;
public final boolean canNavigateUp;
public FileItems(File path, List<FileItem> fileItems, boolean canNavigateUp) {
this.path = path;
this.fileItems = fileItems;
this.canNavigateUp = canNavigateUp;
}
}
} }

@ -30,11 +30,7 @@ import org.floens.chan.utils.ImageDecoder;
import org.floens.chan.utils.Logger; import org.floens.chan.utils.Logger;
import java.io.File; import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import javax.inject.Inject; import javax.inject.Inject;
@ -117,8 +113,11 @@ public class ImageSaveTask implements Runnable, FileCache.DownloadedCallback {
try { try {
if (destination.exists()) { if (destination.exists()) {
onDestination(); onDestination();
// Manually call postFinished()
postFinished(success);
} else { } else {
FileCache.FileCacheDownloader fileCacheDownloader = fileCache.downloadFile(postImage.imageUrl.toString(), this); FileCache.FileCacheDownloader fileCacheDownloader = fileCache.downloadFile(postImage.imageUrl.toString(), this);
// If the fileCacheDownloader is null then the destination already existed and onSuccess() has been called. // If the fileCacheDownloader is null then the destination already existed and onSuccess() has been called.
// Wait otherwise for the download to finish to avoid that the next task is immediately executed. // Wait otherwise for the download to finish to avoid that the next task is immediately executed.
if (fileCacheDownloader != null) { if (fileCacheDownloader != null) {
@ -175,8 +174,6 @@ public class ImageSaveTask implements Runnable, FileCache.DownloadedCallback {
private boolean copyToDestination(File source) { private boolean copyToDestination(File source) {
boolean result = false; boolean result = false;
InputStream is = null;
OutputStream os = null;
try { try {
File parent = destination.getParentFile(); File parent = destination.getParentFile();
if (!parent.mkdirs() && !parent.isDirectory()) { if (!parent.mkdirs() && !parent.isDirectory()) {
@ -187,16 +184,11 @@ public class ImageSaveTask implements Runnable, FileCache.DownloadedCallback {
throw new IOException("Destination file is already a directory"); throw new IOException("Destination file is already a directory");
} }
is = new FileInputStream(source); IOUtils.copyFile(source, destination);
os = new FileOutputStream(destination);
IOUtils.copy(is, os);
result = true; result = true;
} catch (IOException e) { } catch (IOException e) {
Logger.e(TAG, "Error writing to file", e); Logger.e(TAG, "Error writing to file", e);
} finally {
IOUtils.closeQuietly(is);
IOUtils.closeQuietly(os);
} }
return result; return result;

@ -23,6 +23,7 @@ import android.text.TextUtils;
import org.floens.chan.R; import org.floens.chan.R;
import org.floens.chan.core.manager.WatchManager; import org.floens.chan.core.manager.WatchManager;
import org.floens.chan.core.update.UpdateManager;
import org.floens.chan.ui.adapter.PostsFilter; import org.floens.chan.ui.adapter.PostsFilter;
import org.floens.chan.utils.AndroidUtils; import org.floens.chan.utils.AndroidUtils;
@ -154,6 +155,9 @@ public class ChanSettings {
public static final CounterSetting replyOpenCounter; public static final CounterSetting replyOpenCounter;
public static final CounterSetting threadOpenCounter; public static final CounterSetting threadOpenCounter;
public static final LongSetting updateCheckTime;
public static final LongSetting updateCheckInterval;
static { static {
SharedPreferences p = AndroidUtils.getPreferences(); SharedPreferences p = AndroidUtils.getPreferences();
@ -179,7 +183,7 @@ public class ChanSettings {
postDefaultName = new StringSetting(p, "preference_default_name", ""); postDefaultName = new StringSetting(p, "preference_default_name", "");
postPinThread = new BooleanSetting(p, "preference_pin_on_post", false); postPinThread = new BooleanSetting(p, "preference_pin_on_post", false);
postNewCaptcha = new BooleanSetting(p, "preference_new_captcha", false); postNewCaptcha = new BooleanSetting(p, "preference_new_captcha", true);
developer = new BooleanSetting(p, "preference_developer", false); developer = new BooleanSetting(p, "preference_developer", false);
@ -263,6 +267,9 @@ public class ChanSettings {
replyOpenCounter = new CounterSetting(p, "counter_reply_open"); replyOpenCounter = new CounterSetting(p, "counter_reply_open");
threadOpenCounter = new CounterSetting(p, "counter_thread_open"); threadOpenCounter = new CounterSetting(p, "counter_thread_open");
updateCheckTime = new LongSetting(p, "update_check_time", 0L);
updateCheckInterval = new LongSetting(p, "update_check_interval", UpdateManager.DEFAULT_UPDATE_CHECK_INTERVAL_MS);
// Old (but possibly still in some users phone) // Old (but possibly still in some users phone)
// preference_board_view_mode default "list" // preference_board_view_mode default "list"
// preference_board_editor_filler default false // preference_board_editor_filler default false

@ -0,0 +1,49 @@
/*
* 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.settings;
import android.content.SharedPreferences;
public class LongSetting extends Setting<Long> {
private boolean hasCached = false;
private Long cached;
public LongSetting(SharedPreferences sharedPreferences, String key, Long def) {
super(sharedPreferences, key, def);
}
@Override
public Long get() {
if (hasCached) {
return cached;
} else {
cached = sharedPreferences.getLong(key, def);
hasCached = true;
return cached;
}
}
@Override
public void set(Long value) {
if (!value.equals(get())) {
sharedPreferences.edit().putLong(key, value).apply();
cached = value;
onValueChanged();
}
}
}

@ -151,6 +151,11 @@ public class Chan4 implements Site {
b.addPathSegment("country"); b.addPathSegment("country");
b.addPathSegment(arg.get("country_code").toLowerCase(Locale.ENGLISH) + ".gif"); b.addPathSegment(arg.get("country_code").toLowerCase(Locale.ENGLISH) + ".gif");
break; break;
case "troll_country":
b.addPathSegment("country");
b.addPathSegment("troll");
b.addPathSegment(arg.get("troll_country_code").toLowerCase(Locale.ENGLISH) + ".gif");
break;
case "since4pass": case "since4pass":
b.addPathSegment("minileaf.gif"); b.addPathSegment("minileaf.gif");
break; break;

@ -370,6 +370,7 @@ public class Chan4ReaderRequest extends JsonReaderRequest<ChanLoaderResponse> {
// Country flag // Country flag
String countryCode = null; String countryCode = null;
String trollCountryCode = null;
String countryName = null; String countryName = null;
// 4chan pass leaf // 4chan pass leaf
@ -422,6 +423,9 @@ public class Chan4ReaderRequest extends JsonReaderRequest<ChanLoaderResponse> {
case "country": case "country":
countryCode = reader.nextString(); countryCode = reader.nextString();
break; break;
case "troll_country":
trollCountryCode = reader.nextString();
break;
case "country_name": case "country_name":
countryName = reader.nextString(); countryName = reader.nextString();
break; break;
@ -511,6 +515,13 @@ public class Chan4ReaderRequest extends JsonReaderRequest<ChanLoaderResponse> {
builder.addHttpIcon(new PostHttpIcon(countryUrl, countryName)); builder.addHttpIcon(new PostHttpIcon(countryUrl, countryName));
} }
if (trollCountryCode != null && countryName != null) {
Map<String, String> arg = new HashMap<>(1);
arg.put("troll_country_code", trollCountryCode);
HttpUrl countryUrl = endpoints.icon(builder, "troll_country", arg);
builder.addHttpIcon(new PostHttpIcon(countryUrl, countryName));
}
if (since4pass != 0) { if (since4pass != 0) {
HttpUrl iconUrl = endpoints.icon(builder, "since4pass", null); HttpUrl iconUrl = endpoints.icon(builder, "since4pass", null);
builder.addHttpIcon(new PostHttpIcon(iconUrl, String.valueOf(since4pass))); builder.addHttpIcon(new PostHttpIcon(iconUrl, String.valueOf(since4pass)));

@ -0,0 +1,249 @@
/*
* 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.update;
import android.content.Intent;
import android.net.Uri;
import android.os.Environment;
import android.os.StrictMode;
import android.text.TextUtils;
import com.android.volley.RequestQueue;
import com.android.volley.Response;
import com.android.volley.VolleyError;
import org.floens.chan.BuildConfig;
import org.floens.chan.core.cache.FileCache;
import org.floens.chan.core.net.UpdateApiRequest;
import org.floens.chan.core.settings.ChanSettings;
import org.floens.chan.utils.IOUtils;
import org.floens.chan.utils.Logger;
import org.floens.chan.utils.Time;
import java.io.File;
import java.io.IOException;
import javax.inject.Inject;
import okhttp3.HttpUrl;
import static org.floens.chan.Chan.getGraph;
/**
* Calls the update API and downloads and requests installs of APK files.
* <p>The APK files are downloaded to the public Download directory, and the default APK install
* screen is launched after downloading.
*/
public class UpdateManager {
public static final long DEFAULT_UPDATE_CHECK_INTERVAL_MS = 1000 * 60 * 60 * 24 * 5; // 5 days
private static final String TAG = "UpdateManager";
private static final String DOWNLOAD_FILE = "Clover_update.apk";
@Inject
RequestQueue volleyRequestQueue;
@Inject
FileCache fileCache;
private UpdateCallback callback;
public UpdateManager(UpdateCallback callback) {
getGraph().inject(this);
this.callback = callback;
}
public boolean isUpdatingAvailable() {
return !TextUtils.isEmpty(BuildConfig.UPDATE_API_ENDPOINT);
}
public void runUpdateApi(final boolean manual) {
if (!manual) {
long lastUpdateTime = ChanSettings.updateCheckTime.get();
long interval = ChanSettings.updateCheckInterval.get();
long now = Time.get();
long delta = (lastUpdateTime + interval) - now;
if (delta > 0) {
return;
} else {
ChanSettings.updateCheckTime.set(now);
}
}
Logger.d(TAG, "Calling update API");
volleyRequestQueue.add(new UpdateApiRequest(new Response.Listener<UpdateApiRequest.UpdateApiResponse>() {
@Override
public void onResponse(UpdateApiRequest.UpdateApiResponse response) {
if (!processUpdateApiResponse(response) && manual) {
callback.onManualCheckNone();
}
}
}, new Response.ErrorListener() {
@Override
public void onErrorResponse(VolleyError error) {
Logger.e(TAG, "Failed to process API call for updating", error);
if (manual) {
callback.onManualCheckFailed();
}
}
}));
}
private boolean processUpdateApiResponse(UpdateApiRequest.UpdateApiResponse response) {
if (response.newerApiVersion) {
Logger.e(TAG, "API endpoint reports a higher API version than we support, aborting update check.");
// ignore
return false;
}
if (response.checkIntervalMs != 0) {
ChanSettings.updateCheckInterval.set(response.checkIntervalMs);
}
for (UpdateApiRequest.UpdateApiMessage message : response.messages) {
if (processUpdateMessage(message)) {
return true;
}
}
return false;
}
private boolean processUpdateMessage(UpdateApiRequest.UpdateApiMessage message) {
if (message.code <= BuildConfig.VERSION_CODE) {
Logger.d(TAG, "No newer version available (" + BuildConfig.VERSION_CODE + " >= " + message.code + ").");
// Our code is newer than the message
return false;
}
if (message.type.equals(UpdateApiRequest.TYPE_UPDATE)) {
if (message.apkUrl == null) {
Logger.i(TAG, "Update available but none for this build flavor.");
// Not for this flavor, discard.
return false;
}
Logger.i(TAG, "Update available (" + message.code + ") with url \"" + message.apkUrl + "\".");
callback.showUpdateAvailableDialog(message);
return true;
}
return false;
}
/**
* Install the APK file specified in {@code update}. This methods needs the storage permission.
*
* @param update update with apk details.
*/
public void doUpdate(Update update) {
fileCache.downloadFile(update.apkUrl.toString(), new FileCache.DownloadedCallback() {
@Override
public void onProgress(long downloaded, long total, boolean done) {
if (!done) callback.onUpdateDownloadProgress(downloaded, total);
}
@Override
public void onSuccess(File file) {
callback.onUpdateDownloadSuccess();
copyToPublicDirectory(file);
}
@Override
public void onFail(boolean notFound) {
callback.onUpdateDownloadFailed();
}
});
}
public void retry(Install install) {
installApk(install);
}
private void copyToPublicDirectory(File cacheFile) {
File out = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), DOWNLOAD_FILE);
try {
IOUtils.copyFile(cacheFile, out);
} catch (IOException e) {
Logger.e(TAG, "requestApkInstall", e);
callback.onUpdateDownloadMoveFailed();
return;
}
installApk(new Install(out));
}
private void installApk(Install install) {
// First open the dialog that asks to retry and calls this method again.
callback.openUpdateRetryDialog(install);
// Then launch the APK install intent.
Intent intent = new Intent(Intent.ACTION_VIEW);
intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
intent.setDataAndType(Uri.fromFile(install.installFile), "application/vnd.android.package-archive");
// The installer wants a content scheme from android N and up,
// but I don't feel like implementing a content provider just for this feature.
// Temporary change the strictmode policy while starting the intent.
StrictMode.VmPolicy vmPolicy = StrictMode.getVmPolicy();
StrictMode.setVmPolicy(StrictMode.VmPolicy.LAX);
callback.onUpdateOpenInstallScreen(intent);
StrictMode.setVmPolicy(vmPolicy);
}
public static class Update {
private HttpUrl apkUrl;
public Update(HttpUrl apkUrl) {
this.apkUrl = apkUrl;
}
}
public static class Install {
private File installFile;
public Install(File installFile) {
this.installFile = installFile;
}
}
public interface UpdateCallback {
void onManualCheckNone();
void onManualCheckFailed();
void showUpdateAvailableDialog(UpdateApiRequest.UpdateApiMessage message);
void onUpdateDownloadProgress(long downloaded, long total);
void onUpdateDownloadSuccess();
void onUpdateDownloadFailed();
void onUpdateDownloadMoveFailed();
void onUpdateOpenInstallScreen(Intent intent);
void openUpdateRetryDialog(Install install);
}
}

@ -1,303 +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.test;
import android.app.Activity;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.view.View;
import android.widget.Button;
import android.widget.LinearLayout;
import org.floens.chan.Chan;
import org.floens.chan.chan.ChanLoader;
import org.floens.chan.core.cache.FileCache;
import org.floens.chan.core.exception.ChanLoaderException;
import org.floens.chan.core.model.ChanThread;
import org.floens.chan.core.model.Loadable;
import org.floens.chan.core.model.Post;
import org.floens.chan.ui.theme.ThemeHelper;
import org.floens.chan.utils.Logger;
import java.io.File;
// Poor mans unit testing.
// Move to proper unit testing when the gradle plugin fully supports it.
public class TestActivity extends Activity implements View.OnClickListener {
private static final String TAG = "FileCacheTest";
private final Handler handler = new Handler(Looper.getMainLooper());
private Button clearCache;
private Button stats;
private Button simpleTest;
private Button cacheTest;
private Button timeoutTest;
private FileCache fileCache;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
ThemeHelper.getInstance().setupContext(this);
LinearLayout linearLayout = new LinearLayout(this);
linearLayout.setOrientation(LinearLayout.VERTICAL);
clearCache = new Button(this);
clearCache.setText("Clear cache");
clearCache.setOnClickListener(this);
linearLayout.addView(clearCache);
stats = new Button(this);
stats.setText("Stats");
stats.setOnClickListener(this);
linearLayout.addView(stats);
simpleTest = new Button(this);
simpleTest.setText("Test download and cancel");
simpleTest.setOnClickListener(this);
linearLayout.addView(simpleTest);
cacheTest = new Button(this);
cacheTest.setText("Test cache size");
cacheTest.setOnClickListener(this);
linearLayout.addView(cacheTest);
timeoutTest = new Button(this);
timeoutTest.setText("Test multiple parallel");
timeoutTest.setOnClickListener(this);
linearLayout.addView(timeoutTest);
setContentView(linearLayout);
File cacheDir = getExternalCacheDir() != null ? getExternalCacheDir() : getCacheDir();
File fileCacheDir = new File(cacheDir, "filecache");
fileCache = new FileCache(fileCacheDir, 50 * 1024 * 1024, Chan.getInstance().getUserAgent());
}
@Override
public void onClick(View v) {
if (v == clearCache) {
clearCache();
} else if (v == stats) {
stats();
} else if (v == simpleTest) {
testDownloadAndCancel();
} else if (v == cacheTest) {
testCache();
} else if (v == timeoutTest) {
testTimeout();
}
}
public void clearCache() {
fileCache.clearCache();
}
public void stats() {
fileCache.logStats();
}
public void testDownloadAndCancel() {
// 1.9MB file of the clover Logger.i(TAG,
final String testImage = "http://a.pomf.se/ndbolc.png";
final File cacheFile = fileCache.get(testImage);
Logger.i(TAG, "Downloading " + testImage);
final FileCache.FileCacheDownloader downloader = fileCache.downloadFile(testImage, new FileCache.DownloadedCallback() {
@Override
public void onProgress(long downloaded, long total, boolean done) {
Logger.i(TAG, "onProgress " + downloaded + "/" + total + " " + done);
}
@Override
public void onSuccess(File file) {
Logger.i(TAG, "onSuccess " + file.exists());
}
@Override
public void onFail(boolean notFound) {
Logger.i(TAG, "onFail Cachefile exists() = " + cacheFile.exists());
}
});
handler.postDelayed(new Runnable() {
@Override
public void run() {
fileCache.downloadFile(testImage, new FileCache.DownloadedCallback() {
@Override
public void onProgress(long downloaded, long total, boolean done) {
Logger.i(TAG, "2nd progress " + downloaded + "/" + total);
}
@Override
public void onSuccess(File file) {
Logger.i(TAG, "2nd onSuccess " + file.exists());
}
@Override
public void onFail(boolean notFound) {
Logger.i(TAG, "2nd onFail Cachefile exists() = " + cacheFile.exists());
}
});
}
}, 200);
handler.postDelayed(new Runnable() {
@Override
public void run() {
Logger.i(TAG, "Cancelling download!");
downloader.cancel();
}
}, 500);
handler.postDelayed(new Runnable() {
@Override
public void run() {
Logger.i(TAG, "File exists() = " + cacheFile.exists());
}
}, 600);
handler.postDelayed(new Runnable() {
@Override
public void run() {
final File cache404File = fileCache.get(testImage + "404");
fileCache.downloadFile(testImage + "404", new FileCache.DownloadedCallback() {
@Override
public void onProgress(long downloaded, long total, boolean done) {
Logger.i(TAG, "404 progress " + downloaded + "/" + total + " " + done);
}
@Override
public void onSuccess(File file) {
Logger.i(TAG, "404 onSuccess " + file.exists());
}
@Override
public void onFail(boolean notFound) {
Logger.i(TAG, "404 onFail " + cache404File.exists());
}
});
}
}, 1000);
}
private void testCache() {
// Loadable loadable = Loadable.forCatalog(Sites.defaultSite().boards().boards.get(0));
Loadable loadable = null;
ChanLoader loader = new ChanLoader(loadable);
loader.addListener(new ChanLoader.ChanLoaderCallback() {
@Override
public void onChanLoaderData(ChanThread result) {
for (Post post : result.posts) {
if (post.image != null) {
final String imageUrl = post.image.imageUrl.toString();
fileCache.downloadFile(imageUrl, new FileCache.DownloadedCallback() {
@Override
public void onProgress(long downloaded, long total, boolean done) {
Logger.i(TAG, "Progress for " + imageUrl + " " + downloaded + "/" + total + " " + done);
}
@Override
public void onSuccess(File file) {
Logger.i(TAG, "onSuccess for " + imageUrl + " exists() = " + file.exists());
}
@Override
public void onFail(boolean notFound) {
Logger.i(TAG, "onFail for " + imageUrl);
}
});
}
}
}
@Override
public void onChanLoaderError(ChanLoaderException error) {
}
});
loader.requestData();
}
private void testTimeout() {
testTimeoutInner("https://i.4cdn.org/hr/1429923649068.jpg", fileCache, 0);
handler.postDelayed(new Runnable() {
@Override
public void run() {
testTimeoutInner("https://i.4cdn.org/hr/1430058524427.jpg", fileCache, 0);
}
}, 200);
handler.postDelayed(new Runnable() {
@Override
public void run() {
testTimeoutInner("https://i.4cdn.org/hr/1430058627352.jpg", fileCache, 0);
}
}, 400);
handler.postDelayed(new Runnable() {
@Override
public void run() {
testTimeoutInner("https://i.4cdn.org/hr/1430058580015.jpg", fileCache, 0);
}
}, 600);
}
private void testTimeoutInner(final String url, final FileCache fileCache, final int tries) {
final File cacheFile = fileCache.get(url);
Logger.i(TAG, "Downloading " + url + " try " + tries);
final FileCache.FileCacheDownloader downloader = fileCache.downloadFile(url, new FileCache.DownloadedCallback() {
@Override
public void onProgress(long downloaded, long total, boolean done) {
Logger.i(TAG, "onProgress " + url + " " + downloaded + "/" + total);
}
@Override
public void onSuccess(File file) {
Logger.i(TAG, "onSuccess " + file.exists());
}
@Override
public void onFail(boolean notFound) {
Logger.i(TAG, "onFail Cachefile exists() = " + cacheFile.exists());
}
});
handler.postDelayed(new Runnable() {
@Override
public void run() {
if (downloader == null) {
Logger.i(TAG, "Downloader null, cannot cancel");
} else {
downloader.cancel();
handler.postDelayed(new Runnable() {
@Override
public void run() {
if (tries < 10) {
testTimeoutInner(url, fileCache, tries + 1);
} else {
fileCache.logStats();
}
}
}, 500);
}
}
}, 1000);
}
}

@ -53,7 +53,7 @@ import org.floens.chan.ui.controller.StyledToolbarNavigationController;
import org.floens.chan.ui.controller.ThreadSlideController; import org.floens.chan.ui.controller.ThreadSlideController;
import org.floens.chan.ui.controller.ViewThreadController; import org.floens.chan.ui.controller.ViewThreadController;
import org.floens.chan.ui.helper.ImagePickDelegate; import org.floens.chan.ui.helper.ImagePickDelegate;
import org.floens.chan.ui.helper.PreviousVersionHandler; import org.floens.chan.ui.helper.VersionHandler;
import org.floens.chan.ui.helper.RuntimePermissionsHelper; import org.floens.chan.ui.helper.RuntimePermissionsHelper;
import org.floens.chan.ui.state.ChanState; import org.floens.chan.ui.state.ChanState;
import org.floens.chan.ui.theme.ThemeHelper; import org.floens.chan.ui.theme.ThemeHelper;
@ -81,6 +81,7 @@ public class StartActivity extends AppCompatActivity implements NfcAdapter.Creat
private ImagePickDelegate imagePickDelegate; private ImagePickDelegate imagePickDelegate;
private RuntimePermissionsHelper runtimePermissionsHelper; private RuntimePermissionsHelper runtimePermissionsHelper;
private VersionHandler versionHandler;
@Inject @Inject
DatabaseManager databaseManager; DatabaseManager databaseManager;
@ -100,6 +101,7 @@ public class StartActivity extends AppCompatActivity implements NfcAdapter.Creat
imagePickDelegate = new ImagePickDelegate(this); imagePickDelegate = new ImagePickDelegate(this);
runtimePermissionsHelper = new RuntimePermissionsHelper(this); runtimePermissionsHelper = new RuntimePermissionsHelper(this);
versionHandler = new VersionHandler(this, runtimePermissionsHelper);
contentView = (ViewGroup) findViewById(android.R.id.content); contentView = (ViewGroup) findViewById(android.R.id.content);
@ -171,8 +173,7 @@ public class StartActivity extends AppCompatActivity implements NfcAdapter.Creat
browseController.loadDefault(); browseController.loadDefault();
} }
PreviousVersionHandler previousVersionHandler = new PreviousVersionHandler(); versionHandler.run();
previousVersionHandler.run(this);
} }
private void setupLayout() { private void setupLayout() {
@ -341,6 +342,10 @@ public class StartActivity extends AppCompatActivity implements NfcAdapter.Creat
return imagePickDelegate; return imagePickDelegate;
} }
public VersionHandler getVersionHandler() {
return versionHandler;
}
public RuntimePermissionsHelper getRuntimePermissionsHelper() { public RuntimePermissionsHelper getRuntimePermissionsHelper() {
return runtimePermissionsHelper; return runtimePermissionsHelper;
} }

@ -28,8 +28,7 @@ import android.widget.ImageView;
import android.widget.TextView; import android.widget.TextView;
import org.floens.chan.R; import org.floens.chan.R;
import org.floens.chan.core.model.FileItem; import org.floens.chan.core.saver.FileWatcher;
import org.floens.chan.core.model.FileItems;
import static org.floens.chan.utils.AndroidUtils.getAttrColor; import static org.floens.chan.utils.AndroidUtils.getAttrColor;
@ -37,20 +36,20 @@ public class FilesAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>
private static final int ITEM_TYPE_FOLDER = 0; private static final int ITEM_TYPE_FOLDER = 0;
private static final int ITEM_TYPE_FILE = 1; private static final int ITEM_TYPE_FILE = 1;
private FileItem highlightedItem; private FileWatcher.FileItem highlightedItem;
private FileItems fileItems; private FileWatcher.FileItems fileItems;
private Callback callback; private Callback callback;
public FilesAdapter(Callback callback) { public FilesAdapter(Callback callback) {
this.callback = callback; this.callback = callback;
} }
public void setFiles(FileItems fileItems) { public void setFiles(FileWatcher.FileItems fileItems) {
this.fileItems = fileItems; this.fileItems = fileItems;
notifyDataSetChanged(); notifyDataSetChanged();
} }
public void setHighlightedItem(FileItem highlightedItem) { public void setHighlightedItem(FileWatcher.FileItem highlightedItem) {
this.highlightedItem = highlightedItem; this.highlightedItem = highlightedItem;
} }
@ -69,7 +68,7 @@ public class FilesAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>
case ITEM_TYPE_FOLDER: { case ITEM_TYPE_FOLDER: {
boolean isFile = itemViewType == ITEM_TYPE_FILE; boolean isFile = itemViewType == ITEM_TYPE_FILE;
FileItem item = getItem(position); FileWatcher.FileItem item = getItem(position);
FileViewHolder fileViewHolder = ((FileViewHolder) holder); FileViewHolder fileViewHolder = ((FileViewHolder) holder);
fileViewHolder.text.setText(item.file.getName()); fileViewHolder.text.setText(item.file.getName());
@ -104,7 +103,7 @@ public class FilesAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>
@Override @Override
public int getItemViewType(int position) { public int getItemViewType(int position) {
FileItem item = getItem(position); FileWatcher.FileItem item = getItem(position);
if (item.isFile()) { if (item.isFile()) {
return ITEM_TYPE_FILE; return ITEM_TYPE_FILE;
} else if (item.isFolder()) { } else if (item.isFolder()) {
@ -114,11 +113,11 @@ public class FilesAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>
} }
} }
public FileItem getItem(int position) { public FileWatcher.FileItem getItem(int position) {
return fileItems.fileItems.get(position); return fileItems.fileItems.get(position);
} }
private void onItemClicked(FileItem fileItem) { private void onItemClicked(FileWatcher.FileItem fileItem) {
callback.onFileItemClicked(fileItem); callback.onFileItemClicked(fileItem);
} }
@ -135,12 +134,12 @@ public class FilesAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>
@Override @Override
public void onClick(View v) { public void onClick(View v) {
FileItem item = getItem(getAdapterPosition()); FileWatcher.FileItem item = getItem(getAdapterPosition());
onItemClicked(item); onItemClicked(item);
} }
} }
public interface Callback { public interface Callback {
void onFileItemClicked(FileItem fileItem); void onFileItemClicked(FileWatcher.FileItem fileItem);
} }
} }

@ -410,12 +410,8 @@ public class PostCell extends LinearLayout implements PostCellInterface {
icons.apply(); icons.apply();
CharSequence commentText; CharSequence commentText;
if (post.comment.length() > COMMENT_MAX_LENGTH_BOARD && !threadMode) { if (!threadMode && post.comment.length() > COMMENT_MAX_LENGTH_BOARD) {
BreakIterator bi = BreakIterator.getWordInstance(); commentText = truncatePostComment(post, COMMENT_MAX_LENGTH_BOARD);
bi.setText(post.comment.toString());
int precedingBoundary = bi.preceding(COMMENT_MAX_LENGTH_BOARD);
// Fallback to old method in case the comment does not have any spaces/individual words
commentText = precedingBoundary > 0 ? post.comment.subSequence(0, precedingBoundary) : post.comment.subSequence(0, COMMENT_MAX_LENGTH_BOARD);
} else { } else {
commentText = post.comment; commentText = post.comment;
} }
@ -492,6 +488,15 @@ public class PostCell extends LinearLayout implements PostCellInterface {
} }
} }
private CharSequence truncatePostComment(Post post, int maxCommentLength) {
BreakIterator bi = BreakIterator.getWordInstance();
bi.setText(post.comment.toString());
int precedingBoundary = bi.following(maxCommentLength);
// Fallback to old method in case the comment does not have any spaces/individual words
CharSequence commentText = precedingBoundary > 0 ? post.comment.subSequence(0, precedingBoundary) : post.comment.subSequence(0, maxCommentLength);
return TextUtils.concat(commentText, "\u2026"); // append ellipsis
}
private static BackgroundColorSpan BACKGROUND_SPAN = new BackgroundColorSpan(0x6633B5E5); private static BackgroundColorSpan BACKGROUND_SPAN = new BackgroundColorSpan(0x6633B5E5);
/** /**

@ -58,6 +58,17 @@ public class DeveloperSettingsController extends Controller {
LinearLayout wrapper = new LinearLayout(context); LinearLayout wrapper = new LinearLayout(context);
wrapper.setOrientation(LinearLayout.VERTICAL); wrapper.setOrientation(LinearLayout.VERTICAL);
Button logsButton = new Button(context);
logsButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
navigationController.pushController(new LogsController(context));
}
});
logsButton.setText(R.string.settings_open_logs);
wrapper.addView(logsButton);
Button crashButton = new Button(context); Button crashButton = new Button(context);
crashButton.setOnClickListener(new View.OnClickListener() { crashButton.setOnClickListener(new View.OnClickListener() {

@ -0,0 +1,107 @@
/*
* 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.controller;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.view.ViewGroup;
import android.widget.ScrollView;
import android.widget.TextView;
import android.widget.Toast;
import org.floens.chan.R;
import org.floens.chan.controller.Controller;
import org.floens.chan.ui.toolbar.ToolbarMenu;
import org.floens.chan.ui.toolbar.ToolbarMenuItem;
import org.floens.chan.ui.view.FloatingMenuItem;
import org.floens.chan.utils.AndroidUtils;
import org.floens.chan.utils.IOUtils;
import org.floens.chan.utils.Logger;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import static org.floens.chan.utils.AndroidUtils.getAttrColor;
public class LogsController extends Controller implements ToolbarMenuItem.ToolbarMenuItemCallback {
private static final String TAG = "LogsController";
private static final int COPY_ID = 101;
private TextView logTextView;
private String logText;
public LogsController(Context context) {
super(context);
}
@Override
public void onCreate() {
super.onCreate();
navigationItem.setTitle(org.floens.chan.R.string.settings_logs_screen);
navigationItem.menu = new ToolbarMenu(context);
List<FloatingMenuItem> items = new ArrayList<>();
items.add(new FloatingMenuItem(COPY_ID, R.string.settings_logs_copy));
navigationItem.createOverflow(context, this, items);
ScrollView container = new ScrollView(context);
container.setBackgroundColor(getAttrColor(context, org.floens.chan.R.attr.backcolor));
logTextView = new TextView(context);
container.addView(logTextView, new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
view = container;
loadLogs();
}
@Override
public void onMenuItemClicked(ToolbarMenuItem item) {
}
@Override
public void onSubMenuItemClicked(ToolbarMenuItem parent, FloatingMenuItem item) {
if ((int) item.getId() == COPY_ID) {
ClipboardManager clipboard = (ClipboardManager) AndroidUtils.getAppContext().getSystemService(Context.CLIPBOARD_SERVICE);
ClipData clip = ClipData.newPlainText("Logs", logText);
clipboard.setPrimaryClip(clip);
Toast.makeText(context, R.string.settings_logs_copied_to_clipboard, Toast.LENGTH_SHORT).show();
}
}
private void loadLogs() {
Process process;
try {
process = new ProcessBuilder()
.command("logcat", "-d", "-v", "tag")
.start();
} catch (IOException e) {
Logger.e(TAG, "Error starting logcat", e);
return;
}
InputStream outputStream = process.getInputStream();
logText = IOUtils.readString(outputStream);
logTextView.setText(logText);
}
}

@ -372,6 +372,15 @@ public class MainSettingsController extends SettingsController implements Toolba
} }
})); }));
if (((StartActivity) context).getVersionHandler().isUpdatingAvailable()) {
about.add(new LinkSettingView(this, R.string.settings_update_check, 0, new View.OnClickListener() {
@Override
public void onClick(View v) {
((StartActivity) context).getVersionHandler().manualUpdateCheck();
}
}));
}
int extraAbouts = context.getResources().getIdentifier("extra_abouts", "array", context.getPackageName()); int extraAbouts = context.getResources().getIdentifier("extra_abouts", "array", context.getPackageName());
if (extraAbouts != 0) { if (extraAbouts != 0) {
String[] abouts = context.getResources().getStringArray(extraAbouts); String[] abouts = context.getResources().getStringArray(extraAbouts);

@ -19,28 +19,23 @@ package org.floens.chan.ui.controller;
import android.Manifest; import android.Manifest;
import android.content.Context; import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.net.Uri;
import android.provider.Settings;
import android.support.design.widget.FloatingActionButton; import android.support.design.widget.FloatingActionButton;
import android.support.v7.app.AlertDialog;
import android.view.View; import android.view.View;
import org.floens.chan.R; import org.floens.chan.R;
import org.floens.chan.controller.Controller; import org.floens.chan.controller.Controller;
import org.floens.chan.core.model.FileItem;
import org.floens.chan.core.model.FileItems;
import org.floens.chan.core.saver.FileWatcher; import org.floens.chan.core.saver.FileWatcher;
import org.floens.chan.core.settings.ChanSettings; import org.floens.chan.core.settings.ChanSettings;
import org.floens.chan.ui.activity.StartActivity; import org.floens.chan.ui.activity.StartActivity;
import org.floens.chan.ui.adapter.FilesAdapter; import org.floens.chan.ui.adapter.FilesAdapter;
import org.floens.chan.ui.helper.RuntimePermissionsHelper; import org.floens.chan.ui.helper.RuntimePermissionsHelper;
import org.floens.chan.ui.layout.FilesLayout; import org.floens.chan.ui.layout.FilesLayout;
import org.floens.chan.utils.AndroidUtils;
import java.io.File; import java.io.File;
import static org.floens.chan.R.string.save_location_storage_permission_required;
import static org.floens.chan.R.string.save_location_storage_permission_required_title;
public class SaveLocationController extends Controller implements FileWatcher.FileWatcherCallback, FilesAdapter.Callback, FilesLayout.Callback, View.OnClickListener { public class SaveLocationController extends Controller implements FileWatcher.FileWatcherCallback, FilesAdapter.Callback, FilesLayout.Callback, View.OnClickListener {
private static final String TAG = "SaveLocationController"; private static final String TAG = "SaveLocationController";
@ -51,7 +46,7 @@ public class SaveLocationController extends Controller implements FileWatcher.Fi
private boolean gotPermission = false; private boolean gotPermission = false;
private FileWatcher fileWatcher; private FileWatcher fileWatcher;
private FileItems fileItems; private FileWatcher.FileItems fileItems;
public SaveLocationController(Context context) { public SaveLocationController(Context context) {
super(context); super(context);
@ -91,7 +86,7 @@ public class SaveLocationController extends Controller implements FileWatcher.Fi
} }
@Override @Override
public void onFiles(FileItems fileItems) { public void onFiles(FileWatcher.FileItems fileItems) {
this.fileItems = fileItems; this.fileItems = fileItems;
filesLayout.setFiles(fileItems); filesLayout.setFiles(fileItems);
} }
@ -102,7 +97,7 @@ public class SaveLocationController extends Controller implements FileWatcher.Fi
} }
@Override @Override
public void onFileItemClicked(FileItem fileItem) { public void onFileItemClicked(FileWatcher.FileItem fileItem) {
if (fileItem.canNavigate()) { if (fileItem.canNavigate()) {
fileWatcher.navigateTo(fileItem.file); fileWatcher.navigateTo(fileItem.file);
} }
@ -121,26 +116,17 @@ public class SaveLocationController extends Controller implements FileWatcher.Fi
if (gotPermission) { if (gotPermission) {
initialize(); initialize();
} else { } else {
new AlertDialog.Builder(context) runtimePermissionsHelper.showPermissionRequiredDialog(
.setTitle(R.string.write_permission_required_title) context,
.setMessage(R.string.write_permission_required) context.getString(save_location_storage_permission_required_title),
.setCancelable(false) context.getString(save_location_storage_permission_required),
.setNeutralButton(R.string.write_permission_app_settings, new DialogInterface.OnClickListener() { new RuntimePermissionsHelper.PermissionRequiredDialogCallback() {
@Override
public void onClick(DialogInterface dialog, int which) {
requestPermission();
Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.parse("package:" + context.getPackageName()));
AndroidUtils.openIntent(intent);
}
})
.setPositiveButton(R.string.write_permission_grant, new DialogInterface.OnClickListener() {
@Override @Override
public void onClick(DialogInterface dialog, int which) { public void retryPermissionRequest() {
requestPermission(); requestPermission();
} }
}) }
.show(); );
} }
} }
}); });

@ -86,7 +86,7 @@ public class PostHelper {
} }
} }
private static SimpleDateFormat dateFormat = new SimpleDateFormat("LL/dd/yy(EEE)kk:mm:ss", Locale.US); private static SimpleDateFormat dateFormat = new SimpleDateFormat("LL/dd/yy(EEE)HH:mm:ss", Locale.US);
private static Date tmpDate = new Date(); private static Date tmpDate = new Date();
public static String getLocalDate(Post post) { public static String getLocalDate(Post post) {

@ -1,118 +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.ui.helper;
import android.content.Context;
import android.content.DialogInterface;
import android.support.v7.app.AlertDialog;
import android.text.Html;
import android.widget.Button;
import org.floens.chan.R;
import org.floens.chan.core.settings.ChanSettings;
import org.floens.chan.utils.AndroidUtils;
import org.floens.chan.utils.Logger;
import java.io.File;
public class PreviousVersionHandler {
private static final String TAG = "PreviousVersionHandler";
/*
* Manifest version code, manifest version name, this version mapping:
*
* 28 = v1.1.2
* 32 = v1.1.3
* 36 = v1.2.0
* 39 = v1.2.1
* 40 = v1.2.2
* 41 = v1.2.3
* 42 = v1.2.4
* 43 = v1.2.5
* 44 = v1.2.6
* 46 = v1.2.7
* 47 = v1.2.8
* 48 = v1.2.9
* 49 = v1.2.10
* 50 = v1.2.11
* 51 = v2.0.0 = 1
* 52 = v2.1.0 = 2
* 53 = v2.1.1 = 2
* 54 = v2.1.2 = 2
* 55 = v2.1.3 = 2
* 56 = v2.2.0 = 3
*/
private static final int CURRENT_VERSION = 3;
public void run(Context context) {
int previous = ChanSettings.previousVersion.get();
if (previous < CURRENT_VERSION) {
if (previous < 1) {
cleanupOutdatedIonFolder(context);
}
// Add more previous version checks here
showMessage(context, CURRENT_VERSION);
ChanSettings.previousVersion.set(CURRENT_VERSION);
}
}
private void showMessage(Context context, int version) {
int resource = context.getResources().getIdentifier("previous_version_" + version, "string", context.getPackageName());
if (resource != 0) {
CharSequence message = Html.fromHtml(context.getString(resource));
final AlertDialog dialog = new AlertDialog.Builder(context)
.setMessage(message)
.setPositiveButton(R.string.ok, null)
.create();
dialog.show();
dialog.setCanceledOnTouchOutside(false);
final Button button = dialog.getButton(DialogInterface.BUTTON_POSITIVE);
button.setEnabled(false);
AndroidUtils.runOnUiThread(new Runnable() {
@Override
public void run() {
dialog.setCanceledOnTouchOutside(true);
button.setEnabled(true);
}
}, 1500);
}
}
private void cleanupOutdatedIonFolder(Context context) {
Logger.i(TAG, "Cleaning up old ion folder");
File ionCacheFolder = new File(context.getCacheDir() + "/ion");
if (ionCacheFolder.exists() && ionCacheFolder.isDirectory()) {
Logger.i(TAG, "Clearing old ion folder");
for (File file : ionCacheFolder.listFiles()) {
if (!file.delete()) {
Logger.i(TAG, "Could not delete old ion file " + file.getName());
}
}
if (!ionCacheFolder.delete()) {
Logger.i(TAG, "Could not delete old ion folder");
} else {
Logger.i(TAG, "Deleted old ion folder");
}
}
}
}

@ -18,10 +18,19 @@
package org.floens.chan.ui.helper; package org.floens.chan.ui.helper;
import android.app.Activity; import android.app.Activity;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.pm.PackageManager; import android.content.pm.PackageManager;
import android.net.Uri;
import android.provider.Settings;
import android.support.annotation.NonNull; import android.support.annotation.NonNull;
import android.support.v4.app.ActivityCompat; import android.support.v4.app.ActivityCompat;
import android.support.v4.content.ContextCompat; import android.support.v4.content.ContextCompat;
import android.support.v7.app.AlertDialog;
import org.floens.chan.R;
import org.floens.chan.utils.AndroidUtils;
import static org.floens.chan.utils.AndroidUtils.getAppContext; import static org.floens.chan.utils.AndroidUtils.getAppContext;
@ -71,6 +80,33 @@ public class RuntimePermissionsHelper {
} }
} }
public void showPermissionRequiredDialog(final Context context, String title, String message, final PermissionRequiredDialogCallback callback) {
new AlertDialog.Builder(context)
.setTitle(title)
.setMessage(message)
.setCancelable(false)
.setNeutralButton(R.string.permission_app_settings, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
callback.retryPermissionRequest();
Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.parse("package:" + context.getPackageName()));
AndroidUtils.openIntent(intent);
}
})
.setPositiveButton(R.string.permission_grant, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
callback.retryPermissionRequest();
}
})
.show();
}
public interface PermissionRequiredDialogCallback {
void retryPermissionRequest();
}
private class CallbackHolder { private class CallbackHolder {
private Callback callback; private Callback callback;
private String permission; private String permission;

@ -0,0 +1,286 @@
/*
* 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.helper;
import android.Manifest;
import android.app.ProgressDialog;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.support.v7.app.AlertDialog;
import android.text.Html;
import android.text.Spanned;
import android.widget.Button;
import org.floens.chan.BuildConfig;
import org.floens.chan.R;
import org.floens.chan.core.net.UpdateApiRequest;
import org.floens.chan.core.settings.ChanSettings;
import org.floens.chan.core.update.UpdateManager;
import org.floens.chan.utils.AndroidUtils;
import org.floens.chan.utils.Logger;
import java.io.File;
public class VersionHandler implements UpdateManager.UpdateCallback {
private static final String TAG = "VersionHandler";
/*
* Manifest version code, manifest version name, this version mapping:
*
* 28 = v1.1.2
* 32 = v1.1.3
* 36 = v1.2.0
* 39 = v1.2.1
* 40 = v1.2.2
* 41 = v1.2.3
* 42 = v1.2.4
* 43 = v1.2.5
* 44 = v1.2.6
* 46 = v1.2.7
* 47 = v1.2.8
* 48 = v1.2.9
* 49 = v1.2.10
* 50 = v1.2.11
* 51 = v2.0.0 = 1
* 52 = v2.1.0 = 2
* 53 = v2.1.1 = 2
* 54 = v2.1.2 = 2
* 55 = v2.1.3 = 2
* 56 = v2.2.0 = 3
* Since v2.3.0, this has been aligned with the versionCode as defined in build.gradle
* It is of the format XXYYZZ, where XX is major, YY is minor, ZZ is patch.
* 20300 = v2.3.0 = 20300
*/
private static final int CURRENT_VERSION = BuildConfig.VERSION_CODE;
/**
* Context to show dialogs to.
*/
private Context context;
private RuntimePermissionsHelper runtimePermissionsHelper;
private UpdateManager updateManager;
private ProgressDialog updateDownloadDialog;
public VersionHandler(Context context, RuntimePermissionsHelper runtimePermissionsHelper) {
this.context = context;
this.runtimePermissionsHelper = runtimePermissionsHelper;
updateManager = new UpdateManager(this);
}
/**
* Runs every time onCreate is called on the StartActivity.
*/
public void run() {
int previous = ChanSettings.previousVersion.get();
if (previous < CURRENT_VERSION) {
if (previous < 1) {
cleanupOutdatedIonFolder(context);
}
// Add more previous version checks here
showMessage(CURRENT_VERSION);
ChanSettings.previousVersion.set(CURRENT_VERSION);
// Don't process the updater because a dialog is now already showing.
return;
}
if (updateManager.isUpdatingAvailable()) {
updateManager.runUpdateApi(false);
}
}
public boolean isUpdatingAvailable() {
return updateManager.isUpdatingAvailable();
}
public void manualUpdateCheck() {
updateManager.runUpdateApi(true);
}
@Override
public void onManualCheckNone() {
new AlertDialog.Builder(context)
.setTitle(R.string.update_none)
.setPositiveButton(R.string.ok, null)
.show();
}
@Override
public void onManualCheckFailed() {
new AlertDialog.Builder(context)
.setTitle(R.string.update_check_failed)
.setPositiveButton(R.string.ok, null)
.show();
}
@Override
public void showUpdateAvailableDialog(final UpdateApiRequest.UpdateApiMessage message) {
Spanned text = Html.fromHtml(message.messageHtml);
final AlertDialog dialog = new AlertDialog.Builder(context)
.setMessage(text)
.setNegativeButton(R.string.update_later, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
updatePostponed(message);
}
})
.setPositiveButton(R.string.update_install, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
updateInstallRequested(message);
}
})
.create();
dialog.show();
dialog.setCanceledOnTouchOutside(false);
}
private void updatePostponed(UpdateApiRequest.UpdateApiMessage message) {
}
private void updateInstallRequested(final UpdateApiRequest.UpdateApiMessage message) {
runtimePermissionsHelper.requestPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE, new RuntimePermissionsHelper.Callback() {
@Override
public void onRuntimePermissionResult(boolean granted) {
if (granted) {
createDownloadProgressDialog();
updateManager.doUpdate(new UpdateManager.Update(message.apkUrl));
} else {
runtimePermissionsHelper.showPermissionRequiredDialog(context,
context.getString(R.string.update_storage_permission_required_title),
context.getString(R.string.update_storage_permission_required),
new RuntimePermissionsHelper.PermissionRequiredDialogCallback() {
@Override
public void retryPermissionRequest() {
updateInstallRequested(message);
}
});
}
}
});
}
private void createDownloadProgressDialog() {
updateDownloadDialog = new ProgressDialog(context);
updateDownloadDialog.setCancelable(false);
updateDownloadDialog.setTitle(R.string.update_install_downloading);
updateDownloadDialog.setMax(10000);
updateDownloadDialog.setProgressStyle(ProgressDialog.STYLE_HORIZONTAL);
updateDownloadDialog.setProgressNumberFormat("");
updateDownloadDialog.show();
}
@Override
public void onUpdateDownloadProgress(long downloaded, long total) {
updateDownloadDialog.setProgress((int) (updateDownloadDialog.getMax() * (downloaded / (double) total)));
}
@Override
public void onUpdateDownloadSuccess() {
updateDownloadDialog.dismiss();
updateDownloadDialog = null;
}
@Override
public void onUpdateDownloadFailed() {
updateDownloadDialog.dismiss();
updateDownloadDialog = null;
new AlertDialog.Builder(context)
.setTitle(R.string.update_install_download_failed)
.setPositiveButton(R.string.ok, null)
.show();
}
@Override
public void onUpdateDownloadMoveFailed() {
new AlertDialog.Builder(context)
.setTitle(R.string.update_install_download_move_failed)
.setPositiveButton(R.string.ok, null)
.show();
}
@Override
public void onUpdateOpenInstallScreen(Intent intent) {
AndroidUtils.openIntent(intent);
}
@Override
public void openUpdateRetryDialog(final UpdateManager.Install install) {
new AlertDialog.Builder(context)
.setTitle(R.string.update_retry_title)
.setMessage(R.string.update_retry)
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.update_retry_button, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
updateManager.retry(install);
}
})
.show();
}
private void showMessage(int version) {
int resource = context.getResources().getIdentifier("changelog_" + version, "string", context.getPackageName());
if (resource != 0) {
CharSequence message = Html.fromHtml(context.getString(resource));
final AlertDialog dialog = new AlertDialog.Builder(context)
.setMessage(message)
.setPositiveButton(R.string.ok, null)
.create();
dialog.show();
dialog.setCanceledOnTouchOutside(false);
final Button button = dialog.getButton(DialogInterface.BUTTON_POSITIVE);
button.setEnabled(false);
AndroidUtils.runOnUiThread(new Runnable() {
@Override
public void run() {
dialog.setCanceledOnTouchOutside(true);
button.setEnabled(true);
}
}, 1500);
}
}
private void cleanupOutdatedIonFolder(Context context) {
Logger.i(TAG, "Cleaning up old ion folder");
File ionCacheFolder = new File(context.getCacheDir() + "/ion");
if (ionCacheFolder.exists() && ionCacheFolder.isDirectory()) {
Logger.i(TAG, "Clearing old ion folder");
for (File file : ionCacheFolder.listFiles()) {
if (!file.delete()) {
Logger.i(TAG, "Could not delete old ion file " + file.getName());
}
}
if (!ionCacheFolder.delete()) {
Logger.i(TAG, "Could not delete old ion folder");
} else {
Logger.i(TAG, "Deleted old ion folder");
}
}
}
}

@ -30,8 +30,7 @@ import android.widget.LinearLayout;
import android.widget.TextView; import android.widget.TextView;
import org.floens.chan.R; import org.floens.chan.R;
import org.floens.chan.core.model.FileItem; import org.floens.chan.core.saver.FileWatcher;
import org.floens.chan.core.model.FileItems;
import org.floens.chan.ui.adapter.FilesAdapter; import org.floens.chan.ui.adapter.FilesAdapter;
import org.floens.chan.utils.RecyclerUtils; import org.floens.chan.utils.RecyclerUtils;
@ -51,7 +50,7 @@ public class FilesLayout extends LinearLayout implements FilesAdapter.Callback,
private Map<String, FileItemHistory> history = new HashMap<>(); private Map<String, FileItemHistory> history = new HashMap<>();
private FileItemHistory currentHistory; private FileItemHistory currentHistory;
private FileItems currentFileItems; private FileWatcher.FileItems currentFileItems;
private Callback callback; private Callback callback;
@ -91,7 +90,7 @@ public class FilesLayout extends LinearLayout implements FilesAdapter.Callback,
this.callback = callback; this.callback = callback;
} }
public void setFiles(FileItems fileItems) { public void setFiles(FileWatcher.FileItems fileItems) {
// Save the associated list position // Save the associated list position
if (currentFileItems != null) { if (currentFileItems != null) {
int[] indexTop = RecyclerUtils.getIndexAndTop(recyclerView); int[] indexTop = RecyclerUtils.getIndexAndTop(recyclerView);
@ -132,7 +131,7 @@ public class FilesLayout extends LinearLayout implements FilesAdapter.Callback,
} }
@Override @Override
public void onFileItemClicked(FileItem fileItem) { public void onFileItemClicked(FileWatcher.FileItem fileItem) {
currentHistory.clickedItem = fileItem; currentHistory.clickedItem = fileItem;
callback.onFileItemClicked(fileItem); callback.onFileItemClicked(fileItem);
} }
@ -147,12 +146,12 @@ public class FilesLayout extends LinearLayout implements FilesAdapter.Callback,
private class FileItemHistory { private class FileItemHistory {
int index, top; int index, top;
FileItem clickedItem; FileWatcher.FileItem clickedItem;
} }
public interface Callback { public interface Callback {
void onBackClicked(); void onBackClicked();
void onFileItemClicked(FileItem fileItem); void onFileItemClicked(FileWatcher.FileItem fileItem);
} }
} }

@ -39,7 +39,6 @@ import org.floens.chan.core.model.ChanThread;
import org.floens.chan.core.model.Loadable; import org.floens.chan.core.model.Loadable;
import org.floens.chan.core.site.http.Reply; import org.floens.chan.core.site.http.Reply;
import org.floens.chan.core.presenter.ReplyPresenter; import org.floens.chan.core.presenter.ReplyPresenter;
import org.floens.chan.core.settings.ChanSettings;
import org.floens.chan.ui.activity.StartActivity; import org.floens.chan.ui.activity.StartActivity;
import org.floens.chan.ui.captcha.CaptchaCallback; import org.floens.chan.ui.captcha.CaptchaCallback;
import org.floens.chan.ui.captcha.CaptchaLayout; import org.floens.chan.ui.captcha.CaptchaLayout;
@ -65,7 +64,7 @@ import static org.floens.chan.utils.AndroidUtils.setRoundItemBackground;
public class ReplyLayout extends LoadView implements View.OnClickListener, AnimationUtils.LayoutAnimationProgress, ReplyPresenter.ReplyPresenterCallback, TextWatcher, ImageDecoder.ImageDecoderCallback, SelectionListeningEditText.SelectionChangedListener { public class ReplyLayout extends LoadView implements View.OnClickListener, AnimationUtils.LayoutAnimationProgress, ReplyPresenter.ReplyPresenterCallback, TextWatcher, ImageDecoder.ImageDecoderCallback, SelectionListeningEditText.SelectionChangedListener {
private ReplyPresenter presenter; private ReplyPresenter presenter;
private ReplyLayoutCallback callback; private ReplyLayoutCallback callback;
private boolean newCaptcha = ChanSettings.postNewCaptcha.get(); private boolean newCaptcha;
private View replyInputLayout; private View replyInputLayout;
private FrameLayout captchaContainer; private FrameLayout captchaContainer;
@ -251,6 +250,11 @@ public class ReplyLayout extends LoadView implements View.OnClickListener, Anima
} }
} }
@Override
public void setCaptchaVersion(boolean newCaptcha) {
this.newCaptcha = newCaptcha;
}
@Override @Override
public void initCaptcha(String baseUrl, String siteKey, CaptchaCallback callback) { public void initCaptcha(String baseUrl, String siteKey, CaptchaCallback callback) {
authenticationLayout.initCaptcha(baseUrl, siteKey, ThemeHelper.getInstance().getTheme().isLightTheme, callback); authenticationLayout.initCaptcha(baseUrl, siteKey, ThemeHelper.getInstance().getTheme().isLightTheme, callback);

@ -21,6 +21,8 @@ import android.content.Context;
import android.graphics.Bitmap; import android.graphics.Bitmap;
import android.graphics.BitmapFactory; import android.graphics.BitmapFactory;
import android.graphics.Canvas; import android.graphics.Canvas;
import android.os.Handler;
import android.os.Looper;
import android.support.v7.widget.GridLayoutManager; import android.support.v7.widget.GridLayoutManager;
import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.RecyclerView; import android.support.v7.widget.RecyclerView;
@ -77,6 +79,15 @@ public class ThreadListLayout extends FrameLayout implements ReplyLayout.ReplyLa
private int lastPostCount; private int lastPostCount;
private int recyclerViewTopPadding; private int recyclerViewTopPadding;
private Handler mainHandler = new Handler(Looper.getMainLooper());
private RecyclerView.OnScrollListener scrollListener = new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
onRecyclerViewScrolled();
}
};
public ThreadListLayout(Context context, AttributeSet attrs) { public ThreadListLayout(Context context, AttributeSet attrs) {
super(context, attrs); super(context, attrs);
} }
@ -102,24 +113,7 @@ public class ThreadListLayout extends FrameLayout implements ReplyLayout.ReplyLa
postAdapter = new PostAdapter(recyclerView, postAdapterCallback, postCellCallback, statusCellCallback); postAdapter = new PostAdapter(recyclerView, postAdapterCallback, postCellCallback, statusCellCallback);
recyclerView.setAdapter(postAdapter); recyclerView.setAdapter(postAdapter);
recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() { recyclerView.addOnScrollListener(scrollListener);
@Override
public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
// onScrolled can be called after cleanup()
if (showingThread != null) {
int[] indexTop = getIndexAndTop();
showingThread.loadable.setListViewIndex(indexTop[0]);
showingThread.loadable.setListViewTop(indexTop[1]);
int last = getCompleteBottomAdapterPosition();
if (last == postAdapter.getItemCount() - 1 && last > lastPostCount) {
lastPostCount = last;
ThreadListLayout.this.callback.onListScrolledToBottom();
}
}
}
});
attachToolbarScroll(true); attachToolbarScroll(true);
@ -128,6 +122,30 @@ public class ThreadListLayout extends FrameLayout implements ReplyLayout.ReplyLa
searchStatus.getPaddingRight(), searchStatus.getPaddingBottom()); searchStatus.getPaddingRight(), searchStatus.getPaddingBottom());
} }
private void onRecyclerViewScrolled() {
// onScrolled can be called after cleanup()
if (showingThread != null) {
int[] indexTop = getIndexAndTop();
showingThread.loadable.setListViewIndex(indexTop[0]);
showingThread.loadable.setListViewTop(indexTop[1]);
int last = getCompleteBottomAdapterPosition();
if (last == postAdapter.getItemCount() - 1 && last > lastPostCount) {
lastPostCount = last;
// As requested by the RecyclerView, make sure that the adapter isn't changed
// while in a layout pass. Postpone to the next frame.
mainHandler.post(new Runnable() {
@Override
public void run() {
ThreadListLayout.this.callback.onListScrolledToBottom();
}
});
}
}
}
@Override @Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec); super.onMeasure(widthMeasureSpec, heightMeasureSpec);
@ -508,7 +526,7 @@ public class ThreadListLayout extends FrameLayout implements ReplyLayout.ReplyLa
RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) child.getLayoutParams(); RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) child.getLayoutParams();
int top = child.getTop() + params.topMargin; int top = child.getTop() + params.topMargin;
int left = child.getLeft() + params.leftMargin; int left = child.getLeft() + params.leftMargin;
c.drawBitmap(hat, left - parent.getPaddingLeft() - dp(40), top - dp(130) - parent.getPaddingTop() + toolbarHeight(), null); c.drawBitmap(hat, left - parent.getPaddingLeft() - dp(25), top - dp(80) - parent.getPaddingTop() + toolbarHeight(), null);
} }
} }
} }

@ -56,6 +56,8 @@ public class ThemeHelper {
themes.add(new Theme("Yotsuba", "yotsuba", R.style.Chan_Theme_Yotsuba, PrimaryColor.RED)); themes.add(new Theme("Yotsuba", "yotsuba", R.style.Chan_Theme_Yotsuba, PrimaryColor.RED));
themes.add(new Theme("Yotsuba B", "yotsuba_b", R.style.Chan_Theme_YotsubaB, PrimaryColor.RED)); themes.add(new Theme("Yotsuba B", "yotsuba_b", R.style.Chan_Theme_YotsubaB, PrimaryColor.RED));
themes.add(new Theme("Photon", "photon", R.style.Chan_Theme_Photon, PrimaryColor.ORANGE)); themes.add(new Theme("Photon", "photon", R.style.Chan_Theme_Photon, PrimaryColor.ORANGE));
themes.add(new DarkTheme("Insomnia", "insomnia", R.style.Chan_Theme_Insomnia, PrimaryColor.DARK));
themes.add(new DarkTheme("Gruvbox", "gruvbox", R.style.Chan_Theme_Gruvbox, PrimaryColor.DARK));
ChanSettings.ThemeColor settingTheme = ChanSettings.getThemeAndColor(); ChanSettings.ThemeColor settingTheme = ChanSettings.getThemeAndColor();
for (Theme theme : themes) { for (Theme theme : themes) {

@ -94,9 +94,6 @@ public class FloatingMenu {
public void setItems(List<FloatingMenuItem> items) { public void setItems(List<FloatingMenuItem> items) {
this.items = items; this.items = items;
if (popupWindow != null) {
popupWindow.dismiss();
}
} }
public void setSelectedItem(FloatingMenuItem item) { public void setSelectedItem(FloatingMenuItem item) {
@ -105,6 +102,9 @@ public class FloatingMenu {
public void setAdapter(ListAdapter adapter) { public void setAdapter(ListAdapter adapter) {
this.adapter = adapter; this.adapter = adapter;
if (popupWindow != null) {
popupWindow.setAdapter(adapter);
}
} }
public void setCallback(FloatingMenuCallback callback) { public void setCallback(FloatingMenuCallback callback) {

@ -20,7 +20,12 @@ package org.floens.chan.utils;
import android.content.Context; import android.content.Context;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.Closeable; import java.io.Closeable;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.io.InputStreamReader; import java.io.InputStreamReader;
@ -95,4 +100,24 @@ public class IOUtils {
output.write(buffer, 0, read); output.write(buffer, 0, read);
} }
} }
/**
* Copies the {@link File} specified by {@code in} to {@code out}.
* Both streams are always closed.
*
* @param in input file
* @param out output file
* @throws IOException thrown on copy exceptions.
*/
public static void copyFile(File in, File out) throws IOException {
InputStream is = null;
OutputStream os = null;
try {
copy(is = new BufferedInputStream(new FileInputStream(in)),
os = new BufferedOutputStream(new FileOutputStream(out)));
} finally {
IOUtils.closeQuietly(is);
IOUtils.closeQuietly(os);
}
}
} }

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

@ -19,73 +19,18 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Defined in gradle flavor --> <!-- Defined in gradle flavor -->
<!--<string name="app_name">Clover</string>--> <!--<string name="app_name">Clover</string>-->
<string name="previous_version_1"> <string name="changelog_20301">
<![CDATA[ <![CDATA[
<h3>Clover was updated</h3> <h3>Clover was updated</h3>
This is the beta of Clover v2, redesigned with the Google Material Design guidelines in mind.<br> Clover was updated to v2.3.1<br>
<br>
<b>Biggest changes:</b><br>
- New design.<br>
- New inline reply layout.<br>
- Support for thread hiding.<br>
- Support for filtering threads, making posts either highlighted, hidden or completely removed.<br>
- Support for history.<br>
- More themes were added.<br>
- Album downloads have been improved.<br>
- Catalog mode now uses cards.<br>
- More advanced options added.<br>
- Minor features and bug fixes.<br>
<br>
<b>Please provide feedback on GitHub or by writing an email, links are at the bottom of the settings screen.</b>
]]>
</string>
<string name="previous_version_2">
<![CDATA[
<h3>Clover was updated</h3>
This is the release of Clover v2, redesigned with the Material design guidelines in mind.<br>
<br>
<b>Biggest changes are:</b><br>
- New design.<br>
- New inline reply layout.<br>
- Added thread hiding.<br>
- Added filtering threads, making posts either highlighted, hidden or removed.<br>
- Added history.<br>
- Added more themes.<br>
- Better album download screen.<br>
- Catalog mode now uses cards.<br>
- More advanced options added.<br>
- Minor features and bug fixes.<br>
<br>
<b>Please provide feedback on GitHub or by writing an email, links are at the bottom of the settings screen.</b>
]]>
</string>
<string name="previous_version_3">
<![CDATA[
<h3>Clover was updated</h3>
Clover was updated to v2.2.0<br>
<br> <br>
<b>New in this version:</b><br> <b>New in this version:</b><br>
Add sliding of threads back.<br> Better link detection.<br>
Rewrote thread watching, it\'s more stable and works correctly with doze now.<br> More themes.<br>
Optimized parsing with multithreading.<br> Fix image loading taking a long time if the cache is full.<br>
Added album view.<br> Fix crashing bug related to the status view.<br>
Added bookmark clearing.<br> Bug fixes.
<br>
Added inline reporting.<br>
All boards are added by default now.<br>
Thread positions are retained across restarts.<br>
Allow setting of multiple types on filters simultaneously.<br>
Made single frame gifs zoomable.<br>
Add saving of images into their own board folder.<br>
More advanced settings.<br>
Many bug fixes.<br>
<br>
Many thanks to the contributors on GitHub!
]]> ]]>
</string> </string>
@ -100,6 +45,26 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<string name="undo">Undo</string> <string name="undo">Undo</string>
<string name="save">Save</string> <string name="save">Save</string>
<string name="permission_app_settings">App settings</string>
<string name="permission_grant">Grant</string>
<string name="update_later">Later</string>
<string name="update_none">Clover is up to date</string>
<string name="update_check_failed">Failed to check for updates.</string>
<string name="update_install">Install</string>
<string name="update_install_downloading">Downloading update</string>
<string name="update_install_download_failed">Download failed</string>
<string name="update_install_download_move_failed">Failed to move downloaded file to the Download directory.</string>
<string name="update_retry_title">Retry update</string>
<string name="update_retry">Clover was not updated yet. Click retry to retry the install.</string>
<string name="update_retry_button">retry</string>
<string name="update_storage_permission_required_title">Storage permission required</string>
<string name="update_storage_permission_required">
"Permission to access storage is required for installing the update.
Re-enable this permission in the app settings if you permanently disabled it."</string>
<plurals name="minutes"> <plurals name="minutes">
<item quantity="one">%d minute</item> <item quantity="one">%d minute</item>
<item quantity="other">%d minutes</item> <item quantity="other">%d minutes</item>
@ -356,15 +321,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<string name="filters_screen">Filters</string> <string name="filters_screen">Filters</string>
<string name="save_location_screen">Save location</string>
<string name="write_permission_required_title">Storage permission required</string>
<string name="write_permission_required">
"Permission to access storage is required for browsing files.
Re-enable this permission in the app settings if you permanently disabled it."</string>
<string name="write_permission_app_settings">App settings</string>
<string name="write_permission_grant">Grant</string>
<string name="album_download_screen">Select images (%1$d / %2$d)</string> <string name="album_download_screen">Select images (%1$d / %2$d)</string>
<string name="album_download_none_checked">Please select images to download</string> <string name="album_download_none_checked">Please select images to download</string>
<string name="album_download_confirm">%1$s will be downloaded to the folder %2$s</string> <string name="album_download_confirm">%1$s will be downloaded to the folder %2$s</string>
@ -428,19 +384,30 @@ Re-enable this permission in the app settings if you permanently disabled it."</
<string name="setting_post_pin">Pin thread on post</string> <string name="setting_post_pin">Pin thread on post</string>
<string name="settings_group_about">About</string> <string name="settings_group_about">About</string>
<string name="settings_update_check">Check for updates</string>
<string name="settings_about_license">Released under the GNU GPLv3 license</string> <string name="settings_about_license">Released under the GNU GPLv3 license</string>
<string name="settings_about_license_description">Tap to see license</string> <string name="settings_about_license_description">Tap to see license</string>
<string name="settings_about_licenses">Open Source Licenses</string> <string name="settings_about_licenses">Open Source Licenses</string>
<string name="settings_about_licenses_description">Legal information about licenses</string> <string name="settings_about_licenses_description">Legal information about licenses</string>
<string name="settings_developer">Developer settings</string> <string name="settings_developer">Developer settings</string>
<string name="settings_open_logs">View logs</string>
<string name="settings_logs_screen">Logs</string>
<string name="settings_logs_copy">Copy</string>
<string name="settings_logs_copied_to_clipboard">Copied to clipboard</string>
<string name="settings_screen_advanced">Advanced settings</string> <string name="settings_screen_advanced">Advanced settings</string>
<string name="settings_group_advanced">Advanced settings</string> <string name="settings_group_advanced">Advanced settings</string>
<string name="save_location_screen">Save location</string>
<string name="save_location_storage_permission_required_title">Storage permission required</string>
<string name="save_location_storage_permission_required">
"Permission to access storage is required for browsing files.
Re-enable this permission in the app settings if you permanently disabled it."</string>
<string name="setting_save_folder">File save folder</string> <string name="setting_save_folder">File save folder</string>
<string name="setting_save_folder_error_create_folder">Error creating save folder</string> <string name="setting_save_folder_error_create_folder">Error creating save folder</string>
<string name="setting_folder_pick_ok">Choose</string> <string name="setting_folder_pick_ok">Choose</string>
<string name="setting_use_new_captcha">Use the new captcha</string> <string name="setting_use_new_captcha">Use the new captcha</string>
<string name="setting_use_new_captcha_description">Enable to use the newer recaptcha.</string> <string name="setting_use_new_captcha_description">Enable to use the newer recaptcha for thread replies.</string>
<string name="setting_save_original_filename">Save original filename</string> <string name="setting_save_original_filename">Save original filename</string>
<string name="setting_save_board_folder">Save images in a board folder</string> <string name="setting_save_board_folder">Save images in a board folder</string>
<string name="setting_save_board_folder_description">Create a folder for each board to store images in</string> <string name="setting_save_board_folder_description">Create a folder for each board to store images in</string>

@ -162,6 +162,79 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</style> </style>
<style name="Chan.Theme.Insomnia" parent="Chan.Theme.Dark">
<item name="colorAccent">#ff5f89ac</item>
<item name="backcolor">#ff222222</item>
<item name="backcolor_secondary">#ff242424</item>
<item name="android:textColor">#ff9e9e9e</item>
<item name="text_color_primary">#ff9e9e9e</item>
<item name="text_color_secondary">#ff9e9e9e</item>
<item name="text_color_hint">#ff9e9e9e</item>
<item name="text_color_reveal_spoiler">#ff9e9e9e</item>
<item name="post_name_color">#ff566e78</item>
<item name="post_subject_color">#ff567861</item>
<item name="post_details_color">#ff444444</item>
<item name="post_quote_color">#ff7e949e</item>
<item name="post_highlight_quote_color">#ff81a2be</item>
<item name="post_inline_quote_color">#ffa4a987</item>
<item name="post_link_color">#ff81a2be</item>
<item name="post_spoiler_color">#ff242424</item>
<item name="post_capcode_color">#ff9e9e9e</item>
<item name="post_last_seen_color">#ff9e9e9e</item>
<item name="post_id_background_light">#00000000</item>
<item name="post_id_background_dark">#00000000</item>
<item name="post_saved_reply_color">#ff1d1d1d</item>
<item name="post_highlighted_color">#ff1d1d1d</item>
<item name="post_selected_color">#ff1d1d1d</item>
<item name="divider_color">#00222222</item>
<item name="divider_split_color">#00222222</item>
<item name="dropdown_light_color">#ffffffff</item>
<item name="dropdown_light_pressed_color">#ffffffff</item>
<item name="dropdown_dark_color">#ffffffff</item>
<item name="dropdown_dark_pressed_color">#ffffffff</item>
</style>
<style name="Chan.Theme.Gruvbox" parent="Chan.Theme.Dark">
<item name="colorAccent">#ebdbb2</item>
<item name="backcolor">#282828</item>
<item name="backcolor_secondary">#3c3836</item>
<item name="android:textColor">#ebdbb2</item>
<item name="text_color_primary">#ebdbb2</item>
<item name="text_color_secondary">#d5c4a1</item>
<item name="text_color_hint">#bdae93</item>
<item name="text_color_reveal_spoiler">#ebdbb2</item>
<item name="post_name_color">#427b58</item>
<item name="post_subject_color">#458588</item>
<item name="post_details_color">#a89984</item>
<item name="post_quote_color">#9d0006</item>
<item name="post_highlight_quote_color">#cc241d</item>
<item name="post_inline_quote_color">#79740e</item>
<item name="post_link_color">#458588</item>
<item name="post_spoiler_color">#ebdbb2</item>
<item name="post_capcode_color">#9d0006</item>
<item name="post_last_seen_color">#9d0006</item>
<item name="post_id_background_light">#ebdbb2</item>
<item name="post_saved_reply_color">#3c3836</item>
<item name="post_highlighted_color">#3c3836</item>
<item name="post_selected_color">#3c3836</item>
<item name="divider_color">#3c3836</item>
<item name="divider_split_color">#3c3836</item>
<item name="dropdown_dark_color">#ffffff</item>
<item name="dropdown_dark_pressed_color">#ffffff</item>
</style>
<!-- For FloatingMenu --> <!-- For FloatingMenu -->
<style name="ToolbarDropDownListViewStyle" parent="Widget.AppCompat.ListView.DropDown"> <style name="ToolbarDropDownListViewStyle" parent="Widget.AppCompat.ListView.DropDown">
<item name="android:background">#ffffffff</item> <item name="android:background">#ffffffff</item>

@ -4,7 +4,7 @@ buildscript {
jcenter() jcenter()
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:2.2.3' classpath 'com.android.tools.build:gradle:2.3.3'
} }
} }

@ -1,6 +1,6 @@
#Tue Aug 16 12:22:14 CEST 2016 #Tue Mar 07 00:32:11 EST 2017
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-2.14.1-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-3.3-all.zip

@ -1,46 +0,0 @@
mode=checkcaptcha only works on some boards!
Until a proper api exists for checking what boards have this option, it will not be implemented.
(that is, a proper way to check if "var preupload_captcha = true" was set)
Send to the normal url "https://sys.4chan.org/" + board + "/post";
Used in the extension for checking if the captcha is correct when submitting a large file
POST:
mode=checkcaptcha
challenge=<normal captcha challenge>
response=<user response>
returns json:
{
"token": "<captcha token>",
"error": "<error shown to the user>",
"fail": "<fail logged to console>"
}
In the extension:
if (response.token) {
a = $.id("qrCapToken"),
a.value = response.token,
a.removeAttribute("disabled"),
QR.submitDirect()
} else {
if (response.error) {
QR.reloadCaptcha(),
QR.btn.value = "Post",
QR.showPostError(response.error))
} else {
if (response.fail) {
console.log(b.fail),
QR.submitDirect()
}
}
}
qrCapToken is a hidden form field with the name captcha_token
So when token exists in the json, send a normal reply with captcha_token

@ -0,0 +1,22 @@
{
"api_version": 1,
"messages": [
{
"type": "update",
"code": 56,
"date": "2017-03-18T13:23:06.614104",
"message_html": "<h2>Clover v2.2.0 is available</h2>A new version of Clover is available.<br><br>This release fixes stuff.<br>- aaa<br>- bbb",
"apk": {
"default": {
"url": "https://github.com/Floens/Clover/releases/download/v2.2.0/Clover_v2.2.0.apk"
},
"fdroid": {
"url": "https://f-droid.org/repo/org.floens.chan_56.apk"
}
}
}
],
"check_interval": 432000000
}

@ -0,0 +1,29 @@
update_api.json describes the update check api that Clover loads periodically.
api_version
Version of this api, always 1.
check_interval
the interval of loading the file, overrides the default interval of 5 days if set.
messages
array of messages
type:
type of the message, only "update" is supported
code:
code of the new version. if this is higher than the code of the calling app then the message will be processed.
date:
ISO8601 date, parsed but not used for now.
message_html:
message shown to the user, parsed with Html.fromHtml()
apk:
set of apks for each flavor. each key is only parsed if it equals to the flavor name that the app is compiled for.
url:
url of the apk file to download and install.
Loading…
Cancel
Save