Update the ImageSaver to use a task based approach. Do not use DownloadManager for album downloads, also fixes saving to an external sd card problems. A persistent notification is now shown while downloading, to avoid clover being killed when downloading.multisite
@ -0,0 +1,219 @@ |
||||
/* |
||||
* 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.saver; |
||||
|
||||
import android.content.Intent; |
||||
import android.graphics.Bitmap; |
||||
import android.media.MediaScannerConnection; |
||||
import android.net.Uri; |
||||
|
||||
import org.floens.chan.Chan; |
||||
import org.floens.chan.core.cache.FileCache; |
||||
import org.floens.chan.core.model.PostImage; |
||||
import org.floens.chan.utils.AndroidUtils; |
||||
import org.floens.chan.utils.IOUtils; |
||||
import org.floens.chan.utils.ImageDecoder; |
||||
import org.floens.chan.utils.Logger; |
||||
|
||||
import java.io.File; |
||||
import java.io.FileInputStream; |
||||
import java.io.FileOutputStream; |
||||
import java.io.IOException; |
||||
import java.io.InputStream; |
||||
import java.io.OutputStream; |
||||
|
||||
import static org.floens.chan.utils.AndroidUtils.dp; |
||||
import static org.floens.chan.utils.AndroidUtils.getAppContext; |
||||
|
||||
public class ImageSaveTask implements Runnable, FileCache.DownloadedCallback { |
||||
private static final String TAG = "ImageSaveTask"; |
||||
|
||||
private PostImage postImage; |
||||
private ImageSaveTaskCallback callback; |
||||
private File destination; |
||||
private boolean share; |
||||
private boolean makeBitmap; |
||||
private Bitmap bitmap; |
||||
private boolean showToast; |
||||
|
||||
private boolean success = false; |
||||
|
||||
public ImageSaveTask(PostImage postImage) { |
||||
this.postImage = postImage; |
||||
} |
||||
|
||||
public void setCallback(ImageSaveTaskCallback callback) { |
||||
this.callback = callback; |
||||
} |
||||
|
||||
public PostImage getPostImage() { |
||||
return postImage; |
||||
} |
||||
|
||||
public void setDestination(File destination) { |
||||
this.destination = destination; |
||||
} |
||||
|
||||
public File getDestination() { |
||||
return destination; |
||||
} |
||||
|
||||
public void setShare(boolean share) { |
||||
this.share = share; |
||||
} |
||||
|
||||
public void setMakeBitmap(boolean makeBitmap) { |
||||
this.makeBitmap = makeBitmap; |
||||
} |
||||
|
||||
public boolean isMakeBitmap() { |
||||
return makeBitmap; |
||||
} |
||||
|
||||
public Bitmap getBitmap() { |
||||
return bitmap; |
||||
} |
||||
|
||||
public void setShowToast(boolean showToast) { |
||||
this.showToast = showToast; |
||||
} |
||||
|
||||
public boolean isShowToast() { |
||||
return showToast; |
||||
} |
||||
|
||||
@Override |
||||
public void run() { |
||||
try { |
||||
Logger.test("Start! " + toString()); |
||||
|
||||
if (destination.exists()) { |
||||
onDestination(); |
||||
} else { |
||||
FileCache.FileCacheDownloader fileCacheDownloader = Chan.getFileCache().downloadFile(postImage.imageUrl, this); |
||||
// If the fileCacheDownloader is null then the callbacks were already executed here,
|
||||
// else wait for the download to finish to avoid that the next task is immediately executed.
|
||||
if (fileCacheDownloader != null) { |
||||
// If the file is now downloading
|
||||
fileCacheDownloader.getFuture().get(); |
||||
} |
||||
} |
||||
|
||||
Logger.test("End! " + toString()); |
||||
} catch (InterruptedException e) { |
||||
onInterrupted(); |
||||
} catch (Exception e) { |
||||
Logger.e(TAG, "Uncaught exception", e); |
||||
} finally { |
||||
postFinished(success); |
||||
} |
||||
} |
||||
|
||||
@Override |
||||
public void onProgress(long downloaded, long total, boolean done) { |
||||
} |
||||
|
||||
@Override |
||||
public void onFail(boolean notFound) { |
||||
} |
||||
|
||||
@Override |
||||
public void onSuccess(File file) { |
||||
copyToDestination(file); |
||||
onDestination(); |
||||
} |
||||
|
||||
private void onInterrupted() { |
||||
if (destination.exists()) { |
||||
if (!destination.delete()) { |
||||
Logger.e(TAG, "Could not delete destination after an interrupt"); |
||||
} |
||||
} |
||||
} |
||||
|
||||
private void onDestination() { |
||||
scanDestination(); |
||||
if (makeBitmap) { |
||||
bitmap = ImageDecoder.decodeFile(destination, dp(512), dp(256)); |
||||
} |
||||
} |
||||
|
||||
private void copyToDestination(File source) { |
||||
InputStream is = null; |
||||
OutputStream os = null; |
||||
try { |
||||
File parent = destination.getParentFile(); |
||||
if (!parent.mkdirs() && !parent.isDirectory()) { |
||||
throw new IOException("Could not create parent directory"); |
||||
} |
||||
|
||||
if (destination.isDirectory()) { |
||||
throw new IOException("Destination file is already a directory"); |
||||
} |
||||
|
||||
is = new FileInputStream(source); |
||||
os = new FileOutputStream(destination); |
||||
IOUtils.copy(is, os); |
||||
} catch (IOException e) { |
||||
Logger.e(TAG, "Error writing to file", e); |
||||
} finally { |
||||
IOUtils.closeQuietly(is); |
||||
IOUtils.closeQuietly(os); |
||||
} |
||||
success = true; |
||||
} |
||||
|
||||
private void scanDestination() { |
||||
MediaScannerConnection.scanFile(getAppContext(), new String[]{destination.getAbsolutePath()}, null, new MediaScannerConnection.OnScanCompletedListener() { |
||||
@Override |
||||
public void onScanCompleted(String path, final Uri uri) { |
||||
// Runs on a binder thread
|
||||
AndroidUtils.runOnUiThread(new Runnable() { |
||||
@Override |
||||
public void run() { |
||||
afterScan(uri); |
||||
} |
||||
}); |
||||
} |
||||
}); |
||||
} |
||||
|
||||
private void afterScan(final Uri uri) { |
||||
Logger.d(TAG, "Media scan succeeded: " + uri); |
||||
|
||||
if (share) { |
||||
Intent intent = new Intent(Intent.ACTION_SEND); |
||||
intent.setType("image/*"); |
||||
intent.putExtra(Intent.EXTRA_STREAM, uri); |
||||
AndroidUtils.openIntent(intent); |
||||
} |
||||
} |
||||
|
||||
private void postFinished(final boolean success) { |
||||
AndroidUtils.runOnUiThread(new Runnable() { |
||||
@Override |
||||
public void run() { |
||||
callback.imageSaveTaskFinished(ImageSaveTask.this, success); |
||||
} |
||||
}); |
||||
} |
||||
|
||||
public interface ImageSaveTaskCallback { |
||||
void imageSaveTaskFinished(ImageSaveTask task, boolean success); |
||||
} |
||||
} |
@ -0,0 +1,213 @@ |
||||
/* |
||||
* 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.saver; |
||||
|
||||
import android.app.NotificationManager; |
||||
import android.content.Context; |
||||
import android.content.Intent; |
||||
import android.support.v7.app.NotificationCompat; |
||||
import android.widget.Toast; |
||||
|
||||
import org.floens.chan.R; |
||||
import org.floens.chan.core.model.PostImage; |
||||
import org.floens.chan.core.settings.ChanSettings; |
||||
import org.floens.chan.ui.service.SavingNotification; |
||||
|
||||
import java.io.File; |
||||
import java.util.List; |
||||
import java.util.concurrent.ExecutorService; |
||||
import java.util.concurrent.Executors; |
||||
import java.util.regex.Pattern; |
||||
|
||||
import de.greenrobot.event.EventBus; |
||||
|
||||
import static org.floens.chan.utils.AndroidUtils.getAppContext; |
||||
import static org.floens.chan.utils.AndroidUtils.getString; |
||||
|
||||
public class ImageSaver implements ImageSaveTask.ImageSaveTaskCallback { |
||||
private static final String TAG = "ImageSaver"; |
||||
private static final int NOTIFICATION_ID = 3; |
||||
private static final int MAX_NAME_LENGTH = 50; |
||||
private static final Pattern REPEATED_UNDERSCORES_PATTERN = Pattern.compile("_+"); |
||||
private static final Pattern SAFE_CHARACTERS_PATTERN = Pattern.compile("[^a-zA-Z0-9._]"); |
||||
private static final ImageSaver instance = new ImageSaver(); |
||||
|
||||
private NotificationManager notificationManager; |
||||
private ExecutorService executor = Executors.newSingleThreadExecutor(); |
||||
private int doneTasks = 0; |
||||
private int totalTasks = 0; |
||||
|
||||
public static ImageSaver getInstance() { |
||||
return instance; |
||||
} |
||||
|
||||
private ImageSaver() { |
||||
EventBus.getDefault().register(this); |
||||
notificationManager = (NotificationManager) getAppContext().getSystemService(Context.NOTIFICATION_SERVICE); |
||||
} |
||||
|
||||
public void startBundledTask(String subFolder, List<ImageSaveTask> tasks) { |
||||
for (ImageSaveTask task : tasks) { |
||||
PostImage postImage = task.getPostImage(); |
||||
String fileName = filterName(postImage.originalName + "." + postImage.extension); |
||||
task.setDestination(new File(getSaveLocation() + File.separator + subFolder + File.separator + fileName)); |
||||
|
||||
startTask(task); |
||||
} |
||||
updateNotification(); |
||||
} |
||||
|
||||
public String getSubFolder(String name) { |
||||
String filtered = filterName(name); |
||||
filtered = filtered.substring(0, Math.min(filtered.length(), MAX_NAME_LENGTH)); |
||||
return filtered; |
||||
} |
||||
|
||||
public void startDownloadTask(ImageSaveTask task) { |
||||
PostImage postImage = task.getPostImage(); |
||||
String name = ChanSettings.saveOriginalFilename.get() ? postImage.originalName : postImage.filename; |
||||
String fileName = filterName(name + "." + postImage.extension); |
||||
task.setDestination(findUnusedFileName(new File(getSaveLocation(), fileName), false)); |
||||
|
||||
// task.setMakeBitmap(true);
|
||||
task.setShowToast(true); |
||||
|
||||
startTask(task); |
||||
updateNotification(); |
||||
} |
||||
|
||||
public File getSaveLocation() { |
||||
return new File(ChanSettings.saveLocation.get()); |
||||
} |
||||
|
||||
@Override |
||||
public void imageSaveTaskFinished(ImageSaveTask task, boolean success) { |
||||
doneTasks++; |
||||
if (doneTasks == totalTasks) { |
||||
totalTasks = 0; |
||||
doneTasks = 0; |
||||
} |
||||
updateNotification(); |
||||
|
||||
if (task.isMakeBitmap()) { |
||||
showImageSaved(task); |
||||
} |
||||
if (task.isShowToast()) { |
||||
showToast(task); |
||||
} |
||||
} |
||||
|
||||
public void onEvent(SavingNotification.SavingCancelRequestMessage message) { |
||||
cancelAll(); |
||||
} |
||||
|
||||
private void startTask(ImageSaveTask task) { |
||||
task.setCallback(this); |
||||
|
||||
totalTasks++; |
||||
executor.execute(task); |
||||
} |
||||
|
||||
private void cancelAll() { |
||||
executor.shutdownNow(); |
||||
executor = Executors.newSingleThreadExecutor(); |
||||
|
||||
totalTasks = 0; |
||||
doneTasks = 0; |
||||
updateNotification(); |
||||
} |
||||
|
||||
private void updateNotification() { |
||||
Intent service = new Intent(getAppContext(), SavingNotification.class); |
||||
if (totalTasks == 0) { |
||||
getAppContext().stopService(service); |
||||
} else { |
||||
service.putExtra(SavingNotification.DONE_TASKS_KEY, doneTasks); |
||||
service.putExtra(SavingNotification.TOTAL_TASKS_KEY, totalTasks); |
||||
getAppContext().startService(service); |
||||
} |
||||
} |
||||
|
||||
private void showImageSaved(ImageSaveTask task) { |
||||
NotificationCompat.Builder builder = new NotificationCompat.Builder(getAppContext()); |
||||
builder.setSmallIcon(R.drawable.ic_stat_notify); |
||||
builder.setContentTitle(getString(R.string.image_save_saved)); |
||||
String savedAs = getAppContext().getString(R.string.image_save_as, task.getDestination().getName()); |
||||
builder.setContentText(savedAs); |
||||
builder.setPriority(NotificationCompat.PRIORITY_HIGH); |
||||
builder.setStyle(new NotificationCompat.BigPictureStyle() |
||||
.bigPicture(task.getBitmap()) |
||||
.setSummaryText(savedAs)); |
||||
|
||||
notificationManager.notify(NOTIFICATION_ID, builder.build()); |
||||
} |
||||
|
||||
private void showToast(ImageSaveTask task) { |
||||
String savedAs = getAppContext().getString(R.string.image_save_as, task.getDestination().getName()); |
||||
Toast.makeText(getAppContext(), savedAs, Toast.LENGTH_LONG).show(); |
||||
} |
||||
|
||||
private String filterName(String name) { |
||||
name = name.replace(' ', '_'); |
||||
name = SAFE_CHARACTERS_PATTERN.matcher(name).replaceAll(""); |
||||
name = REPEATED_UNDERSCORES_PATTERN.matcher(name).replaceAll("_"); |
||||
if (name.length() == 0) { |
||||
name = "_"; |
||||
} |
||||
return name; |
||||
} |
||||
|
||||
private File findUnusedFileName(File start, boolean directory) { |
||||
String base; |
||||
String extension; |
||||
|
||||
if (directory) { |
||||
base = start.getAbsolutePath(); |
||||
extension = null; |
||||
} else { |
||||
String[] splitted = start.getAbsolutePath().split("\\.(?=[^\\.]+$)"); |
||||
if (splitted.length == 2) { |
||||
base = splitted[0]; |
||||
extension = "." + splitted[1]; |
||||
} else { |
||||
base = splitted[0]; |
||||
extension = "."; |
||||
} |
||||
} |
||||
|
||||
File test; |
||||
if (directory) { |
||||
test = new File(base); |
||||
} else { |
||||
test = new File(base + extension); |
||||
} |
||||
|
||||
int index = 0; |
||||
int tries = 0; |
||||
while (test.exists() && tries++ < 100) { |
||||
if (directory) { |
||||
test = new File(base + "_" + index); |
||||
} else { |
||||
test = new File(base + "_" + index + extension); |
||||
} |
||||
index++; |
||||
} |
||||
|
||||
return test; |
||||
} |
||||
} |
@ -0,0 +1,271 @@ |
||||
/* |
||||
* 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.Context; |
||||
import android.content.DialogInterface; |
||||
import android.graphics.drawable.Drawable; |
||||
import android.support.design.widget.FloatingActionButton; |
||||
import android.support.v4.graphics.drawable.DrawableCompat; |
||||
import android.support.v7.app.AlertDialog; |
||||
import android.support.v7.widget.GridLayoutManager; |
||||
import android.support.v7.widget.RecyclerView; |
||||
import android.view.LayoutInflater; |
||||
import android.view.View; |
||||
import android.view.ViewGroup; |
||||
import android.view.animation.DecelerateInterpolator; |
||||
import android.widget.ImageView; |
||||
|
||||
import org.floens.chan.R; |
||||
import org.floens.chan.controller.Controller; |
||||
import org.floens.chan.core.model.Loadable; |
||||
import org.floens.chan.core.model.PostImage; |
||||
import org.floens.chan.core.saver.ImageSaveTask; |
||||
import org.floens.chan.core.saver.ImageSaver; |
||||
import org.floens.chan.ui.theme.ThemeHelper; |
||||
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.ui.view.GridRecyclerView; |
||||
import org.floens.chan.ui.view.ThumbnailView; |
||||
import org.floens.chan.utils.RecyclerUtils; |
||||
|
||||
import java.util.ArrayList; |
||||
import java.util.List; |
||||
|
||||
import static org.floens.chan.utils.AndroidUtils.dp; |
||||
|
||||
public class AlbumDownloadController extends Controller implements ToolbarMenuItem.ToolbarMenuItemCallback, View.OnClickListener { |
||||
private static final int CHECK_ALL = 1; |
||||
|
||||
private GridRecyclerView recyclerView; |
||||
private GridLayoutManager gridLayoutManager; |
||||
private FloatingActionButton download; |
||||
|
||||
private List<AlbumDownloadItem> items = new ArrayList<>(); |
||||
private Loadable loadable; |
||||
|
||||
private boolean allChecked = true; |
||||
private AlbumAdapter adapter; |
||||
private ImageSaver imageSaver; |
||||
|
||||
public AlbumDownloadController(Context context) { |
||||
super(context); |
||||
} |
||||
|
||||
@Override |
||||
public void onCreate() { |
||||
super.onCreate(); |
||||
|
||||
imageSaver = ImageSaver.getInstance(); |
||||
|
||||
view = inflateRes(R.layout.controller_album_download); |
||||
|
||||
updateTitle(); |
||||
|
||||
navigationItem.menu = new ToolbarMenu(context); |
||||
navigationItem.menu.addItem(new ToolbarMenuItem(context, this, CHECK_ALL, R.drawable.ic_select_all_white_24dp)); |
||||
|
||||
download = (FloatingActionButton) view.findViewById(R.id.download); |
||||
download.setOnClickListener(this); |
||||
recyclerView = (GridRecyclerView) view.findViewById(R.id.recycler_view); |
||||
recyclerView.setHasFixedSize(true); |
||||
gridLayoutManager = new GridLayoutManager(context, 3); |
||||
recyclerView.setLayoutManager(gridLayoutManager); |
||||
recyclerView.setSpanWidth(dp(90)); |
||||
|
||||
adapter = new AlbumAdapter(); |
||||
recyclerView.setAdapter(adapter); |
||||
} |
||||
|
||||
@Override |
||||
public void onClick(View v) { |
||||
if (v == download) { |
||||
int checkCount = getCheckCount(); |
||||
if (checkCount == 0) { |
||||
new AlertDialog.Builder(context) |
||||
.setMessage(R.string.album_download_none_checked) |
||||
.setPositiveButton(R.string.ok, null) |
||||
.show(); |
||||
} else { |
||||
final String folderForAlbum = imageSaver.getSubFolder(loadable.title); |
||||
|
||||
String message = context.getString(R.string.album_download_confirm, |
||||
context.getResources().getQuantityString(R.plurals.image, checkCount, checkCount), |
||||
folderForAlbum); |
||||
|
||||
new AlertDialog.Builder(context) |
||||
.setMessage(message) |
||||
.setNegativeButton(R.string.cancel, null) |
||||
.setPositiveButton(R.string.ok, new DialogInterface.OnClickListener() { |
||||
@Override |
||||
public void onClick(DialogInterface dialog, int which) { |
||||
List<ImageSaveTask> tasks = new ArrayList<>(items.size()); |
||||
for (AlbumDownloadItem item : items) { |
||||
if (item.checked) { |
||||
tasks.add(new ImageSaveTask(item.postImage)); |
||||
} |
||||
} |
||||
|
||||
imageSaver.startBundledTask(folderForAlbum, tasks); |
||||
navigationController.popController(); |
||||
} |
||||
}) |
||||
.show(); |
||||
} |
||||
} |
||||
} |
||||
|
||||
@Override |
||||
public void onMenuItemClicked(ToolbarMenuItem menuItem) { |
||||
if ((Integer) menuItem.getId() == CHECK_ALL) { |
||||
RecyclerUtils.clearRecyclerCache(recyclerView); |
||||
|
||||
for (int i = 0, itemsSize = items.size(); i < itemsSize; i++) { |
||||
AlbumDownloadItem item = items.get(i); |
||||
if (item.checked == allChecked) { |
||||
item.checked = !allChecked; |
||||
AlbumDownloadCell cell = (AlbumDownloadCell) recyclerView.findViewHolderForAdapterPosition(i); |
||||
if (cell != null) { |
||||
setItemChecked(cell, item.checked, true); |
||||
} |
||||
} |
||||
} |
||||
updateAllChecked(); |
||||
updateTitle(); |
||||
} |
||||
} |
||||
|
||||
@Override |
||||
public void onSubMenuItemClicked(ToolbarMenuItem parent, FloatingMenuItem item) { |
||||
} |
||||
|
||||
public void setPostImages(Loadable loadable, List<PostImage> postImages) { |
||||
this.loadable = loadable; |
||||
for (int i = 0, postImagesSize = postImages.size(); i < postImagesSize; i++) { |
||||
PostImage postImage = postImages.get(i); |
||||
items.add(new AlbumDownloadItem(postImage, true, i)); |
||||
} |
||||
} |
||||
|
||||
private void updateTitle() { |
||||
navigationItem.title = context.getString(R.string.album_download_screen, getCheckCount(), items.size()); |
||||
navigationItem.updateTitle(); |
||||
} |
||||
|
||||
private void updateAllChecked() { |
||||
allChecked = getCheckCount() == items.size(); |
||||
} |
||||
|
||||
private int getCheckCount() { |
||||
int checkCount = 0; |
||||
for (AlbumDownloadItem item : items) { |
||||
if (item.checked) { |
||||
checkCount++; |
||||
} |
||||
} |
||||
return checkCount; |
||||
} |
||||
|
||||
private static class AlbumDownloadItem { |
||||
public PostImage postImage; |
||||
public boolean checked; |
||||
public int id; |
||||
|
||||
public AlbumDownloadItem(PostImage postImage, boolean checked, int id) { |
||||
this.postImage = postImage; |
||||
this.checked = checked; |
||||
this.id = id; |
||||
} |
||||
} |
||||
|
||||
private class AlbumAdapter extends RecyclerView.Adapter<AlbumDownloadCell> { |
||||
public AlbumAdapter() { |
||||
setHasStableIds(true); |
||||
} |
||||
|
||||
@Override |
||||
public AlbumDownloadCell onCreateViewHolder(ViewGroup parent, int viewType) { |
||||
return new AlbumDownloadCell(LayoutInflater.from(parent.getContext()).inflate(R.layout.cell_album_download, parent, false)); |
||||
} |
||||
|
||||
@Override |
||||
public void onBindViewHolder(AlbumDownloadCell holder, int position) { |
||||
AlbumDownloadItem item = items.get(position); |
||||
|
||||
holder.thumbnailView.setUrl(item.postImage.thumbnailUrl, dp(100), dp(100)); |
||||
setItemChecked(holder, item.checked, false); |
||||
} |
||||
|
||||
@Override |
||||
public int getItemCount() { |
||||
return items.size(); |
||||
} |
||||
|
||||
@Override |
||||
public long getItemId(int position) { |
||||
return items.get(position).id; |
||||
} |
||||
} |
||||
|
||||
private class AlbumDownloadCell extends RecyclerView.ViewHolder implements View.OnClickListener { |
||||
private ImageView checkbox; |
||||
private ThumbnailView thumbnailView; |
||||
|
||||
public AlbumDownloadCell(View itemView) { |
||||
super(itemView); |
||||
itemView.getLayoutParams().height = recyclerView.getRealSpanWidth(); |
||||
checkbox = (ImageView) itemView.findViewById(R.id.checkbox); |
||||
thumbnailView = (ThumbnailView) itemView.findViewById(R.id.thumbnail_view); |
||||
itemView.setOnClickListener(this); |
||||
} |
||||
|
||||
@Override |
||||
public void onClick(View v) { |
||||
int adapterPosition = getAdapterPosition(); |
||||
AlbumDownloadItem item = items.get(adapterPosition); |
||||
item.checked = !item.checked; |
||||
updateAllChecked(); |
||||
updateTitle(); |
||||
setItemChecked(this, item.checked, true); |
||||
} |
||||
} |
||||
|
||||
@SuppressWarnings("deprecation") |
||||
private void setItemChecked(AlbumDownloadCell cell, boolean checked, boolean animated) { |
||||
float scale = checked ? 0.75f : 1f; |
||||
if (animated) { |
||||
cell.thumbnailView.animate().scaleX(scale).scaleY(scale) |
||||
.setInterpolator(new DecelerateInterpolator(3f)).setDuration(500).start(); |
||||
} else { |
||||
cell.thumbnailView.setScaleX(scale); |
||||
cell.thumbnailView.setScaleY(scale); |
||||
} |
||||
|
||||
Drawable drawable = context.getResources().getDrawable(checked ? R.drawable.ic_check_circle_white_24dp : |
||||
R.drawable.ic_radio_button_unchecked_white_24dp); |
||||
|
||||
if (checked) { |
||||
Drawable wrapped = DrawableCompat.wrap(drawable); |
||||
DrawableCompat.setTint(wrapped, ThemeHelper.PrimaryColor.BLUE.color); |
||||
cell.checkbox.setImageDrawable(wrapped); |
||||
} else { |
||||
cell.checkbox.setImageDrawable(drawable); |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,108 @@ |
||||
/* |
||||
* 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.service; |
||||
|
||||
import android.app.Notification; |
||||
import android.app.NotificationManager; |
||||
import android.app.PendingIntent; |
||||
import android.app.Service; |
||||
import android.content.Intent; |
||||
import android.os.Bundle; |
||||
import android.os.IBinder; |
||||
import android.support.annotation.Nullable; |
||||
import android.support.v7.app.NotificationCompat; |
||||
|
||||
import org.floens.chan.R; |
||||
|
||||
import de.greenrobot.event.EventBus; |
||||
|
||||
import static org.floens.chan.utils.AndroidUtils.getAppContext; |
||||
|
||||
public class SavingNotification extends Service { |
||||
public static final String DONE_TASKS_KEY = "done_tasks"; |
||||
public static final String TOTAL_TASKS_KEY = "total_tasks"; |
||||
private static final String CANCEL_KEY = "cancel"; |
||||
|
||||
private static final int NOTIFICATION_ID = 2; |
||||
|
||||
private NotificationManager notificationManager; |
||||
|
||||
private boolean inForeground = false; |
||||
private int doneTasks; |
||||
private int totalTasks; |
||||
|
||||
@Nullable |
||||
@Override |
||||
public IBinder onBind(Intent intent) { |
||||
return null; |
||||
} |
||||
|
||||
@Override |
||||
public void onCreate() { |
||||
super.onCreate(); |
||||
notificationManager = (NotificationManager) getSystemService(NOTIFICATION_SERVICE); |
||||
} |
||||
|
||||
@Override |
||||
public void onDestroy() { |
||||
super.onDestroy(); |
||||
notificationManager.cancel(NOTIFICATION_ID); |
||||
} |
||||
|
||||
@Override |
||||
public int onStartCommand(Intent intent, int flags, int startId) { |
||||
if (intent != null && intent.getExtras() != null) { |
||||
Bundle extras = intent.getExtras(); |
||||
|
||||
if (extras.getBoolean(CANCEL_KEY)) { |
||||
EventBus.getDefault().post(new SavingCancelRequestMessage()); |
||||
} else { |
||||
doneTasks = extras.getInt(DONE_TASKS_KEY); |
||||
totalTasks = extras.getInt(TOTAL_TASKS_KEY); |
||||
|
||||
if (!inForeground) { |
||||
startForeground(NOTIFICATION_ID, getNotification()); |
||||
inForeground = true; |
||||
} else { |
||||
notificationManager.notify(NOTIFICATION_ID, getNotification()); |
||||
} |
||||
} |
||||
} |
||||
|
||||
return START_STICKY; |
||||
} |
||||
|
||||
private Notification getNotification() { |
||||
NotificationCompat.Builder builder = new NotificationCompat.Builder(getAppContext()); |
||||
builder.setSmallIcon(R.drawable.ic_stat_notify); |
||||
builder.setContentTitle(getString(R.string.image_save_notification_downloading)); |
||||
builder.setContentText(getString(R.string.image_save_notification_cancel)); |
||||
builder.setProgress(totalTasks, doneTasks, false); |
||||
builder.setContentInfo(doneTasks + "/" + totalTasks); |
||||
|
||||
Intent intent = new Intent(this, SavingNotification.class); |
||||
intent.putExtra(CANCEL_KEY, true); |
||||
PendingIntent pendingIntent = PendingIntent.getService(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); |
||||
builder.setContentIntent(pendingIntent); |
||||
|
||||
return builder.build(); |
||||
} |
||||
|
||||
public static class SavingCancelRequestMessage { |
||||
} |
||||
} |
@ -0,0 +1,75 @@ |
||||
/* |
||||
* 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.view; |
||||
|
||||
import android.content.Context; |
||||
import android.support.v7.widget.GridLayoutManager; |
||||
import android.support.v7.widget.RecyclerView; |
||||
import android.util.AttributeSet; |
||||
|
||||
/** |
||||
* A RecyclerView with a GridLayoutManager that manages the span count by dividing the width of the |
||||
* view with the value set by {@link #setSpanWidth(int)}. |
||||
*/ |
||||
public class GridRecyclerView extends RecyclerView { |
||||
private GridLayoutManager gridLayoutManager; |
||||
private int spanWidth; |
||||
private int realSpanWidth; |
||||
|
||||
public GridRecyclerView(Context context) { |
||||
super(context); |
||||
} |
||||
|
||||
public GridRecyclerView(Context context, AttributeSet attrs) { |
||||
super(context, attrs); |
||||
} |
||||
|
||||
public GridRecyclerView(Context context, AttributeSet attrs, int defStyle) { |
||||
super(context, attrs, defStyle); |
||||
} |
||||
|
||||
public void setLayoutManager(GridLayoutManager gridLayoutManager) { |
||||
this.gridLayoutManager = gridLayoutManager; |
||||
super.setLayoutManager(gridLayoutManager); |
||||
} |
||||
|
||||
/** |
||||
* Set the width of each span in pixels. |
||||
* |
||||
* @param spanWidth width of each span in pixels. |
||||
*/ |
||||
public void setSpanWidth(int spanWidth) { |
||||
this.spanWidth = spanWidth; |
||||
} |
||||
|
||||
public int getRealSpanWidth() { |
||||
return realSpanWidth; |
||||
} |
||||
|
||||
@Override |
||||
protected void onMeasure(int widthSpec, int heightSpec) { |
||||
super.onMeasure(widthSpec, heightSpec); |
||||
int spanCount = Math.max(1, getMeasuredWidth() / spanWidth); |
||||
gridLayoutManager.setSpanCount(spanCount); |
||||
int oldRealSpanWidth = realSpanWidth; |
||||
realSpanWidth = getMeasuredWidth() / spanCount; |
||||
if (realSpanWidth != oldRealSpanWidth) { |
||||
getAdapter().notifyDataSetChanged(); |
||||
} |
||||
} |
||||
} |
@ -1,307 +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.utils; |
||||
|
||||
import android.app.AlertDialog; |
||||
import android.app.DownloadManager; |
||||
import android.content.BroadcastReceiver; |
||||
import android.content.Context; |
||||
import android.content.DialogInterface; |
||||
import android.content.Intent; |
||||
import android.content.IntentFilter; |
||||
import android.media.MediaScannerConnection; |
||||
import android.net.Uri; |
||||
import android.widget.Toast; |
||||
|
||||
import org.floens.chan.Chan; |
||||
import org.floens.chan.R; |
||||
import org.floens.chan.core.cache.FileCache; |
||||
import org.floens.chan.core.settings.ChanSettings; |
||||
|
||||
import java.io.File; |
||||
import java.io.FileInputStream; |
||||
import java.io.FileOutputStream; |
||||
import java.io.IOException; |
||||
import java.io.InputStream; |
||||
import java.io.OutputStream; |
||||
import java.util.ArrayList; |
||||
import java.util.List; |
||||
import java.util.concurrent.atomic.AtomicBoolean; |
||||
|
||||
public class ImageSaver { |
||||
private static final String TAG = "ImageSaver"; |
||||
private static final ImageSaver instance = new ImageSaver(); |
||||
|
||||
public static ImageSaver getInstance() { |
||||
return instance; |
||||
} |
||||
|
||||
private BroadcastReceiver receiver; |
||||
|
||||
public void saveAll(final Context context, String folderName, final List<DownloadPair> list) { |
||||
final File subFolder = new File(ChanSettings.saveLocation.get() + File.separator + filterName(folderName)); |
||||
|
||||
String text = context.getString(R.string.image_download_confirm, Integer.toString(list.size()), subFolder.getAbsolutePath()); |
||||
|
||||
new AlertDialog.Builder(context).setMessage(text).setNegativeButton(R.string.cancel, null) |
||||
.setPositiveButton(R.string.ok, new DialogInterface.OnClickListener() { |
||||
@Override |
||||
public void onClick(DialogInterface dialog, int which) { |
||||
listDownload(context, subFolder, list); |
||||
} |
||||
}).show(); |
||||
} |
||||
|
||||
public void saveImage(final Context context, String imageUrl, final String name, final String extension, final boolean share) { |
||||
Chan.getFileCache().downloadFile(imageUrl, new FileCache.DownloadedCallback() { |
||||
@Override |
||||
@SuppressWarnings("deprecation") |
||||
public void onProgress(long downloaded, long total, boolean done) { |
||||
} |
||||
|
||||
@Override |
||||
public void onSuccess(final File source) { |
||||
onFileDownloaded(context, name, extension, source, share); |
||||
} |
||||
|
||||
@Override |
||||
public void onFail(boolean notFound) { |
||||
Toast.makeText(context, R.string.image_open_failed, Toast.LENGTH_LONG).show(); |
||||
} |
||||
}); |
||||
} |
||||
|
||||
private void onFileDownloaded(final Context context, final String name, final String extension, final File downloaded, boolean share) { |
||||
if (share) { |
||||
scanFile(context, downloaded, true); |
||||
} else { |
||||
/*if (!Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) { |
||||
Toast.makeText(context, R.string.image_save_not_mounted, Toast.LENGTH_LONG).show(); |
||||
return; |
||||
}*/ |
||||
|
||||
new Thread(new Runnable() { |
||||
@Override |
||||
public void run() { |
||||
File saveDir = new File(ChanSettings.saveLocation.get()); |
||||
|
||||
if (!saveDir.isDirectory() && !saveDir.mkdirs()) { |
||||
showToast(context, context.getString(R.string.image_save_directory_error)); |
||||
return; |
||||
} |
||||
|
||||
String fileName = filterName(name + "." + extension); |
||||
File destination = findUnused(new File(saveDir, fileName), false); |
||||
|
||||
final boolean success = storeImage(downloaded, destination); |
||||
if (success) { |
||||
scanFile(context, destination, false); |
||||
showToast(context, context.getString(R.string.image_save_succeeded) + " " + destination.getAbsolutePath()); |
||||
} else { |
||||
showToast(context, context.getString(R.string.image_save_failed)); |
||||
} |
||||
} |
||||
}).start(); |
||||
} |
||||
} |
||||
|
||||
private void showToast(final Context context, final String message) { |
||||
AndroidUtils.runOnUiThread(new Runnable() { |
||||
@Override |
||||
public void run() { |
||||
Toast.makeText(context, message, Toast.LENGTH_LONG).show(); |
||||
} |
||||
}); |
||||
} |
||||
|
||||
private void listDownload(Context context, File subFolder, final List<DownloadPair> list) { |
||||
final DownloadManager dm = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE); |
||||
|
||||
if (!subFolder.isDirectory() && !subFolder.mkdirs()) { |
||||
Toast.makeText(context, R.string.image_save_directory_error, Toast.LENGTH_LONG).show(); |
||||
return; |
||||
} |
||||
|
||||
final List<Pair> files = new ArrayList<>(list.size()); |
||||
for (DownloadPair uri : list) { |
||||
File destination = new File(subFolder, filterName(uri.imageName)); |
||||
if (destination.exists()) continue; |
||||
|
||||
Pair p = new Pair(); |
||||
p.uri = Uri.parse(uri.imageUrl); |
||||
p.file = destination; |
||||
files.add(p); |
||||
} |
||||
|
||||
final AtomicBoolean stopped = new AtomicBoolean(false); |
||||
final List<Long> ids = new ArrayList<>(); |
||||
|
||||
new Thread(new Runnable() { |
||||
@Override |
||||
public void run() { |
||||
for (Pair pair : files) { |
||||
if (stopped.get()) { |
||||
break; |
||||
} |
||||
|
||||
DownloadManager.Request request; |
||||
try { |
||||
request = new DownloadManager.Request(pair.uri); |
||||
} catch (IllegalArgumentException e) { |
||||
continue; |
||||
} |
||||
|
||||
request.setDestinationUri(Uri.fromFile(pair.file)); |
||||
request.setVisibleInDownloadsUi(false); |
||||
request.allowScanningByMediaScanner(); |
||||
|
||||
synchronized (stopped) { |
||||
if (stopped.get()) { |
||||
break; |
||||
} |
||||
ids.add(dm.enqueue(request)); |
||||
} |
||||
} |
||||
} |
||||
}).start(); |
||||
|
||||
receiver = new BroadcastReceiver() { |
||||
@Override |
||||
public void onReceive(Context context, Intent intent) { |
||||
if (DownloadManager.ACTION_NOTIFICATION_CLICKED.equals(intent.getAction())) { |
||||
synchronized (stopped) { |
||||
stopped.set(true); |
||||
|
||||
for (Long id : ids) { |
||||
dm.remove(id); |
||||
} |
||||
} |
||||
} else if (DownloadManager.ACTION_DOWNLOAD_COMPLETE.equals(intent.getAction())) { |
||||
if (receiver != null) { |
||||
context.unregisterReceiver(receiver); |
||||
receiver = null; |
||||
} |
||||
} |
||||
} |
||||
}; |
||||
|
||||
IntentFilter filter = new IntentFilter(); |
||||
filter.addAction(DownloadManager.ACTION_NOTIFICATION_CLICKED); |
||||
filter.addAction(DownloadManager.ACTION_DOWNLOAD_COMPLETE); |
||||
|
||||
context.registerReceiver(receiver, filter); |
||||
} |
||||
|
||||
private String filterName(String name) { |
||||
return name.replaceAll("[^a-zA-Z0-9.]", "_"); |
||||
} |
||||
|
||||
private File findUnused(File start, boolean isDir) { |
||||
String base; |
||||
String extension; |
||||
|
||||
if (isDir) { |
||||
base = start.getAbsolutePath(); |
||||
extension = null; |
||||
} else { |
||||
String[] splitted = start.getAbsolutePath().split("\\.(?=[^\\.]+$)"); |
||||
if (splitted.length == 2) { |
||||
base = splitted[0]; |
||||
extension = "." + splitted[1]; |
||||
} else { |
||||
base = splitted[0]; |
||||
extension = "."; |
||||
} |
||||
} |
||||
|
||||
File test; |
||||
if (isDir) { |
||||
test = new File(base); |
||||
} else { |
||||
test = new File(base + extension); |
||||
} |
||||
int index = 0; |
||||
int tries = 0; |
||||
while (test.exists() && tries++ < 100) { |
||||
if (isDir) { |
||||
test = new File(base + "_" + index); |
||||
} else { |
||||
test = new File(base + "_" + index + extension); |
||||
} |
||||
index++; |
||||
} |
||||
|
||||
return test; |
||||
} |
||||
|
||||
private boolean storeImage(final File source, final File destination) { |
||||
boolean res = true; |
||||
InputStream is = null; |
||||
OutputStream os = null; |
||||
try { |
||||
is = new FileInputStream(source); |
||||
os = new FileOutputStream(destination); |
||||
IOUtils.copy(is, os); |
||||
} catch (IOException e) { |
||||
res = false; |
||||
} finally { |
||||
IOUtils.closeQuietly(is); |
||||
IOUtils.closeQuietly(os); |
||||
} |
||||
|
||||
return res; |
||||
} |
||||
|
||||
private void scanFile(final Context context, final File file, final boolean shareAfterwards) { |
||||
MediaScannerConnection.scanFile(context, new String[]{file.getAbsolutePath()}, null, |
||||
new MediaScannerConnection.OnScanCompletedListener() { |
||||
@Override |
||||
public void onScanCompleted(String unused, final Uri uri) { |
||||
AndroidUtils.runOnUiThread(new Runnable() { |
||||
@Override |
||||
public void run() { |
||||
Logger.d(TAG, "Media scan succeeded: " + uri); |
||||
|
||||
if (shareAfterwards) { |
||||
Intent intent = new Intent(Intent.ACTION_SEND); |
||||
intent.setType("image/*"); |
||||
intent.putExtra(Intent.EXTRA_STREAM, uri); |
||||
context.startActivity(Intent.createChooser(intent, context.getString(R.string.action_share))); |
||||
} |
||||
} |
||||
}); |
||||
} |
||||
} |
||||
); |
||||
} |
||||
|
||||
public static class DownloadPair { |
||||
public String imageUrl; |
||||
public String imageName; |
||||
|
||||
public DownloadPair(String uri, String name) { |
||||
this.imageUrl = uri; |
||||
this.imageName = name; |
||||
} |
||||
} |
||||
|
||||
private static class Pair { |
||||
public Uri uri; |
||||
public File file; |
||||
} |
||||
} |
@ -0,0 +1,37 @@ |
||||
/* |
||||
* 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.utils; |
||||
|
||||
import android.support.v7.widget.RecyclerView; |
||||
|
||||
import java.lang.reflect.Field; |
||||
|
||||
public class RecyclerUtils { |
||||
private static final String TAG = "RecyclerUtils"; |
||||
|
||||
public static void clearRecyclerCache(RecyclerView recyclerView) { |
||||
try { |
||||
Field field = RecyclerView.class.getDeclaredField("mRecycler"); |
||||
field.setAccessible(true); |
||||
RecyclerView.Recycler recycler = (RecyclerView.Recycler) field.get(recyclerView); |
||||
recycler.clear(); |
||||
} catch (Exception e) { |
||||
Logger.e(TAG, "Error clearing RecyclerView cache with reflection", e); |
||||
} |
||||
} |
||||
} |
After Width: | Height: | Size: 399 B |
After Width: | Height: | Size: 461 B |
After Width: | Height: | Size: 222 B |
After Width: | Height: | Size: 267 B |
After Width: | Height: | Size: 310 B |
After Width: | Height: | Size: 141 B |
After Width: | Height: | Size: 510 B |
After Width: | Height: | Size: 637 B |
After Width: | Height: | Size: 196 B |
After Width: | Height: | Size: 738 B |
After Width: | Height: | Size: 927 B |
After Width: | Height: | Size: 262 B |
After Width: | Height: | Size: 963 B |
After Width: | Height: | Size: 1.2 KiB |
After Width: | Height: | Size: 315 B |
@ -0,0 +1,39 @@ |
||||
<?xml version="1.0" encoding="utf-8"?><!-- |
||||
Clover - 4chan browser https://github.com/Floens/Clover/ |
||||
Copyright (C) 2014 Floens |
||||
|
||||
This program is free software: you can redistribute it and/or modify |
||||
it under the terms of the GNU General Public License as published by |
||||
the Free Software Foundation, either version 3 of the License, or |
||||
(at your option) any later version. |
||||
|
||||
This program is distributed in the hope that it will be useful, |
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of |
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||||
GNU General Public License for more details. |
||||
|
||||
You should have received a copy of the GNU General Public License |
||||
along with this program. If not, see <http://www.gnu.org/licenses/>. |
||||
--> |
||||
<FrameLayout |
||||
xmlns:android="http://schemas.android.com/apk/res/android" |
||||
xmlns:tools="http://schemas.android.com/tools" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="0dp" |
||||
tools:ignore="ContentDescription,RtlHardcoded,RtlSymmetry"> |
||||
|
||||
<org.floens.chan.ui.view.ThumbnailView |
||||
android:id="@+id/thumbnail_view" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="match_parent" |
||||
android:padding="5dp"/> |
||||
|
||||
<ImageView |
||||
android:id="@+id/checkbox" |
||||
android:layout_width="wrap_content" |
||||
android:layout_height="wrap_content" |
||||
android:layout_gravity="left|top" |
||||
android:paddingLeft="7dp" |
||||
android:paddingTop="7dp"/> |
||||
|
||||
</FrameLayout> |
@ -0,0 +1,44 @@ |
||||
<?xml version="1.0" encoding="utf-8"?><!-- |
||||
Clover - 4chan browser https://github.com/Floens/Clover/ |
||||
Copyright (C) 2014 Floens |
||||
|
||||
This program is free software: you can redistribute it and/or modify |
||||
it under the terms of the GNU General Public License as published by |
||||
the Free Software Foundation, either version 3 of the License, or |
||||
(at your option) any later version. |
||||
|
||||
This program is distributed in the hope that it will be useful, |
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of |
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||||
GNU General Public License for more details. |
||||
|
||||
You should have received a copy of the GNU General Public License |
||||
along with this program. If not, see <http://www.gnu.org/licenses/>. |
||||
--> |
||||
<android.support.design.widget.CoordinatorLayout |
||||
xmlns:android="http://schemas.android.com/apk/res/android" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="match_parent" |
||||
android:background="?backcolor"> |
||||
|
||||
<org.floens.chan.ui.view.GridRecyclerView |
||||
android:id="@+id/recycler_view" |
||||
xmlns:android="http://schemas.android.com/apk/res/android" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="match_parent" |
||||
android:background="#ff000000" |
||||
android:clipToPadding="false" |
||||
android:padding="5dp" |
||||
android:paddingBottom="72dp" |
||||
android:scrollbarStyle="outsideOverlay" |
||||
android:scrollbars="vertical"/> |
||||
|
||||
<android.support.design.widget.FloatingActionButton |
||||
android:id="@+id/download" |
||||
android:layout_width="wrap_content" |
||||
android:layout_height="wrap_content" |
||||
android:layout_gravity="right|bottom" |
||||
android:layout_margin="16dp" |
||||
android:src="@drawable/ic_file_download_white_24dp"/> |
||||
|
||||
</android.support.design.widget.CoordinatorLayout> |
@ -1,6 +1,6 @@ |
||||
#Thu May 14 11:43:47 CEST 2015 |
||||
#Tue Aug 11 17:05:27 CEST 2015 |
||||
distributionBase=GRADLE_USER_HOME |
||||
distributionPath=wrapper/dists |
||||
zipStoreBase=GRADLE_USER_HOME |
||||
zipStorePath=wrapper/dists |
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-2.4-all.zip |
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-2.4-bin.zip |
||||
|