src/main/java/anabolicandroids/chanobol/ui/posts/PostsActivity.java
public class PostsActivity extends SwipeRefreshActivity {

    // Construction ////////////////////////////////////////////////////////////////////////////////

    private static Thread threadTransfer;
    // backup
    private static final String EXTRA_THREAD = "thread";

    // Transition related
    private static final String EXTRA_FROM_MEDIAACTIVITY = "fromMediaActivity";
    private static final String EXTRA_SILENT = "silent";
    private static final String EXTRA_TRANSITIONNAME = "transitionName";
    private boolean fromMediaActivity;
    private boolean silent;
    private String transitionName;
    private BitmapDrawable opImage;

    // Essential state
    private static String EXTRA_ROOT = "root";
    private static String EXTRA_START_INDEX = "startIndex";
    private static String THREAD = "thread";
    private Thread thread;

    // Internal state
    private PostsAdapter postsAdapter;
    // To remove all the bitmaps from Ion's cache once the activity's been destroyed
    private static String BITMAP_CACHE_KEYS = "bitmapCacheKeys";
    private ArrayList<String> bitmapCacheKeys;
    // Convenience
    private String boardName;
    private String threadNumber;
    private LinearLayoutManager layoutManager;

    // Scrolling via volume keys
    private int SCROLL_BY_HEIGHT;

    private static void launch(
            Class activityClass, Activity callerActivity,
            View transitionView, String transitionName,
            Thread thread, int index,
            boolean fromNavbarWatchlist, boolean fromMediaActivity,
            boolean excludeFromRecents, boolean silently
    ) {
        if (callerActivity instanceof PostsActivity) {
            // Special case: Thread was opened from catalog, is scrolled and reopened from
            // watchlist in nav drawer. Then the scroll position should be remembered correctly.
            PostsActivity pa = (PostsActivity) callerActivity;
            if (thread.id.equals(pa.thread.id)) {
                pa.persistPositionMaybe();
            }
        }

        // Must not be null or there will be glitches with the first PostView!
        if (transitionName == null) transitionName = "not null";
        if (transitionView != null) ViewCompat.setTransitionName(transitionView, transitionName);
        ActivityOptionsCompat options = makeSceneTransitionAnimation(callerActivity,
                Pair.create(transitionView, transitionName)
        );
        Intent intent = new Intent(callerActivity, activityClass);
        intent.putExtra(EXTRA_TRANSITIONNAME, transitionName);
        intent.putExtra(EXTRA_ROOT, fromNavbarWatchlist);
        intent.putExtra(EXTRA_THREAD, Parcels.wrap(new Thread(thread.boardName, thread.threadNumber)));
        intent.putExtra(EXTRA_START_INDEX, index);
        threadTransfer = thread;
        if (silently) {
            intent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION);
            intent.putExtra(EXTRA_SILENT, true);
        }
        if (excludeFromRecents) intent.addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS);
        if (fromMediaActivity) {
            intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
            intent.putExtra(EXTRA_FROM_MEDIAACTIVITY, true);
        }
        if (fromNavbarWatchlist) {
            intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
            callerActivity.startActivity(intent);
        } else {
            ActivityCompat.startActivity(callerActivity, intent, options.toBundle());
        }
    }

    public static void launch(Activity activity, View transitionView, String transitionName, Thread thread) {
        launch(PostsActivity.class, activity, transitionView, transitionName, thread, -1, false, false, false, false);
    }

    public static void launch(
            Activity activity, View transitionView, String transitionName,
            Post opPost, String boardName, String threadNumber
    ) {
        Thread thread = new Thread(boardName, threadNumber);
        thread.posts.add(opPost);
        launch(activity, transitionView, transitionName, thread);
    }

    public static void launchFromNavbarWatchlist(Activity activity, Thread thread, boolean excludeFromRecents) {
        launch(WatchlistPostsActivity.class, activity, null, null, thread, -1, true, false, excludeFromRecents, false);
    }

    public static void launchFromMediaActivity(Activity activity, Thread thread, int index) {
        launch(PostsActivity.class, activity, null, null, thread, index, false, true, false, false);
    }

    public static void launchSilently(Activity activity, Thread thread) {
        launch(PostsActivity.class, activity, null, null, thread, -1, false, false, false, true);
    }

    @Inject ImageSaver imageSaver;

    @InjectView(R.id.posts) RecyclerView postsView;

    @Override protected int getLayoutResource() { return R.layout.activity_posts; }
    @Override protected RecyclerView getRootRecyclerView() { return postsView; }

    @Override protected void onCreate(Bundle savedInstanceState) {
        supportPostponeEnterTransition();
        Bundle b = getIntent().getExtras();
        taskRoot = b.getBoolean(EXTRA_ROOT);
        silent = b.getBoolean(EXTRA_SILENT);
        fromMediaActivity = b.getBoolean(EXTRA_FROM_MEDIAACTIVITY);
        super.onCreate(savedInstanceState);

        transitionName = b.getString(EXTRA_TRANSITIONNAME);
        SCROLL_BY_HEIGHT = Util.getScreenHeight(this) - Util.getActionBarHeight(this);

        // Ugly quick fix (transaction too large)
        thread = Parcels.unwrap(b.getParcelable(EXTRA_THREAD));
        if (threadTransfer != null && threadTransfer.threadNumber.equals(thread.threadNumber))
            thread = threadTransfer.deepCopy();
        threadTransfer = null;

        boolean inWatchlist = persistentData.isInWatchlist(thread.id);
        if (inWatchlist) {
            Thread watchlistThread = persistentData.getWatchlistThread(thread.id);
            if (watchlistThread != null) thread = watchlistThread;
        }
        boardName = thread.boardName;
        threadNumber = thread.threadNumber;
        Post opPost = thread.opPost();

        if (opPost != null) {
            String thumbUrl = ApiModule.thumbUrl(boardName, opPost.mediaId);
            try {
                opImage = new BitmapDrawable(getResources(), ion.build(this).load(thumbUrl).asBitmap().get(500, TimeUnit.MILLISECONDS));
                if (opImage.getBitmap() != null) {
                    Palette palette = Palette.generate(opImage.getBitmap());
                    int primaryDark = getResources().getColor(R.color.colorPrimaryDark);
                    opPost.thumbMutedColor = palette.getMutedColor(primaryDark);
                }
            } catch (InterruptedException | ExecutionException | TimeoutException e) {
                e.printStackTrace();
            }
        }

        if (savedInstanceState == null) {
            firstLoad = true;
            bitmapCacheKeys = new ArrayList<>();
        } else {
            firstLoad = false;
            thread = Parcels.unwrap(savedInstanceState.getParcelable(THREAD));
            bitmapCacheKeys = Parcels.unwrap(savedInstanceState.getParcelable(BITMAP_CACHE_KEYS));

            previousToolbarPosition = savedInstanceState.getFloat(PREVIOUS_TOOLBAR_POSITION);
            previousStackHeight = savedInstanceState.getInt(PREVIOUS_STACK_HEIGHT);
            previousTitle = savedInstanceState.getString(PREVIOUS_TITLE);
        }

        setTitle(thread.title());

        postsAdapter = new PostsAdapter();
        postsView.setAdapter(postsAdapter);
        layoutManager = new LinearLayoutManager(this);
        postsView.setLayoutManager(layoutManager);
        postsView.addItemDecoration(new SpacesItemDecoration((int) resources.getDimension(R.dimen.post_spacing)));

        // Subtle because of interaction with loading last viewed position in watchlist thread
        // Can most probably be made more uniform/elegant.
        int startIndex = b.getInt(EXTRA_START_INDEX);
        if (inWatchlist && startIndex == -1) {
            postsView.scrollToPosition(thread.lastVisibleIndex);
        } else if (savedInstanceState == null && startIndex != -1) {
            postsView.scrollToPosition(startIndex);
        }

        getSupportFragmentManager().addOnBackStackChangedListener(backStackChangedListener);
        setupUpLongClickDismissAll();

        if (firstLoad && opPost != null) {
            postsAdapter.notifyItemChanged(0);
        }

        if (inWatchlist || fromMediaActivity) {
            firstLoad = false;
            postsAdapter.notifyDataSetChanged();
        } else {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
                getWindow().getSharedElementEnterTransition().addListener(new TransitionListenerAdapter() {
                    @Override public void onTransitionEnd(Transition transition) {
                        revealAnimationCallback.markTransitionFinished();
                    }
                });
                getWindow().setEnterTransition(TransitionInflater.from(this).inflateTransition(android.R.transition.slide_right));
                getWindow().setReturnTransition(TransitionInflater.from(this).inflateTransition(android.R.transition.slide_right));
            } else {
                revealAnimationCallback.markTransitionFinished();
            }
        }

        if (savedInstanceState == null) load();

        // Why this trivial delay? Otherwise the transition glitches for some reason.
        postsView.postDelayed(new Runnable() {
            @Override public void run() {
                supportStartPostponedEnterTransition();
            }
        }, inWatchlist ? 100 : 20);
    }

    @Override protected void onSaveInstanceState(Bundle outState) {
        super.onSaveInstanceState(outState);
        outState.putParcelable(THREAD, Parcels.wrap(thread));
        outState.putParcelable(BITMAP_CACHE_KEYS, Parcels.wrap(bitmapCacheKeys));

        outState.putString(PREVIOUS_TITLE, previousTitle);
        outState.putFloat(PREVIOUS_TOOLBAR_POSITION, previousToolbarPosition);
        outState.putInt(PREVIOUS_STACK_HEIGHT, previousStackHeight);
    }

    // Callbacks ///////////////////////////////////////////////////////////////////////////////////

    public static interface RepliesCallback {
        public void onClick(Post post);
    }
    RepliesCallback repliesCallback = new RepliesCallback() {
        @Override public void onClick(final Post post) {
            postsView.postDelayed(new Runnable() {
                @Override public void run() {
                    ArrayList<Post> posts = new ArrayList<>(post.replyCount);
                    for (String id : thread.replies.get(post.number)) {
                        posts.add(thread.postMap.get(id));
                    }
                    showPostsDialog(post, posts);
                }
            }, RIPPLE_DELAY());
        }
    };

    public static interface QuoteCallback {
        public void onClick(String quoterId, String quotedId);
    }
    QuoteCallback quoteCallback = new QuoteCallback() {
        @Override public void onClick(final String quoterId, final String quotedId) {
            Post quoted = thread.postMap.get(quotedId);
            if (quoted != null) {
                postsView.postDelayed(new Runnable() {
                    @Override public void run() {
                        showPostsDialog(thread.postMap.get(quoterId), thread.postMap.get(quotedId));
                    }
                }, RIPPLE_DELAY());
            }
        }
    };

    public static interface ImageCallback {
        public void onClick(int position, Post post, ImageView iv);
    }
    ImageCallback imageCallback = new ImageCallback() {
        @Override public void onClick(int position, Post post, ImageView iv) {
            int w = iv.getWidth();
            int h = iv.getHeight();
            Drawable d = iv.getDrawable();
            int r = Math.min(d.getIntrinsicHeight(), d.getIntrinsicWidth());
            int[] xy = new int[2];
            iv.getLocationOnScreen(xy);
            int cx = xy[0] + w/2;
            int cy = xy[1] + h/2;
            String uuid = position == 0 ? transitionName : UUID.randomUUID().toString();
            MediaActivity.transitionBitmap = Util.drawableToBitmap(iv.getDrawable());
            int color = post.thumbMutedColor != -1 ? post.thumbMutedColor : getResources().getColor(R.color.colorPrimaryDark);
            MediaActivity.launch(
                    PostsActivity.this, iv, uuid, new Point(cx, cy), r, color, false,
                    thread, thread.mediaMap.get(post.number)
            );
        }
    };

    public static interface JumpCallback {
        public void onJumpTo(Post post);
    }
    JumpCallback jumpCallback = new JumpCallback() {
        @Override public void onJumpTo(Post post) {
            final int index = thread.indexForPost(post);
            if (index == -1) return;
            dismissAllPostsDialogs();
            showToolbar();
            postsView.scrollToPosition(index);
            postsView.postDelayed(new Runnable() {
                // A more reliable way would be to listen to when the scroll finished
                // but too much work for this visual hint, so a rough delay it will be.
                @Override public void run() {
                    PostView pv = (PostView) layoutManager.findViewByPosition(index);
                    if (pv != null) pv.blinkRed();
                }
            }, 500);
        }
    };

    private class RevealAnimationCallback {
        private boolean dataLoaded, transitionFinished;
        public void markDataLoaded() {
            dataLoaded = true;
            startAnimation();
        }
        public void markTransitionFinished() {
            transitionFinished = true;
            startAnimation();
        }
        private void startAnimation() {
            if (dataLoaded && transitionFinished && firstLoad) {
                postsView.postDelayed(new Runnable() {
                    @Override public void run() {
                        animatePostsArrival();
                        firstLoad = false;
                    }
                }, 80);
            }
        }
    }
    RevealAnimationCallback revealAnimationCallback = new RevealAnimationCallback();

    // Data Loading ////////////////////////////////////////////////////////////////////////////////

    public boolean firstLoad;

    @Override protected void load() { load(false); }

    private void load(final boolean silent) {
        if (thread.dead) {
            loaded();
            if (!silent) showToast(R.string.no_thread);
            return;
        }

        super.load();
        if (silent || firstLoad) loadingBar.setVisibility(View.GONE);

        // Indicate ongoing request after all if first load takes especially long
        if (firstLoad) {
            postsView.postDelayed(new Runnable() {
                @Override public void run() {
                    if (firstLoad && loading) {
                        loadingBar.setVisibility(View.VISIBLE);
                    }
                }
            }, 500);
        }

        thread.load(service, new Thread.OnResultCallback() {
            @Override public void onSuccess() {
                if (prefs.preloadThumbnails()) {
                    for (MediaPointer mp : thread.mediaPointers)
                        ion.build(PostsActivity.this).load(ApiModule.thumbUrl(boardName, mp.id)).asBitmap().tryGet();
                }
                if (firstLoad) revealAnimationCallback.markDataLoaded();
                else postsAdapter.notifyDataSetChanged();
                loaded();
            }

            @Override public void onError(String message) {
                if (!silent) showToast(message);
                loaded();
            }
        });

        service.listThreads(this, boardName, new FutureCallback<List<ThreadPreview>>() {
            @Override public void onCompleted(Exception e, List<ThreadPreview> result) {
                if (e != null) return;
                boolean dead = true;
                for (ThreadPreview t : result) {
                    if (t.number.equals(threadNumber)) {
                        dead = false;
                        break;
                    }
                }
                if (dead) {
                    thread.dead = true;
                    setTitle(thread.title());
                    // Simply to update the title in the nav bar with a dead indication
                    if (persistentData.isInWatchlist(thread.id)) {
                        persistentData.addWatchlistThread(thread);
                    }
                }
            }
        });
    }

    private void update() {
        if (loading) return;
        thread.resetUpdateTimer();
        load(true);
    }

    @Override protected void cancelPending() {
        super.cancelPending();
        // TODO: Reenable once https://github.com/koush/ion/issues/422 is fixed
        //ion.cancelAll(this);
    }

    // Lifecycle ///////////////////////////////////////////////////////////////////////////////////

    @Override public void onResume() {
        super.onResume();
        if (silent) finish();
        thread.initBackgroundUpdater(prefs, new Runnable() {
            @Override public void run() {
                PostsActivity.this.runOnUiThread(new Runnable() {
                    @Override public void run() {
                        update();
                    }
                });
            }
        });
        thread.resumeBackgroundUpdating();
    }

    @Override protected void onPause() {
        super.onPause();
        thread.pauseBackgroundUpdating();
    }

    @Override public void onConfigurationChanged(Configuration newConfig) {
        super.onConfigurationChanged(newConfig);
        postsView.postDelayed(new Runnable() {
            @Override public void run() {
                postsView.requestLayout();
                SCROLL_BY_HEIGHT = Util.getScreenHeight(PostsActivity.this) - Util.getActionBarHeight(PostsActivity.this);
            }
        }, 100);
    }

    @Override public void onDestroy() {
        thread.killBackgroundUpdater();
        persistPositionMaybe();
        getSupportFragmentManager().removeOnBackStackChangedListener(backStackChangedListener);
        for (String key : bitmapCacheKeys) {
            if (key != null) ion.getBitmapCache().remove(key);
        }
        System.gc();
        super.onDestroy();
    }

    public void persistPositionMaybe() {
        if (persistentData.isInWatchlist(thread.id)) {
            thread.lastVisibleIndex = layoutManager.findFirstVisibleItemPosition();
            persistentData.addWatchlistThread(thread);
        }
    }

    // Toolbar Menu ////////////////////////////////////////////////////////////////////////////////

    Menu menu;

    @Override public boolean onCreateOptionsMenu(Menu menu) {
        getMenuInflater().inflate(R.menu.posts, menu);
        this.menu = menu;
        if (persistentData.isInWatchlist(thread.id)) {
            menu.findItem(R.id.addWatchlist).setVisible(false);
            menu.findItem(R.id.removeWatchlist).setVisible(true);
        } else {
            menu.findItem(R.id.addWatchlist).setVisible(true);
            menu.findItem(R.id.removeWatchlist).setVisible(false);
        }
        return true;
    }

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        switch(item.getItemId()) {
            case R.id.up:
                postsView.scrollToPosition(0);
                break;
            case R.id.down:
                postsView.scrollToPosition(postsAdapter.getItemCount() - 1);
                break;
            case R.id.refresh:
                load();
                break;
            case R.id.gallery:
                GalleryActivity.launch(this, thread);
                break;
            case R.id.close:
                dismissAllPostsDialogs();
                break;
            case R.id.saveAll:
                GalleryActivity.saveAll(this, imageSaver, boardName, threadNumber, thread.mediaPointers);
                break;
            case R.id.addWatchlist:
                persistentData.addWatchlistThread(thread);
                invalidateOptionsMenu();
                break;
            case R.id.removeWatchlist:
                persistentData.removeWatchlistThread(thread);
                invalidateOptionsMenu();
                break;
            case R.id.openBrowser:
                Intent i = new Intent(Intent.ACTION_VIEW, Uri.parse(ApiModule.threadUrl(boardName, threadNumber)));
                startActivity(Intent.createChooser(i, "Open in Browser"));
                break;
        }
        return super.onOptionsItemSelected(item);
    }

    // Posts Dialogs ///////////////////////////////////////////////////////////////////////////////

    private static String PREVIOUS_TITLE = "previousTitle";
    private static String PREVIOUS_TOOLBAR_POSITION = "previousToolbarPosition";
    private static String PREVIOUS_STACK_HEIGHT = "previousStackHeight";
    String previousTitle;
    float previousToolbarPosition;
    int previousStackHeight;

    private void addPostsDialog(PostsDialog d) {
        FragmentManager fm = getSupportFragmentManager();
        int stackHeight = fm.getBackStackEntryCount();
        if (stackHeight == 0) {
            previousToolbarPosition = ViewHelper.getTranslationY(toolbar);
            previousTitle = getTitle().toString();
            showToolbar();
            menu.setGroupVisible(R.id.posts, false);
            menu.setGroupVisible(R.id.postsDialog, true);
        }
        fm.beginTransaction()
                .add(R.id.container, d)
                .addToBackStack(PostsDialog.STACK_ID)
                .commitAllowingStateLoss();
    }

    FragmentManager.OnBackStackChangedListener backStackChangedListener = new FragmentManager.OnBackStackChangedListener() {
        @Override public void onBackStackChanged() {
            FragmentManager fm = getSupportFragmentManager();
            int stackHeight = fm.getBackStackEntryCount();
            if (stackHeight == 0) {
                if (Build.VERSION.SDK_INT >= 14) {
                    toolbar.animate().y(previousToolbarPosition).setDuration(dur);
                } else {
                    ViewHelper.setTranslationY(toolbar, previousToolbarPosition);
                }
                setTitle(previousTitle);
                menu.setGroupVisible(R.id.posts, true);
                menu.setGroupVisible(R.id.postsDialog, false);
            }
            if (stackHeight > 0 && stackHeight < previousStackHeight) {
                Fragment fragment = fm.getFragments().get(stackHeight-1);
                if (fragment != null) fragment.onResume();
            }
            previousStackHeight = stackHeight;
        }
    };

    boolean removing = false;
    @Override public void onBackPressed() {
        if (removing) return;
        FragmentManager fm = getSupportFragmentManager();
        int stackHeight = fm.getBackStackEntryCount();
        if (stackHeight > 0) {
            removing = true;
            PostsDialog dialog = (PostsDialog) fm.getFragments().get(stackHeight-1);
            if (dialog != null) dialog.animatePostsRemoval();
            postsView.postDelayed(new Runnable() {
                @Override public void run() {
                    removing = false;
                    PostsActivity.super.onBackPressed();
                }
            }, PostsDialog.ANIM_DURATION);
        } else {
            super.onBackPressed();
        }
    }

    @Override public boolean onKeyLongPress(int keyCode, KeyEvent event) {
        FragmentManager fm = getSupportFragmentManager();
        int stackHeight = fm.getBackStackEntryCount();
        if (stackHeight > 0 && keyCode == KeyEvent.KEYCODE_BACK) {
            dismissAllPostsDialogs();
            return true;
        }
        return super.onKeyLongPress(keyCode, event);
    }

    // http://stackoverflow.com/a/27180584/283607
    private void setupUpLongClickDismissAll() {
        if (Build.VERSION.SDK_INT >= 14) {
            toolbar.setNavigationContentDescription("up");
            final ArrayList<View> outViews = new ArrayList<>();
            toolbar.findViewsWithText(outViews, "up", View.FIND_VIEWS_WITH_CONTENT_DESCRIPTION);
            if (outViews.size() == 0 || outViews.get(0) == null) return;
            outViews.get(0).setOnLongClickListener(new View.OnLongClickListener() {
                @Override public boolean onLongClick(View v) {
                    FragmentManager fm = getSupportFragmentManager();
                    int stackHeight = fm.getBackStackEntryCount();
                    if (stackHeight > 0 && !drawerLayout.isDrawerOpen(drawer)) {
                        dismissAllPostsDialogs();
                        return true;
                    }
                    return false;
                }
            });
        }
    }

    @Override
    public boolean dispatchKeyEvent(KeyEvent event) {
        int action = event.getAction();
        int keyCode = event.getKeyCode();

        if (prefs.volumeScrolling()) {
            FragmentManager fm = getSupportFragmentManager();
            int stackHeight = fm.getBackStackEntryCount();

            if (stackHeight == 0) {
                switch (keyCode) {
                    case KeyEvent.KEYCODE_VOLUME_UP:
                        if (action == KeyEvent.ACTION_DOWN) postsView.smoothScrollBy(0, -SCROLL_BY_HEIGHT);
                        return true;
                    case KeyEvent.KEYCODE_VOLUME_DOWN:
                        if (action == KeyEvent.ACTION_DOWN) postsView.smoothScrollBy(0, SCROLL_BY_HEIGHT);
                        return true;
                }
            } else {
                final PostsDialog dialog = (PostsDialog) fm.getFragments().get(stackHeight-1);
                final int dialogPadding = 150;
                final int scrollDuration = 500;

                switch (keyCode) {
                    case KeyEvent.KEYCODE_VOLUME_UP:
                        if (action == KeyEvent.ACTION_DOWN) dialog.postsView.smoothScrollBy(-SCROLL_BY_HEIGHT - dialogPadding, scrollDuration);
                        return true;
                    case KeyEvent.KEYCODE_VOLUME_DOWN:
                        if (action == KeyEvent.ACTION_DOWN) dialog.postsView.smoothScrollBy(SCROLL_BY_HEIGHT - dialogPadding, scrollDuration);
                        return true;
                }
            }
        }

        return super.dispatchKeyEvent(event);
    }

    private void showPostsDialog(Post repliedTo, List<Post> posts) {
        PostsDialog dialog = new PostsDialog();
        dialog.repliedTo = repliedTo;
        dialog.adapter = new PostsDialogAdapter(posts);
        addPostsDialog(dialog);
    }

    private void showPostsDialog(Post quotedBy, Post post) {
        PostsDialog dialog = new PostsDialog();
        dialog.quotedBy = quotedBy;
        dialog.adapter = new PostsDialogAdapter(Arrays.asList(post));
        addPostsDialog(dialog);
    }

    boolean removingAll = false;
    private void dismissAllPostsDialogs() {
        if (removingAll) return;
        FragmentManager fm = getSupportFragmentManager();
        if (fm.getFragments() == null) return;
        removingAll = true;

        for (Fragment f : fm.getFragments()) { if (f instanceof PostsDialog) { PostsDialog d = (PostsDialog) f;
            d.animatePostsRemoval();
        }}
        postsView.postDelayed(new Runnable() {
            @Override public void run() {
                removingAll = false;
                getSupportFragmentManager().popBackStack(PostsDialog.STACK_ID, FragmentManager.POP_BACK_STACK_INCLUSIVE);
            }
        }, PostsDialog.ANIM_DURATION);
    }

    // Animations //////////////////////////////////////////////////////////////////////////////////

    @TargetApi(Build.VERSION_CODES.LOLLIPOP) private void animatePostsArrival() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            postsAdapter.hide = true;
            postsAdapter.firstLoad = true;
            postsAdapter.notifyDataSetChanged();
            TransitionManager.beginDelayedTransition(postsView, new Slide(Gravity.BOTTOM));
            postsAdapter.hide = false;
            postsAdapter.firstLoad = false;
            postsAdapter.notifyDataSetChanged();
            // Unreliable, often leads to https://code.google.com/p/android/issues/detail?id=77846
            //postsAdapter.notifyItemChanged(0);
            //postsAdapter.notifyItemRangeInserted(1, posts.size()-1);
        } else {
            postsAdapter.notifyDataSetChanged();
        }
    }

    // Adapters ////////////////////////////////////////////////////////////////////////////////////

    class PostsAdapter extends UiAdapter<Post> {
        boolean hide;
        boolean firstLoad;

        public PostsAdapter() { this(thread.posts, null, null); }
        public PostsAdapter(List<Post> posts,
                            View.OnClickListener clickListener,
                            View.OnLongClickListener longClickListener) {
            super(PostsActivity.this, clickListener, longClickListener);
            this.items = posts;
        }

        @Override public View newView(ViewGroup container) {
            return newViewDRY(container);
        }

        @Override public void bindView(final Post item, int position, View view) {
            bindViewDRY(item, position, false, view);
            setVisibility(view, !hide || position == 0);
        }
    }

    // The reason for these methods is that I need both a RecyclerView adapter
    // and a ListView adapter that are almost identical. See 8ebd8a30115e2e62ffc28cc02f47873d4035290d
    private View newViewDRY(ViewGroup container) {
        return getLayoutInflater().inflate(R.layout.view_post, container, false);
    }

    private void bindViewDRY(final Post item, int position, boolean inDialog, View view) {
        PostView v = (PostView) view;
        if (item == null) return;
        v.prefs = prefs;
        if (position == 0 && firstLoad) {
            v.bindToOp(opImage, item, boardName, ion);
        } else {
            v.bindTo(position, item, boardName, ion, bitmapCacheKeys, inDialog,
                     repliesCallback, quoteCallback, imageCallback, jumpCallback);
        }
        v.setImageTransitionName(position == 0 ? transitionName : null);
    }

    class PostsDialogAdapter extends BindableAdapter<Post> {
        boolean hide = false;
        List<Post> items;

        public PostsDialogAdapter(List<Post> posts) {
            super(PostsActivity.this);
            this.items = posts;
        }

        @Override public View newView(LayoutInflater inflater, int position, ViewGroup container) {
            return newViewDRY(container);
        }

        @Override public void bindView(final Post item, int position, View view) {
            bindViewDRY(item, position, true, view);
            setVisibility(view, !hide);
        }

        @Override public int getCount() { return items.size(); }
        @Override public Post getItem(int position) { return items.get(position); }
        @Override public long getItemId(int position) { return position; }
    }

}
src/main/java/anabolicandroids/chanobol/ui/media/GalleryActivity.java
public class GalleryActivity extends SwipeRefreshActivity {

    // Construction ////////////////////////////////////////////////////////////////////////////////

    private static Thread threadTransfer;
    // backup
    private static String EXTRA_THREAD = "thread";

    private static String THREAD = "thread";
    private Thread thread;
    // Convenience
    private String boardName;
    private String threadNumber;

    private GalleryAdapter galleryAdapter;

    private static void launch(Activity activity, Thread thread, boolean fromCatalog) {
        ActivityOptionsCompat options = makeSceneTransitionAnimation(activity);
        Intent intent = new Intent(activity, GalleryActivity.class);
        intent.putExtra(EXTRA_THREAD, Parcels.wrap(new Thread(thread.boardName, thread.threadNumber)));
        threadTransfer = thread;
        if (fromCatalog) {
            PostsActivity.launchSilently(activity, thread);
            activity.startActivity(intent);
        } else {
            ActivityCompat.startActivity(activity, intent, options.toBundle());
        }
    }

    public static void launch(Activity activity, Thread thread) {
        launch(activity, thread, false);
    }

    public static void launchFromCatalog(Activity activity, Thread thread) {
        launch(activity, thread, true);
    }

    @Inject ImageSaver imageSaver;

    @InjectView(R.id.gallery) RecyclerView galleryView;

    @Override protected int getLayoutResource() { return R.layout.activity_gallery; }
    @Override protected RecyclerView getRootRecyclerView() { return galleryView; }

    @Override public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        // To prevent transition glitch
        getWindow().setBackgroundDrawableResource(R.color.transparent);

        Bundle b = getIntent().getExtras();

        // Ugly quick fix (transaction too large)
        thread = Parcels.unwrap(b.getParcelable(EXTRA_THREAD));
        if (threadTransfer != null && threadTransfer.threadNumber.equals(thread.threadNumber))
            thread = threadTransfer.deepCopy();
        threadTransfer = null;

        boardName = thread.boardName;
        threadNumber = thread.threadNumber;

        if (savedInstanceState == null) {
            firstLoad = true;
            load();
        } else {
            firstLoad = false;
            thread = Parcels.unwrap(savedInstanceState.getParcelable(THREAD));
        }

        setTitle(thread.titleForGallery());

        galleryAdapter = new GalleryAdapter(clickListener, null);
        galleryView.setAdapter(galleryAdapter);
        galleryView.setHasFixedSize(true);
        GridLayoutManager glm = new GridLayoutManager(this, 3);
        galleryView.setLayoutManager(glm);
        galleryView.setItemAnimator(new DefaultItemAnimator());
        Util.calcDynamicSpanCountById(this, galleryView, glm, R.dimen.column_width_gallery);

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            getWindow().setEnterTransition(TransitionInflater.from(this).inflateTransition(android.R.transition.slide_right));
            getWindow().setReturnTransition(TransitionInflater.from(this).inflateTransition(android.R.transition.slide_right));
        }
    }

    @Override protected void onSaveInstanceState(Bundle outState) {
        super.onSaveInstanceState(outState);
        outState.putParcelable(THREAD, Parcels.wrap(thread));
    }

    // Callbacks ///////////////////////////////////////////////////////////////////////////////////

    View.OnClickListener clickListener = new View.OnClickListener() {
        @Override public void onClick(View v) {
            final GalleryThumbView iv = (GalleryThumbView) v;
            int w = iv.getWidth();
            int h = iv.getHeight();
            final Drawable d = iv.image.getDrawable();
            final int r = Math.min(d.getIntrinsicHeight(), d.getIntrinsicWidth());
            int[] xy = new int[2];
            iv.getLocationOnScreen(xy);
            final int cx = xy[0] + w/2;
            final int cy = xy[1] + h/2;
            iv.postDelayed(new Runnable() {
                @Override public void run() {
                    MediaActivity.transitionBitmap = Util.drawableToBitmap(iv.image.getDrawable());
                    int color = getResources().getColor(R.color.colorPrimaryDark);
                    if (MediaActivity.transitionBitmap != null) {
                        Palette palette = Palette.generate(MediaActivity.transitionBitmap);
                        color = palette.getMutedColor(color);
                    }
                    MediaActivity.launch(
                            GalleryActivity.this, iv.image, iv.index + "", new Point(cx, cy), r, color,
                            true, thread, iv.index
                    );
                }
            }, 200);
        }
    };

    // Data Loading ////////////////////////////////////////////////////////////////////////////////

    public boolean firstLoad;

    @Override protected void load() { load(false); }

    private void load(final boolean silent) {
        if (thread.dead) {
            loaded();
            if (!silent) showToast(R.string.no_thread);
            return;
        }

        super.load();
        if (silent || firstLoad) loadingBar.setVisibility(View.GONE);

        // Indicate ongoing request after all if first load takes especially long
        if (firstLoad) {
            galleryView.postDelayed(new Runnable() {
                @Override public void run() {
                    if (firstLoad && loading) {
                        loadingBar.setVisibility(View.VISIBLE);
                    }
                }
            }, 500);
        }

        thread.load(service, new Thread.OnResultCallback() {
            @Override public void onSuccess() {
                galleryAdapter.notifyDataSetChanged();
                firstLoad = false;
                loaded();
            }

            @Override public void onError(String message) {
                if (!silent) showToast(message);
                firstLoad = false;
                loaded();
            }
        });

        service.listThreads(this, boardName, new FutureCallback<List<ThreadPreview>>() {
            @Override public void onCompleted(Exception e, List<ThreadPreview> result) {
                if (e != null) return;
                boolean dead = true;
                for (ThreadPreview t : result) {
                    if (t.number.equals(threadNumber)) {
                        dead = false;
                        break;
                    }
                }
                if (dead) {
                    thread.dead = true;
                    setTitle(thread.titleForGallery());
                }
            }
        });
    }

    private void update() {
        if (loading) return;
        thread.resetUpdateTimer();
        load(true);
    }

    @Override protected void cancelPending() {
        super.cancelPending();
        // TODO: Reenable once https://github.com/koush/ion/issues/422 is fixed
        //ion.cancelAll(this);
    }

    // Lifecycle ///////////////////////////////////////////////////////////////////////////////////

    @Override protected void onResume() {
        super.onResume();
        thread.initBackgroundUpdater(prefs, new Runnable() {
            @Override public void run() {
                GalleryActivity.this.runOnUiThread(new Runnable() {
                    @Override public void run() {
                        update();
                    }
                });
            }
        });
        thread.resumeBackgroundUpdating();
    }

    @Override protected void onPause() {
        super.onPause();
        thread.pauseBackgroundUpdating();
    }

    @Override public void onConfigurationChanged(Configuration newConfig) {
        super.onConfigurationChanged(newConfig);
        Util.updateRecyclerViewGridOnConfigChange(galleryView, R.dimen.column_width_gallery);
    }

    // Toolbar Menu ////////////////////////////////////////////////////////////////////////////////

    @Override public boolean onCreateOptionsMenu(Menu menu) {
        getMenuInflater().inflate(R.menu.gallery, menu);
        return true;
    }

    @Override public boolean onOptionsItemSelected(MenuItem item) {
        switch(item.getItemId()) {
            case R.id.saveAll:
                saveAll(this, imageSaver, boardName, threadNumber, thread.mediaPointers);
                break;
        }
        return super.onOptionsItemSelected(item);
    }

    public static void saveAll(Context context, ImageSaver imageSaver, String boardName, String threadNumber, List<MediaPointer> mediaPointers) {
        // From Clover
        if (mediaPointers.size() > 0) {
            List<ImageSaver.DownloadPair> list = new ArrayList<>();
            String folderName = ThreadPreview.generateTitle(boardName, threadNumber);
            for (MediaPointer p : mediaPointers) {
                list.add(new ImageSaver.DownloadPair(ApiModule.mediaUrl(boardName, p.id, p.ext), p.fileName()));
            }
            imageSaver.saveAll(context, folderName, list);
        }
    }

    // Adapters ////////////////////////////////////////////////////////////////////////////////////

    class GalleryAdapter extends UiAdapter<MediaPointer> {

        public GalleryAdapter(View.OnClickListener clickListener, View.OnLongClickListener longClickListener) {
            super(GalleryActivity.this, clickListener, longClickListener);
            this.items = thread.mediaPointers;
        }

        @Override public View newView(ViewGroup container) {
            return getLayoutInflater().inflate(R.layout.view_gallery_thumb, container, false);
        }

        @Override public void bindView(MediaPointer mediaPointer, int position, View view) {
            GalleryThumbView g = (GalleryThumbView) view;
            g.bindTo(ion, boardName, mediaPointer, position);
            ViewCompat.setTransitionName(g.image, position+"");
        }
    }
}
src/main/java/anabolicandroids/chanobol/ui/threads/WatchlistThreadsActivity.java
public class WatchlistThreadsActivity extends ThreadsActivity {

    @Override protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            setTaskDescription(new ActivityManager.TaskDescription(getResources().getString(R.string.watchtask)));
        }
    }

}
src/main/java/anabolicandroids/chanobol/ui/boards/FavoritesActivity.java
public class FavoritesActivity extends UiActivity {

    // Construction ////////////////////////////////////////////////////////////////////////////////

    public static void launch(Activity activity) {
        Intent intent = new Intent(activity, FavoritesActivity.class);
        intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
        activity.startActivity(intent);
        activity.finish();
    }

    @InjectView(R.id.boards) RecyclerView favoritesView;

    @Inject @SfwMode boolean sfw;
    @Inject @Nsfw List<Board> allBoards;
    BoardsAdapter boardsAdapter;

    @Override protected int getLayoutResource() { return R.layout.activity_boards; }
    @Override protected RecyclerView getRootRecyclerView() { return favoritesView; }

    @Override public void onCreate(Bundle savedInstanceState) {
        taskRoot = true;
        super.onCreate(savedInstanceState);
        setTitle(R.string.favorite_boards);

        if (!sfw && App.firstStart && persistentData.getFavorites().size() == 0) {
            App.firstStart = false;
            BoardsActivity.launch(this);
        }
        App.firstStart = false;

        boardsAdapter = new BoardsAdapter(this, ion,
                new ArrayList<>(persistentData.getFavorites()),
                clickListener, longClickListener);
        favoritesView.setAdapter(boardsAdapter);
        favoritesView.setHasFixedSize(true);
        GridLayoutManager glm = new GridLayoutManager(this, 2);
        favoritesView.setLayoutManager(glm);
        boardsAdapter.notifyDataSetChanged();
        Util.calcDynamicSpanCountById(this, favoritesView, glm, R.dimen.column_width);

        persistentData.addFavoritesChangedCallback(favoritesCallback);
    }

    // Callbacks ///////////////////////////////////////////////////////////////////////////////////

    View.OnClickListener clickListener = new View.OnClickListener() {
        @Override public void onClick(View v) {
            final BoardView bv = (BoardView) v;
            bv.postDelayed(new Runnable() {
                @Override public void run() {
                    ThreadsActivity.launch(FavoritesActivity.this, bv.board);
                }
            }, RIPPLE_DELAY());
        }
    };

    View.OnLongClickListener longClickListener = new View.OnLongClickListener() {
        @Override public boolean onLongClick(View v) {
            final BoardView bv = (BoardView) v;
            new AlertDialog.Builder(FavoritesActivity.this)
                    .setTitle(R.string.delete_title)
                    .setNegativeButton(android.R.string.cancel, null)
                    .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() {
                        public void onClick(DialogInterface dialog, int whichButton) {
                            persistentData.removeFavorite(bv.board);
                        }
                    }).show();
            return true;
        }
    };

    PersistentData.FavoritesCallback favoritesCallback = new PersistentData.FavoritesCallback() {
        @Override public void onChanged(Set<Board> newFavorites) {
            boardsAdapter.replaceWith(new ArrayList<>(newFavorites));
        }
    };

    // Lifecycle ///////////////////////////////////////////////////////////////////////////////////

    @Override public void onConfigurationChanged(Configuration newConfig) {
        super.onConfigurationChanged(newConfig);
        // Why is there no onPostConfigurationChanged...
        Util.updateRecyclerViewGridOnConfigChange(favoritesView, R.dimen.column_width);
    }

    // Toolbar Menu ////////////////////////////////////////////////////////////////////////////////

    @Override public boolean onCreateOptionsMenu(Menu menu) {
        getMenuInflater().inflate(R.menu.favorites, menu);
        return true;
    }

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        if (item.getItemId() == R.id.addFavorite) {
            final EditText input = new EditText(this);
            input.setHint("ck");
            new AlertDialog.Builder(this)
                    .setTitle(R.string.add_favorite_title)
                    .setView(input)
                    .setNegativeButton(android.R.string.cancel, null)
                    .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() {
                        public void onClick(DialogInterface dialog, int whichButton) {
                            String normalizedName = input.getText().toString().replace("/", "");
                            for (Board board : allBoards) {
                                if (board.name.equals(normalizedName)) {
                                    persistentData.addFavorite(board);
                                    showToast(board.name + " " + resources.getString(R.string.favorite_added));
                                    return;
                                }
                            }
                            showToast(R.string.no_board);
                        }
                    }).show();
        }
        return super.onOptionsItemSelected(item);
    }
}
src/main/java/anabolicandroids/chanobol/util/ImageSaver.java
public class ImageSaver {
    /*
    private static final ImageSaver instance = new ImageSaver();

    public static ImageSaver getInstance() {
        return instance;
    }
    */

    private final Prefs prefs;
    private final Ion ion;
    private final FileCache fileCache;

    public ImageSaver(Prefs prefs, Ion ion, FileCache fileCache) {
        this.prefs = prefs;
        this.ion = ion;
        this.fileCache = fileCache;
    }

    private BroadcastReceiver receiver;

    public void saveAll(final Context context, String folderName, final List<DownloadPair> list) {
        // TODO: I've never seen the plus operator used for type File... but it works...
        final File subFolder = new File(prefs.imageDir() + File.separator + filterName(folderName));

        String text = context.getString(R.string.download_confirm, Integer.toString(list.size()), subFolder.getAbsolutePath());

        new AlertDialog.Builder(context).setMessage(text).setNegativeButton(android.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) {
        Future<?> ionRequest = fileCache.downloadFile(context, 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 = prefs.imageDir();

                    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) {
        Util.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;

        try {
            copy(new FileInputStream(source), new FileOutputStream(destination));
        } catch (IOException e) {
            res = false;
        }

        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) {
                        Util.runOnUiThread(new Runnable() {
                            @Override
                            public void run() {
                                Timber.i("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)));
                                }
                            }
                        });
                    }
                }
        );
    }

    /**
     * Copies the inputstream to the outputstream and closes both streams.
     *
     * @param is
     * @param os
     * @throws IOException
     */
    public static void copy(InputStream is, OutputStream os) throws IOException {
        int read;
        byte[] buffer = new byte[4096];
        while ((read = is.read(buffer)) != -1) {
            os.write(buffer, 0, read);
        }

        is.close();
        os.close();
    }

    public static void copy(Reader input, Writer output) throws IOException {
        char[] buffer = new char[4096];
        int read;
        while ((read = input.read(buffer)) != -1) {
            output.write(buffer, 0, read);
        }
    }

    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;
    }
}
src/main/java/anabolicandroids/chanobol/ui/Settings.java
public class Settings extends BaseSettings {

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        addPreferencesFromResource(R.xml.prefs);

        Preference p = findPreference(Prefs.EXTERNAL_WEBM);
        if (Build.VERSION.SDK_INT < 15) {
            p.setSummary("In-app WebM playback needs at least Android 3.0");
            p.setEnabled(false);
            p.getEditor().putBoolean(Prefs.EXTERNAL_WEBM, true).apply();
        }

        p = findPreference(Prefs.IMMERSIVE_MODE);
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) {
            p.setSummary("Immersive Mode needs at least Android 4.4");
            p.setEnabled(false);
            p.getEditor().putBoolean(Prefs.IMMERSIVE_MODE, false).apply();
        }
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            p.setSummary("Experimental");
        }
    }

    @Override
    public void onSharedPreferenceChanged(SharedPreferences prefs, String key) {
        if (key.equals(Prefs.HIDABLE_TOOLBAR)) {
            // For the case that the toolbar is not fully protracted and the user
            // disables the auto-hiding toolbar it needs to be fully protracted afterwards.
            App.needToProtractToolbar = true;
        }
        if (key.equals(Prefs.THEME)) {
            Util.restartApp(getApplication(), this);
        }
    }
}
src/main/java/anabolicandroids/chanobol/ui/boards/BoardsActivity.java
public class BoardsActivity extends UiActivity {

    // Construction ////////////////////////////////////////////////////////////////////////////////

    public static void launch(Activity activity) {
        Intent intent = new Intent(activity, BoardsActivity.class);
        intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
        activity.startActivity(intent);
        activity.finish();
    }

    @InjectView(R.id.boards) RecyclerView boardsView;

    @Inject List<Board> boards;
    BoardsAdapter boardsAdapter;

    @Override protected int getLayoutResource() { return R.layout.activity_boards; }
    @Override protected RecyclerView getRootRecyclerView() { return boardsView; }

    @Override public void onCreate(Bundle savedInstanceState) {
        taskRoot = true;
        super.onCreate(savedInstanceState);
        setTitle(R.string.all_boards);

        boardsAdapter = new BoardsAdapter(this, ion, boards, clickListener, longClickListener);
        boardsView.setAdapter(boardsAdapter);
        boardsView.setHasFixedSize(true);
        GridLayoutManager glm = new GridLayoutManager(this, 2);
        boardsView.setLayoutManager(glm);
        boardsView.setItemAnimator(new DefaultItemAnimator());
        Util.calcDynamicSpanCountById(this, boardsView, glm, R.dimen.column_width);
    }

    // Callbacks ///////////////////////////////////////////////////////////////////////////////////

    View.OnClickListener clickListener = new View.OnClickListener() {
        @Override public void onClick(View v) {
            final BoardView bv = (BoardView) v;
            bv.postDelayed(new Runnable() {
                @Override public void run() {
                    ThreadsActivity.launch(BoardsActivity.this, bv.board);
                }
            }, RIPPLE_DELAY());
        }
    };

    View.OnLongClickListener longClickListener = new View.OnLongClickListener() {
        @Override public boolean onLongClick(View v) {
            final Board board = ((BoardView) v).board;
            showAddFavoriteDialog(BoardsActivity.this, persistentData, board);
            return true;
        }
    };

    public static void showAddFavoriteDialog(
            final Context context, final PersistentData persistentData, final Board board
    ) {
        new AlertDialog.Builder(context)
                .setTitle(R.string.favorize_title)
                .setNegativeButton(android.R.string.cancel, null)
                .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() {
                    public void onClick(DialogInterface dialog, int whichButton) {
                        persistentData.addFavorite(board);
                        Util.showToast(context,
                                board.name + " " + context.getResources().getString(R.string.favorite_added));
                    }
                }).show();
    }

    // Lifecycle ///////////////////////////////////////////////////////////////////////////////////

    @Override public void onConfigurationChanged(Configuration newConfig) {
        super.onConfigurationChanged(newConfig);
        Util.updateRecyclerViewGridOnConfigChange(boardsView, R.dimen.column_width);
    }

    // Toolbar Menu ////////////////////////////////////////////////////////////////////////////////

    @Override public boolean onCreateOptionsMenu(Menu menu) {
        getMenuInflater().inflate(R.menu.boards, menu);
        return true;
    }
}
src/main/java/anabolicandroids/chanobol/ui/scaffolding/PersistentData.java
@Singleton public class PersistentData {
    public static final String favoriteBoardsId = "favoriteBoards";
    public static final String watchlistId = "watchlist";

    Context context;
    SharedPreferences prefs;
    Gson gson = new Gson();

    public PersistentData(App context, SharedPreferences prefs) {
        this.context = context;
        this.prefs = prefs;
    }

    ArrayList<FavoritesCallback> favoritesCallbacks = new ArrayList<>();

    private void saveFavorites(Set<Board> favorites) {
        String jsonFavorites = gson.toJson(favorites);
        prefs.edit().putString(favoriteBoardsId, jsonFavorites).apply();
        for (FavoritesCallback cb : favoritesCallbacks) {
            cb.onChanged(favorites);
        }
    }

    public void addFavorite(Board board) {
        Set<Board> favorites = getFavorites();
        favorites.add(board);
        saveFavorites(favorites);
    }

    public void removeFavorite(Board board) {
        Set<Board> favorites = getFavorites();
        favorites.remove(board);
        saveFavorites(favorites);
    }

    public Set<Board> getFavorites() {
        String jsonFavorites = prefs.getString(favoriteBoardsId, "[]");
        Board[] boards;
        try {
            boards = gson.fromJson(jsonFavorites, Board[].class);
        } catch(JsonSyntaxException e){
            boards = new Board[0];
        }
        return new TreeSet<>(Arrays.asList(boards));
    }

    public void addFavoritesChangedCallback(FavoritesCallback callback) {
        favoritesCallbacks.add(callback);
    }

    public void removeFavoritesChangedCallback(FavoritesCallback callback) {
        favoritesCallbacks.remove(callback);
    }

    public static interface FavoritesCallback {
        public void onChanged(Set<Board> newFavorites);
    }


    public static class WatchlistEntry implements Comparable<WatchlistEntry> {
        public String id;
        public String title;
        @SuppressWarnings("UnusedDeclaration") WatchlistEntry(){}
        WatchlistEntry(String id) { this.id = id; }
        WatchlistEntry(String id, String title) {
            this.id = id;
            this.title = title;
        }
        @Override public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            WatchlistEntry that = (WatchlistEntry) o;
            return id.equals(that.id);
        }
        @Override public int hashCode() { return id.hashCode(); }
        @Override public int compareTo(@NonNull WatchlistEntry another) {
            return this.id.compareTo(another.id);
        }
    }

    ArrayList<WatchlistCallback> watchlistCallbacks = new ArrayList<>();

    @SuppressLint("CommitPrefEdits")
    private void saveWatchlist(Set<WatchlistEntry> watchlist) {
        String jsonWatchlist = gson.toJson(watchlist);
        prefs.edit().putString(watchlistId, jsonWatchlist).commit();
        for (WatchlistCallback cb : watchlistCallbacks) {
            cb.onChanged(watchlist);
        }
    }

    public boolean isInWatchlist(String id) {
        return getWatchlist().contains(new WatchlistEntry(id));
    }

    public void addWatchlistThread(Thread thread) {
        Set<WatchlistEntry> watchlist = getWatchlist();
        WatchlistEntry e = new WatchlistEntry(thread.id, thread.title());
        // Why remove? In order to force updating of the entry (e.g. new title with skull)
        watchlist.remove(e);
        watchlist.add(e);
        saveWatchlist(watchlist);

        String jsonThread = gson.toJson(thread);
        writeToInternalFile(thread.id, jsonThread);
    }

    public void removeWatchlistThread(WatchlistEntry entry) {
        Set<WatchlistEntry> watchlist = getWatchlist();
        watchlist.remove(entry);
        saveWatchlist(watchlist);

        context.deleteFile(escapePathSeparator(entry.id));
    }
    public void removeWatchlistThread(Thread thread) {
        removeWatchlistThread(new WatchlistEntry(thread.id));
    }

    public Set<WatchlistEntry> getWatchlist() {
        String jsonWatchlist = prefs.getString(watchlistId, "[]");
        WatchlistEntry[] threads;
        try {
            threads = gson.fromJson(jsonWatchlist, WatchlistEntry[].class);
        } catch (JsonSyntaxException e) {
            threads = new WatchlistEntry[0];
        }
        return new TreeSet<>(Arrays.asList(threads));
    }

    public void clearWatchList() {
        Set<WatchlistEntry> watchlist = getWatchlist();
        for (WatchlistEntry entry : watchlist) {
            removeWatchlistThread(entry);
        }
    }

    public Thread getWatchlistThread(String id) {
        String jsonThread = readFromInternalFile(id);
        return gson.fromJson(jsonThread, Thread.class);
    }

    public void addWatchlistChangedCallback(WatchlistCallback callback) {
        watchlistCallbacks.add(callback);
    }

    public void removeWatchlistChangedCallback(WatchlistCallback callback) {
        watchlistCallbacks.remove(callback);
    }

    public static interface WatchlistCallback {
        public void onChanged(Set<WatchlistEntry> newWatchlist);
    }


    // http://stackoverflow.com/a/14377185/283607

    private String escapePathSeparator(String path) {
        return path.replace("/", "_");
    }

    private void writeToInternalFile(String name, String data) {
        try {
            OutputStreamWriter outputStreamWriter = new OutputStreamWriter(context.openFileOutput(escapePathSeparator(name), Context.MODE_PRIVATE));
            outputStreamWriter.write(data);
            outputStreamWriter.close();
        }
        catch (IOException e) {
            Log.e("Exception", "File write failed: " + e.toString());
        }
    }

    private String readFromInternalFile(String name) {
        String ret = "";
        try {
            InputStream inputStream = context.openFileInput(escapePathSeparator(name));
            if ( inputStream != null ) {
                InputStreamReader inputStreamReader = new InputStreamReader(inputStream);
                BufferedReader bufferedReader = new BufferedReader(inputStreamReader);
                String receiveString;
                StringBuilder stringBuilder = new StringBuilder();

                while ( (receiveString = bufferedReader.readLine()) != null ) {
                    stringBuilder.append(receiveString);
                }

                inputStream.close();
                ret = stringBuilder.toString();
            }
        }
        catch (FileNotFoundException e) {
            Log.e("login activity", "File not found: " + e.toString());
        } catch (IOException e) {
            Log.e("login activity", "Can not read file: " + e.toString());
        }
        return ret;
    }

}
src/main/java/anabolicandroids/chanobol/ui/threads/ThreadsActivity.java
public class ThreadsActivity extends SwipeRefreshActivity {

    // Construction ////////////////////////////////////////////////////////////////////////////////

    // To load the respective threads from 4Chan
    private static String EXTRA_BOARD = "board";
    private static String EXTRA_IS_WATCHLIST = "watchlist";
    private Board board;
    private boolean watchlist;

    // Internal state
    private static String THREADS = "threads";
    private static String THREADMAP = "threadMap";
    private static String SEARCH_QUERY = "searchQuery";
    private ArrayList<ThreadPreview> threadPreviews;
    private HashMap<String, Integer> threadMap;
    private ThreadsAdapter threadsAdapter;
    private String searchQuery;
    // No need to persist, is recreated on demand
    private ThreadSortOrder sortOrder;
    private ArrayList<ThreadPreview> sortedThreadPreviews;
    private ArrayList<ThreadPreview> filteredThreadPreviews;

    // This whole bitMap stuff is only needed to prevent the thumbnails from blinking after
    // update is finished. I tried many other approaches but this is the only one that worked.
    private HashMap<String, Bitmap> bitMap;

    private static void launch(Class activityClass, Activity callerActivity, Board board, boolean isWatchlist, boolean excludeFromRecents) {
        Intent intent = new Intent(callerActivity, activityClass);
        if (board != null) intent.putExtra(EXTRA_BOARD, Parcels.wrap(board));
        intent.putExtra(EXTRA_IS_WATCHLIST, isWatchlist);
        if (isWatchlist) intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK);
        if (excludeFromRecents) intent.addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS);
        intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
        callerActivity.startActivity(intent);
    }
    public static void launch(Activity activity, Board board) {
        launch(ThreadsActivity.class, activity, board, false, false);
    }
    public static void launchForWatchlist(Activity activity, boolean excludeFromRecents) {
        launch(WatchlistThreadsActivity.class, activity, null, true, excludeFromRecents);
    }

    @InjectView(R.id.threads) RecyclerView threadsView;

    @Override protected int getLayoutResource() { return R.layout.activity_threads; }
    @Override protected RecyclerView getRootRecyclerView() { return threadsView; }

    @Override protected void onCreate(Bundle savedInstanceState) {
        Bundle b = getIntent().getExtras();
        watchlist = b.getBoolean(EXTRA_IS_WATCHLIST);
        if (watchlist) board = null;
        else board = Parcels.unwrap(b.getParcelable(EXTRA_BOARD));
        taskRoot = watchlist;

        super.onCreate(savedInstanceState);

        if (!watchlist && (board == null || board.name == null)) {
            // Get some crash reports for this: http://crashes.to/s/9eae4cc77a6
            showToast("Could not load board");
            finish();
            return;
        }

        if (savedInstanceState == null) {
            freeMemory();
            threadPreviews = new ArrayList<>();
            threadMap = new HashMap<>();
            searchQuery = "";
        } else {
            threadPreviews = Parcels.unwrap(savedInstanceState.getParcelable(THREADS));
            threadMap = Parcels.unwrap(savedInstanceState.getParcelable(THREADMAP));
            searchQuery = savedInstanceState.getString(SEARCH_QUERY);
        }

        if (watchlist) setTitle(getResources().getString(R.string.watchlist));
        else setTitle(board.name + " – " + board.title);

        sortOrder = prefs.threadSortOrder();
        sortedThreadPreviews = new ArrayList<>();
        filteredThreadPreviews = new ArrayList<>();

        bitMap = new HashMap<>();

        threadsAdapter = new ThreadsAdapter(clickCallback, longClickCallback);
        threadsView.setAdapter(threadsAdapter);
        threadsView.setHasFixedSize(true);
        GridLayoutManager glm = new GridLayoutManager(ThreadsActivity.this, 2);
        threadsView.setLayoutManager(glm);
        Util.calcDynamicSpanCountById(ThreadsActivity.this, threadsView, glm, R.dimen.column_width);

        if (watchlist) {
            if (swipe != null) swipe.setEnabled(false); // null-check due to: http://crashes.to/s/172ca87ecb8 and http://crashes.to/s/9ef45e4e36b
            persistentData.addWatchlistChangedCallback(watchlistChangedCallback);
            if (savedInstanceState == null) loadWatchlistThreadsFromStorage();
            else notifyDataSetChanged();
        } else {
            if (savedInstanceState == null) load(); else notifyDataSetChanged();
        }
    }

    private void loadWatchlistThreadsFromStorage() {
        // TODO: Load asynchronously in background
        threadPreviews.clear();
        threadMap.clear();
        Set<PersistentData.WatchlistEntry> watchlistEntries = persistentData.getWatchlist();
        int i = 0;
        for (PersistentData.WatchlistEntry e : watchlistEntries) {
            Thread t = persistentData.getWatchlistThread(e.id);
            if (t == null || t.opPost() == null) continue;
            ThreadPreview p = t.opPost().toThreadPreview();
            p.dead = t.dead;
            threadPreviews.add(p);
            threadMap.put(p.number, i);
            i++;
        }
        notifyDataSetChanged();
    }

    public void showSortOrderDialog() {
        final CharSequence[] sort_names = getResources().getStringArray(R.array.sort_names);
        final CharSequence[] sort_keys = getResources().getStringArray(R.array.sort_keys);

        int indexOfCurrentOrder = Arrays.asList(sort_keys).indexOf(prefs.threadSortOrder().string);
        new AlertDialog.Builder(this)
            .setTitle(R.string.choose_sort_order)
            .setNegativeButton(android.R.string.cancel, null)
            .setSingleChoiceItems(sort_names, indexOfCurrentOrder, new DialogInterface.OnClickListener() {
                public void onClick(DialogInterface dialog, int item) {
                    ThreadSortOrder newSortOrder = ThreadSortOrder.values()[item];
                    prefs.threadSortOrder(newSortOrder);
                    sortOrder = newSortOrder;
                    notifyDataSetChanged();

                    dialog.dismiss();
                }
            }).show();
    }

    public static void showClearWatchListDialog(
            final Context context, final PersistentData persistentData
    ) {
        new AlertDialog.Builder(context)
                .setTitle(R.string.clear_watchlist_title)
                .setNegativeButton(android.R.string.cancel, null)
                .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() {
                    public void onClick(DialogInterface dialog, int whichButton) {
                        persistentData.clearWatchList();
                        Util.showToast(context,
                                context.getResources().getString(R.string.watchlist_cleared));
                    }
                }).show();
    }

    @Override protected void onSaveInstanceState(Bundle outState) {
        super.onSaveInstanceState(outState);
        outState.putParcelable(THREADS, Parcels.wrap(threadPreviews));
        outState.putParcelable(THREADMAP, Parcels.wrap(threadMap));
        outState.putString(SEARCH_QUERY, searchQuery);
    }

    // Callbacks ///////////////////////////////////////////////////////////////////////////////////

    private View.OnClickListener clickCallback = new View.OnClickListener() {
        @Override public void onClick(View v) {
            final ThreadView tv = (ThreadView) v;
            ThreadPreview threadPreview = tv.threadPreview;
            if (threadPreview.dead && !watchlist) {
                showToast(R.string.no_thread);
                return;
            }
            pauseUpdating = true;
            String uuid = UUID.randomUUID().toString();
            pauseUpdating = true; // To prevent update during transition
            PostsActivity.launch(
                    ThreadsActivity.this, tv.image, uuid,
                    threadPreview.toOpPost(), threadPreview.board, threadPreview.number
            );
        }
    };

    private View.OnLongClickListener longClickCallback = new View.OnLongClickListener() {
        @Override public boolean onLongClick(View v) {
            final ThreadView tv = (ThreadView) v;
            ThreadPreview threadPreview = tv.threadPreview;
            if (threadPreview.dead) {
                showToast(R.string.no_thread);
                return true;
            }
            pauseUpdating = true;
            // TODO: Shared element animation
            pauseUpdating = true; // To prevent update during transition
            GalleryActivity.launchFromCatalog(ThreadsActivity.this, threadPreview.toThread());
            return true;
        }
    };

    private PersistentData.WatchlistCallback watchlistChangedCallback = new PersistentData.WatchlistCallback() {
        @Override public void onChanged(Set<PersistentData.WatchlistEntry> newWatchlist) {
            loadWatchlistThreadsFromStorage();
        }
    };

    private void setSearchCallback(final SearchView searchView) {
        if (searchQuery != null && searchQuery.length() > 0) {
            searchView.setIconified(false);
            searchView.setQuery(searchQuery, false);
            searchView.clearFocus();
        }
        searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() {
            @Override public boolean onQueryTextSubmit(String query) {
                searchView.clearFocus();
                return true;
            }
            @Override public boolean onQueryTextChange(String query) {
                searchQuery = query;
                notifyDataSetChanged();
                return true;
            }
        });
    }

    // Data Loading ////////////////////////////////////////////////////////////////////////////////

    @Override protected void load() {
        if (watchlist) return;
        super.load();
        service.listThreads(this, board.name, new FutureCallback<List<ThreadPreview>>() {
            @Override public void onCompleted(Exception e, List<ThreadPreview> result) {
                if (e != null) {
                    if (e.getMessage() != null) showToast(e.getMessage());
                    System.out.println("" + e.getMessage());
                    loaded();
                    return;
                }
                boolean empty = threadPreviews.isEmpty();
                threadPreviews.clear();
                sortedThreadPreviews.clear();
                threadMap.clear();
                bitMap.clear(); // prevent strong references to possibly stale bitmaps
                for (int i = 0; i < result.size(); i++) {
                    ThreadPreview threadPreview = result.get(i);
                    threadPreviews.add(threadPreview);
                    threadMap.put(threadPreview.number, i);
                }
                if (empty) {
                    animateThreadsArrival();
                } else {
                    notifyDataSetChanged();
                }
                loaded();
                lastUpdate = System.currentTimeMillis();
            }
        });
    }

    private void update() {
        if (watchlist) return;
        if (loading) return;
        lastUpdate = System.currentTimeMillis();
        service.listThreads(this, board.name, new FutureCallback<List<ThreadPreview>>() {
            @Override public void onCompleted(Exception e, List<ThreadPreview> result) {
                if (e != null) {
                    System.out.println("" + e.getMessage());
                    return;
                }
                if (loading) return; // cancel on real refresh
                boolean[] positions = new boolean[threadPreviews.size()];
                for (ThreadPreview threadPreview : result) {
                    Integer position = threadMap.get(threadPreview.number);
                    if (position != null) {
                        positions[position] = true;
                        threadPreviews.set(position, threadPreview);
                    }
                }
                for (int i = 0; i < positions.length; i++)
                    if (!positions[i]) threadPreviews.get(i).dead = true;
                notifyDataSetChangedWithoutBlinking();
                lastUpdate = System.currentTimeMillis();
            }
        });
    }

    @Override protected void cancelPending() {
        super.cancelPending();
        // TODO: Reenable once https://github.com/koush/ion/issues/422 is fixed
        //ion.cancelAll(this);
    }

    private void notifyDataSetChanged() {
        sortedThreadPreviews.clear();
        boolean hasSticky = threadPreviews.size() > 0 && threadPreviews.get(0).isSticky();
        for (int i = hasSticky ? 1 : 0; i < threadPreviews.size(); i++) {
            sortedThreadPreviews.add(threadPreviews.get(i));
        }
        Collections.sort(sortedThreadPreviews, new Comparator<ThreadPreview>() {
            @Override public int compare(ThreadPreview lhs, ThreadPreview rhs) {
                switch(sortOrder) {
                    case Bump: return 0;
                    case Replies: return rhs.replies - lhs.replies;
                    case Images: return rhs.images - lhs.images;
                    case Date: return rhs.time - lhs.time;
                }
                return 0;
            }
        });
        if (hasSticky) sortedThreadPreviews.add(0, threadPreviews.get(0));

        filteredThreadPreviews.clear();
        for (ThreadPreview t : sortedThreadPreviews) {
            if (t.excerpt.toLowerCase().contains(searchQuery))
                filteredThreadPreviews.add(t);
        }

        threadsAdapter.notifyDataSetChanged();
    }

    boolean onlyUpdateText;
    public void notifyDataSetChangedWithoutBlinking() {
        onlyUpdateText = true;
        notifyDataSetChanged();
        threadsView.postDelayed(new Runnable() {
            @Override public void run() {
                onlyUpdateText = false;
            }
        }, 500);
    }

    // Lifecycle ///////////////////////////////////////////////////////////////////////////////////

    @Override public void onResume() {
        super.onResume();
        // When the sort order has been changed in the settings activity and one resumes back to this activity
        if (sortOrder != prefs.threadSortOrder()) {
            sortOrder = prefs.threadSortOrder();
            notifyDataSetChanged();
        }
        update();
        initBackgroundUpdater();
        pauseUpdating = false;
    }

    @Override public void onConfigurationChanged(Configuration newConfig) {
        super.onConfigurationChanged(newConfig);
        Util.updateRecyclerViewGridOnConfigChange(threadsView, R.dimen.column_width);
    }

    @Override public void onPause() {
        super.onPause();
        pauseUpdating = true;
    }

    @Override public void onDestroy() {
        if (executor != null) executor.shutdown();
        if (bitMap != null) bitMap.clear();
        if (persistentData != null) persistentData.removeWatchlistChangedCallback(watchlistChangedCallback);
        super.onDestroy();
    }

    // Toolbar Menu ////////////////////////////////////////////////////////////////////////////////

    @Override public boolean onCreateOptionsMenu(Menu menu) {
        getMenuInflater().inflate(R.menu.threads, menu);
        if (watchlist) {
            menu.findItem(R.id.favorize).setVisible(false);
            menu.findItem(R.id.refresh).setVisible(false);
            menu.findItem(R.id.clear_watchlist).setVisible(true);
        }

        final MenuItem searchItem = menu.findItem(R.id.search);
        SearchView searchView = null;
        if (searchItem != null) searchView = (SearchView) MenuItemCompat.getActionView(searchItem);
        if (searchView != null) setSearchCallback(searchView);

        return super.onCreateOptionsMenu(menu);
    }

    @Override public boolean onOptionsItemSelected(MenuItem item) {
        switch(item.getItemId()) {
            case R.id.refresh:
                load();
                break;
            case R.id.up:
                threadsView.scrollToPosition(0);
                break;
            case R.id.down:
                threadsView.scrollToPosition(threadPreviews.size()-1);
                break;
            case R.id.sort_order:
                showSortOrderDialog();
                break;
            case R.id.clear_watchlist:
                showClearWatchListDialog(ThreadsActivity.this, persistentData);
                break;
            case R.id.favorize:
                BoardsActivity.showAddFavoriteDialog(this, persistentData, board);
                break;
        }
        return super.onOptionsItemSelected(item);
    }

    // Background Refresh //////////////////////////////////////////////////////////////////////////

    // TODO: Isn't there a more elegant solution?
    long lastUpdate;
    static final long updateInterval = 30_000;
    boolean pauseUpdating = false;
    private ScheduledThreadPoolExecutor executor;

    private void initBackgroundUpdater() {
        if (executor == null) {
            executor = new ScheduledThreadPoolExecutor(1);
            executor.scheduleWithFixedDelay(new Runnable() {
                @Override
                public void run() {
                    if (!prefs.autoRefresh()) return;
                    if (pauseUpdating) return;
                    if (System.currentTimeMillis() - lastUpdate > updateInterval) {
                        runOnUiThread(new Runnable() {
                            @Override
                            public void run() {
                                update();
                            }
                        });
                    }
                }
            }, 5000, 5000, TimeUnit.MILLISECONDS);
        }
    }

    // Animations //////////////////////////////////////////////////////////////////////////////////

    @TargetApi(Build.VERSION_CODES.LOLLIPOP) private void animateThreadsArrival() {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
            notifyDataSetChanged();
            return;
        }
        // Add slight delay because thumbnails must be retrieved, too,
        // and it looks bad when the thumbnails are loaded afterwards.
        threadsView.postDelayed(new Runnable() {
            @Override public void run() {
                threadsAdapter.hide = true;
                notifyDataSetChanged();
                TransitionManager.beginDelayedTransition(threadsView, new Slide(Gravity.BOTTOM));
                threadsAdapter.hide = false;
                notifyDataSetChanged();
            }
        }, 300);
    }

    // Adapters ////////////////////////////////////////////////////////////////////////////////////

    class ThreadsAdapter extends UiAdapter<ThreadPreview> {
        boolean hide = false;

        public ThreadsAdapter(View.OnClickListener clickListener, View.OnLongClickListener longClickListener) {
            super(ThreadsActivity.this, clickListener, longClickListener);
            this.items = filteredThreadPreviews;
        }

        @Override public View newView(ViewGroup container) {
            return inflater.inflate(R.layout.view_thread, container, false);
        }

        @Override public void bindView(ThreadPreview item, int position, View view) {
            ThreadView tv = (ThreadView) view;
            tv.bindTo(item, item.board, ion, onlyUpdateText, watchlist, bitMap);
            setVisibility(tv, !hide);
        }
    }

}
src/main/java/anabolicandroids/chanobol/ui/posts/PostsDialog.java
public class PostsDialog extends BaseFragment {

    public static final String STACK_ID = "postsdialog";
    public static final int ANIM_DURATION = Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP ?
            500 : UiActivity.RIPPLE_DELAY;

    @InjectView(R.id.blackness) View background;
    // Why not RecyclerView? It _used to be_ RecyclerView but as the by default provided
    // Layoutmanagers do not support wrap_content and thus no easy ability to center the
    // inline posts vertically, it seemed to be the best course of action to simply go back to
    // ListView in the meantime even if it means that PostsActivity.PostsAdapter cannot be reused.
    @InjectView(R.id.posts) ListView postsView;


    Post repliedTo;
    Post quotedBy;
    PostsActivity.PostsDialogAdapter adapter;

    private boolean dismissed = false;
    @OnClick(R.id.blackness) void dismiss() {
        if (dismissed) return;
        dismissed = true;
        animatePostsRemoval();
        postsView.postDelayed(new Runnable() {
            @Override public void run() {
                FragmentManager fm = getFragmentManager();
                if (fm != null) fm.popBackStack();
            }
        }, ANIM_DURATION);
    }

    @Override protected int getLayoutResource() { return R.layout.fragment_posts_dialog; }

    @Override public void onActivityCreated(@Nullable Bundle savedInstanceState) {
        super.onActivityCreated(savedInstanceState);
        if (adapter != null) {
            postsView.setAdapter(adapter);
            adapter.notifyDataSetChanged();
            animatePostsArrival();
        }
    }

    @Override public void onResume() {
        super.onResume();
        if (repliedTo != null)
            getActivity().setTitle(resources.getString(R.string.replies_to) + " " + repliedTo.number);
        else if (quotedBy != null)
            getActivity().setTitle(resources.getString(R.string.quoted_by) + " " + quotedBy.number);
    }

    @Override public void onConfigurationChanged(Configuration newConfig) {
        super.onConfigurationChanged(newConfig);
        postsView.postDelayed(new Runnable() {
            @Override public void run() {
                postsView.requestLayout();
            }
        }, 100);
    }

    // Ideally, I would use fragment manager transitions but see https://code.google.com/p/android/issues/detail?id=82832
    @TargetApi(Build.VERSION_CODES.LOLLIPOP) private void animatePostsArrival() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            postsView.setVisibility(View.INVISIBLE);
            postsView.postDelayed(new Runnable() {
                @Override public void run() {
                    revealBackground();
                    postsView.setVisibility(View.GONE);
                    Transition t = TransitionInflater.from(getActivity()).inflateTransition(R.transition.postsdialog);
                    TransitionManager.beginDelayedTransition(postsView, t);
                    postsView.setVisibility(View.VISIBLE);
                }
            }, 10);
        } else {
            background.setVisibility(View.VISIBLE);
        }
    }
    @TargetApi(Build.VERSION_CODES.LOLLIPOP) void animatePostsRemoval() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            hideBackground();
            postsView.setVisibility(View.VISIBLE);
            Transition t = TransitionInflater.from(getActivity()).inflateTransition(R.transition.postsdialog);
            TransitionManager.beginDelayedTransition(postsView, t);
            postsView.setVisibility(View.GONE);
        }
    }

    // TODO: The circle should start at the position of the click (suggests origin)
    // https://developer.android.com/training/material/animations.html#Reveal
    @TargetApi(Build.VERSION_CODES.LOLLIPOP) private void revealBackground() {
        int cx = Util.getScreenWidth(getActivity()) / 2;
        int cy = Util.getScreenHeight(getActivity());
        int finalRadius = Math.max(background.getWidth(), background.getHeight());
        Animator anim = ViewAnimationUtils.createCircularReveal(
                background, cx, cy, 0, finalRadius
        );
        anim.setDuration(ANIM_DURATION/2);
        background.setVisibility(View.VISIBLE);
        anim.start();
    }
    @TargetApi(Build.VERSION_CODES.LOLLIPOP) private void hideBackground() {
        int cx = Util.getScreenWidth(getActivity()) / 2;
        int cy = Util.getScreenHeight(getActivity());
        int initialRadius = Math.max(background.getWidth(), background.getHeight());
        Animator anim = ViewAnimationUtils.createCircularReveal(
                background, cx, cy, initialRadius, 0
        );
        anim.setDuration(ANIM_DURATION/2);
        anim.setStartDelay((int)(ANIM_DURATION/2.5));
        anim.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                super.onAnimationEnd(animation);
                if (background != null) background.setVisibility(View.INVISIBLE);
            }
        });
        anim.start();
    }
}
src/main/java/anabolicandroids/chanobol/ui/scaffolding/Prefs.java
@SuppressWarnings("UnusedDeclaration")
public class Prefs {

    private final SharedPreferences p;

    public Prefs(SharedPreferences sharedPrefs) {
        this.p = sharedPrefs;
    }

    public static final String THEME = "pref_theme";
    public Theme theme() {
        String name = p.getString("pref_theme", Theme.LIGHT.name);
        if (name.equals(Theme.LIGHT.name)) return Theme.LIGHT;
        if (name.equals(Theme.DARK.name)) return Theme.DARK;
        if (name.equals(Theme.TEAL.name)) return Theme.TEAL;
        if (name.equals(Theme.BLUE1.name)) return Theme.BLUE1;
        if (name.equals(Theme.BLUE2.name)) return Theme.BLUE2;
        if (name.equals(Theme.GRAY.name)) return Theme.GRAY;
        return Theme.LIGHT;
    }
    public void theme(Theme theme) {
        p.edit().putString(THEME, theme.name).apply();
    }

    public static final String HIDABLE_TOOLBAR = "pref_hidable_toolbar";
    public boolean hidableToolbar() {
        return p.getBoolean(HIDABLE_TOOLBAR, true);
    }
    public void hidableToolbar(boolean v) {
        p.edit().putBoolean(HIDABLE_TOOLBAR, v).apply();
    }

    public static final String PRELOAD_THUMBNAILS = "pref_preload_thumbnails";
    public boolean preloadThumbnails() {
        return p.getBoolean(PRELOAD_THUMBNAILS, true);
    }
    public void preloadThumbnails(boolean v) {
        p.edit().putBoolean(PRELOAD_THUMBNAILS, v).apply();
    }

    public static final String ONLY_THUMBNAILS = "pref_only_thumbnails_postlist";
    public boolean onlyThumbnailsInPostlist() {
        return p.getBoolean(ONLY_THUMBNAILS, false);
    }
    public void onlyThumbnailsInPostlist(boolean v) {
        p.edit().putBoolean(ONLY_THUMBNAILS, v).apply();
    }

    public static final String VOLUME_SCROLLING = "pref_volume_scrolling";
    public boolean volumeScrolling() {
        return p.getBoolean(VOLUME_SCROLLING, false);
    }
    public void volumeScrolling(boolean v) {
        p.edit().putBoolean(VOLUME_SCROLLING, v).apply();
    }

    public static final String AUTO_REFRESH = "pref_refresh";
    public boolean autoRefresh() {
        return p.getBoolean(AUTO_REFRESH, true);
    }
    public void autoRefresh(boolean v) {
        p.edit().putBoolean(AUTO_REFRESH, v).apply();
    }

    public static final String IMAGE_DIR = "pref_image_dir";
    public File imageDir() {
        String path = p.getString(IMAGE_DIR, null);
        if (path == null) return new File(Environment.getExternalStorageDirectory() + File.separator + "Chanobol");
        else return new File(path);
    }
    public void imageDir(String dir) {
        p.edit().putString(IMAGE_DIR, dir).apply();
    }


    public static final String THREAD_SORT_ORDER = "pref_thread_sort_order";
    public ThreadSortOrder threadSortOrder() {
        String s = p.getString(THREAD_SORT_ORDER, ThreadSortOrder.Bump.string);
        if (s.equals(ThreadSortOrder.Bump.string)) return ThreadSortOrder.Bump;
        if (s.equals(ThreadSortOrder.Replies.string)) return ThreadSortOrder.Replies;
        if (s.equals(ThreadSortOrder.Images.string)) return ThreadSortOrder.Images;
        if (s.equals(ThreadSortOrder.Date.string)) return ThreadSortOrder.Date;
        return ThreadSortOrder.Bump;
    }
    public void threadSortOrder(ThreadSortOrder order) {
        p.edit().putString(THREAD_SORT_ORDER, order.string).apply();
    }

    public static final String EXTERNAL_WEBM = "pref_external_webm";
    public boolean externalWebm() {
        if (Build.VERSION.SDK_INT < 15) return true;
        return p.getBoolean(EXTERNAL_WEBM, false);
    }
    public void externalWebm(boolean v) {
        p.edit().putBoolean(EXTERNAL_WEBM, v).apply();
    }

    public static final String IMMERSIVE_MODE = "pref_immersive_mode";
    public boolean immersiveMode() {
        if (Build.VERSION.SDK_INT < 19) return false;
        return p.getBoolean(IMMERSIVE_MODE, false);
    }
    public void immersiveMode(boolean v) {
        p.edit().putBoolean(IMMERSIVE_MODE, v).apply();
    }

    public static final String WATCHTASK_RECENTS = "pref_watchtask_recents";
    public boolean excludeWatchtaskRecents() {
        return p.getBoolean(WATCHTASK_RECENTS, true);
    }
    public void excludeWatchtaskRecents(boolean v) {
        p.edit().putBoolean(WATCHTASK_RECENTS, v).apply();
    }

}
src/main/java/anabolicandroids/chanobol/util/FileCache.java
public class FileCache {
    private static final String TAG = "FileCache";

    private final File directory;
    private final long maxSize;
    private final Ion ion;

    private long size;

    public FileCache(File directory, long maxSize, Ion ion) {
        this.directory = directory;
        this.maxSize = maxSize;
        this.ion = ion;

        makeDir();
        calculateSize();
    }

    public File get(String key) {
        makeDir();

        return new File(directory, Integer.toString(key.hashCode()));
    }

    public void put(File file) {
        size += file.length();

        trim();
    }

    public boolean delete(File file) {
        size -= file.length();

        return file.delete();
    }

    public Future<Response<File>> downloadFile(Context context, String url, final DownloadedCallback callback) {
        File file = get(url);
        if (file.exists()) {
            file.setLastModified(Time.get());
            callback.onProgress(0, 0, true);
            callback.onSuccess(file);
            return null;
        } else {
            return ion.build(context)
                    .load(url)
                    .progress(new ProgressCallback() {
                        @Override
                        public void onProgress(final long downloaded, final long total) {
                            Util.runOnUiThread(new Runnable() {
                                @Override
                                public void run() {
                                    callback.onProgress(downloaded, total, false);
                                }
                            });
                        }
                    })
                    .write(file)
                    .withResponse()
                    .setCallback(new FutureCallback<Response<File>>() {
                        @Override
                        public void onCompleted(Exception e, Response<File> result) {
                            callback.onProgress(0, 0, true);

                            if (result != null && result.getHeaders() != null && result.getHeaders().code() / 100 != 2) {
                                if (result.getResult() != null) {
                                    delete(result.getResult());
                                }
                                callback.onFail(true);
                                return;
                            }

                            if (e != null && !(e instanceof CancellationException)) {
                                e.printStackTrace();
                                if (result != null && result.getResult() != null) {
                                    delete(result.getResult());
                                }
                                callback.onFail(false);
                                return;
                            }

                            if (result != null && result.getResult() != null) {
                                put(result.getResult());
                                callback.onSuccess(result.getResult());
                            }
                        }
                    });
        }
    }

    private void makeDir() {
        if (!directory.exists()) {
            if (!directory.mkdirs()) {
                Timber.e("Unable to create file cache dir " + directory.getAbsolutePath());
            } else {
                calculateSize();
            }
        }
    }

    private void trim() {
        int tries = 0;
        while (size > maxSize && tries++ < 10) {
            File[] files = directory.listFiles();
            if (files == null) {
                break;
            }
            long age = Long.MAX_VALUE;
            long last;
            File oldest = null;
            for (File file : files) {
                last = file.lastModified();
                if (last < age && last != 0L) {
                    age = last;
                    oldest = file;
                }
            }

            if (oldest == null) {
                Log.e(TAG, "No files to trim");
                break;
            } else {
                Log.d(TAG, "Deleting " + oldest.getAbsolutePath());
                if (!delete(oldest)) {
                    Log.e(TAG, "Cannot delete cache file");
                    calculateSize();
                    break;
                }
            }

            calculateSize();
        }
    }

    private void calculateSize() {
        size = 0;

        File[] files = directory.listFiles();
        if (files != null) {
            for (File file : files) {
                size += file.length();
            }
        }
    }

    public interface DownloadedCallback {
        public void onProgress(long downloaded, long total, boolean done);

        public void onSuccess(File file);

        public void onFail(boolean notFound);
    }
}
src/main/java/anabolicandroids/chanobol/util/Util.java
public class Util {

    public static int clamp(double min, double val, double max) {
        return (int) Math.max(min, Math.min(max, val));
    }

    // http://stackoverflow.com/a/5599842
    public static String readableFileSize(long size) {
        if(size <= 0) return "0";
        final String[] units = new String[] { "B", "kB", "MB", "GB", "TB" };
        int digitGroups = (int) (Math.log10(size)/Math.log10(1024));
        return new DecimalFormat("#,##0.#").format(size/Math.pow(1024, digitGroups)) + " " + units[digitGroups];
    }

    public static void showToast(Context context, String msg) {
        Toast.makeText(context, msg, Toast.LENGTH_SHORT).show();
    }

    public static void showToast(Context context, int res) {
        Toast.makeText(context, res, Toast.LENGTH_SHORT).show();
    }

    @SuppressWarnings("UnusedDeclaration")
    public static void setAlpha(View view, float alpha) {
        if (Build.VERSION.SDK_INT < 11) {
            final AlphaAnimation animation = new AlphaAnimation(alpha, alpha);
            animation.setDuration(0);
            animation.setFillAfter(true);
            view.startAnimation(animation);
        } else view.setAlpha(alpha);
    }

    public static String loadJSONFromAsset(Context context, String path) {
        String json;
        try {
            InputStream is = context.getAssets().open(path);
            int size = is.available();
            byte[] buffer = new byte[size];
            //noinspection ResultOfMethodCallIgnored
            is.read(buffer);
            is.close();
            json = new String(buffer, "UTF-8");
        } catch (IOException ex) {
            ex.printStackTrace();
            return null;
        }
        return json;
    }

    public static int getScreenHeight(Context context) {
        android.view.Display display = ((android.view.WindowManager)context.getSystemService(Context.WINDOW_SERVICE)).getDefaultDisplay();
        //noinspection deprecation
        return display.getHeight();
    }

    public static int getScreenWidth(Context context) {
        android.view.Display display = ((android.view.WindowManager)context.getSystemService(Context.WINDOW_SERVICE)).getDefaultDisplay();
        //noinspection deprecation
        return display.getWidth();
    }

    public static int getActionBarHeight(Context context) {
        TypedValue tv = new TypedValue();
        if (context.getTheme().resolveAttribute(R.attr.actionBarSize, tv, true)) {
            return TypedValue.complexToDimensionPixelSize(tv.data, context.getResources().getDisplayMetrics());
        }
        return -1;
    }

    public static float dpToPx(float dp, Context context){
        Resources resources = context.getResources();
        DisplayMetrics metrics = resources.getDisplayMetrics();
        return dp * (metrics.densityDpi / 160f);
    }

    @SuppressWarnings("UnusedDeclaration")
    public static float pxToDp(float px, Context context){
        Resources resources = context.getResources();
        DisplayMetrics metrics = resources.getDisplayMetrics();
        return px / (metrics.densityDpi / 160f);
    }

    // Unsatisfactory compared to GridView but given the current constraint best solution
    // http://stackoverflow.com/questions/26666143/recyclerview-gridlayoutmanager-how-to-auto-detect-span-count
    public static void calcDynamicSpanCount(final RecyclerView rv,
                                            final GridLayoutManager glm,
                                            final float cardWidth) {
        rv.getViewTreeObserver().addOnGlobalLayoutListener(
                new ViewTreeObserver.OnGlobalLayoutListener() {
                    @Override
                    public void onGlobalLayout() {
                        //noinspection deprecation
                        rv.getViewTreeObserver().removeGlobalOnLayoutListener(this);
                        int viewWidth = rv.getMeasuredWidth();
                        int newSpanCount = (int) Math.floor(viewWidth / cardWidth);
                        glm.setSpanCount(Math.max(1, newSpanCount));
                        glm.requestLayout();
                    }
                });
    }
    public static void calcDynamicSpanCountById(final Context cxt,
                                                final RecyclerView rv,
                                                final GridLayoutManager glm,
                                                final int cardWidthId) {
        float cardWidth = cxt.getResources().getDimension(cardWidthId);
        calcDynamicSpanCount(rv, glm, cardWidth);
    }

    public static void updateRecyclerViewGridOnConfigChange(final RecyclerView rv, final int cardWidthId) {
        rv.postDelayed(new Runnable() {
            @Override public void run() {
                GridLayoutManager glm = (GridLayoutManager) rv.getLayoutManager();
                Util.calcDynamicSpanCountById(rv.getContext(), rv, glm, cardWidthId);
                rv.requestLayout();
            }
        }, 100);
    }

    public static void animateY(final View view, int from, int to, int duration) {
        ValueAnimator anim = ValueAnimator.ofInt(from, to);
        anim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator valueAnimator) {
                int val = (Integer) valueAnimator.getAnimatedValue();
                ViewHelper.setTranslationY(view, val);
            }
        });
        anim.setDuration(duration);
        anim.start();
    }

    public static void setVisibility(View view, boolean show) {
        if (show) view.setVisibility(View.VISIBLE);
        else view.setVisibility(View.GONE);
    }

    // http://stackoverflow.com/a/21051758/283607
    public static Bitmap blur(Context ctx, Bitmap image, float radius) {
        int width = Math.round(image.getWidth());
        int height = Math.round(image.getHeight());

        Bitmap inputBitmap = Bitmap.createScaledBitmap(image, width, height, false);
        Bitmap outputBitmap = Bitmap.createBitmap(inputBitmap);

        RenderScript rs = RenderScript.create(ctx);
        ScriptIntrinsicBlur theIntrinsic = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs));
        Allocation tmpIn = Allocation.createFromBitmap(rs, inputBitmap);
        Allocation tmpOut = Allocation.createFromBitmap(rs, outputBitmap);
        theIntrinsic.setRadius(radius);
        theIntrinsic.setInput(tmpIn);
        theIntrinsic.forEach(tmpOut);
        tmpOut.copyTo(outputBitmap);

        return outputBitmap;
    }

    // http://stackoverflow.com/a/24313590/283607
    public static Bitmap setHasAlphaCompat(Bitmap bit) {
        int width =  bit.getWidth();
        int height = bit.getHeight();
        Bitmap myBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
        int [] allpixels = new int [ myBitmap.getHeight()*myBitmap.getWidth()];
        bit.getPixels(allpixels, 0, myBitmap.getWidth(), 0, 0, myBitmap.getWidth(),myBitmap.getHeight());
        myBitmap.setPixels(allpixels, 0, width, 0, 0, width, height);
        return myBitmap;
    }

    public static void startWebmActivity(Context context, String url) {
        Intent intent = new Intent(Intent.ACTION_VIEW).setDataAndType(Uri.parse(url), "video/webm");
        if(intent.resolveActivity(context.getPackageManager()) != null)
            context.startActivity(intent);
        else
            Util.showToast(context, R.string.no_app);
    }

    public static void openLink(Context context, String link) {
        Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(link));
        if(intent.resolveActivity(context.getPackageManager()) != null)
            context.startActivity(intent);
        else
            Util.showToast(context, R.string.no_app);
    }

    public static void restartApp(Application app, Activity activity) {
        // TODO: Apparently there is a way to recreate the whole back stack: http://stackoverflow.com/a/28799124/283607
        // From U2020 DebugAppContainer.setEndpointAndRelaunch
        Intent newApp = new Intent(activity, FavoritesActivity.class);
        if (Build.VERSION.SDK_INT >= 11) {
            newApp.setFlags(FLAG_ACTIVITY_CLEAR_TASK | FLAG_ACTIVITY_NEW_TASK);
        } else {
            newApp.setFlags(FLAG_ACTIVITY_NEW_TASK);
        }
        app.startActivity(newApp);
        App.get(app).buildAppGraphAndInject();
    }

    public static void setTheme(Activity activity, Prefs prefs) {
        Theme theme = prefs.theme();
        activity.setTheme(theme.resValue);
        ThemeContext.getInstance().reloadPostViewColors(activity);
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            // It isn't loaded from the xml in setTheme for some reason
            Window window = activity.getWindow();
            Resources r = activity.getResources();
            if (theme == Theme.LIGHT)
                window.setStatusBarColor(r.getColor(R.color.colorPrimaryDark_light));
            if (theme == Theme.DARK)
                window.setStatusBarColor(r.getColor(R.color.colorPrimaryDark));
            if (theme == Theme.TEAL)
                window.setStatusBarColor(r.getColor(R.color.colorPrimaryDark_teal));
            if (theme == Theme.BLUE1)
                window.setStatusBarColor(r.getColor(R.color.colorPrimaryDark_blue1));
            if (theme == Theme.BLUE2)
                window.setStatusBarColor(r.getColor(R.color.colorPrimaryDark_blue2));
            if (theme == Theme.GRAY)
                window.setStatusBarColor(r.getColor(R.color.colorPrimaryDark_gray));
        }
    }

    // http://stackoverflow.com/a/10600736/283607
    public static Bitmap drawableToBitmap(Drawable drawable) {
        if (drawable instanceof BitmapDrawable) {
            return ((BitmapDrawable)drawable).getBitmap();
        }

        int w = drawable.getIntrinsicWidth();
        int h = drawable.getIntrinsicHeight();
        if (w <= 0 || h <= 0) return null;

        Bitmap bitmap = Bitmap.createBitmap(drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight(), Bitmap.Config.ARGB_8888);
        Canvas canvas = new Canvas(bitmap);
        drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight());
        drawable.draw(canvas);

        return bitmap;
    }

    // http://stackoverflow.com/a/23683075/283607
    public static Bitmap copy(Bitmap b) {
        return b.copy(b.getConfig(), true);
    }

    // From Clover
    public static void runOnUiThread(Runnable runnable) {
        new Handler(Looper.getMainLooper()).post(runnable);
    }

    // From Clover and http://stackoverflow.com/a/10965541/283607
    public static void copyToClipboard(Context context, String text) {
        if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.HONEYCOMB) {
            ClipboardManager clipboard = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
            ClipData clip = ClipData.newPlainText(context.getResources().getString(R.string.clipoard_copy_label), text);
            clipboard.setPrimaryClip(clip);
        } else {
            android.text.ClipboardManager clipboard = (android.text.ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
            clipboard.setText(text);
        }
    }
}
src/main/java/anabolicandroids/chanobol/ui/media/MediaActivity.java
public class MediaActivity extends UiActivity {

    // Construction ////////////////////////////////////////////////////////////////////////////////

    private static Thread threadTransfer;
    // backup
    private static String EXTRA_THREAD = "thread";

    // Transition related
    public static Bitmap transitionBitmap;
    private static String EXTRA_TRANSITIONNAME = "transitionName";
    private static String EXTRA_REVEALPOINT = "revealPoint";
    private static String EXTRA_REVEALRADIUS = "revealRadius";
    private static String EXTRA_REVEALCOLOR = "revealColor";
    private static String EXTRA_FROM_GALLERY = "fromGallery";
    private static String EXTRA_INITIALINDEX = "initialIndex";
    private String transitionName;
    private Point revealPoint;
    private int revealRadius;
    @SuppressWarnings("FieldCanBeLocal")
    private int revealColor;
    private boolean fromGallery;
    private int initialIndex;

    // Needed to form a valid request to 4Chan
    private static String THREAD = "thread";
    private Thread thread;
    // Convenience
    private String boardName;

    // Swipes
    private static String EXTRA_INDEX = "index";
    private int currentIndex;

    // To prevent transition glitches
    private int initialStatusBarColor;
    private int targetStatusBarColor;
    private int initialNavBarColor;
    private int targetNavBarColor;

    public static void launch(
            Activity activity, View transitionView, String transitionName,
            Point revealPoint, int revealStartRadius, int revealColor, boolean fromGallery,
            Thread thread, int index
    ) {
        ViewCompat.setTransitionName(transitionView, transitionName);
        ActivityOptionsCompat options = makeSceneTransitionAnimation(activity,
                Pair.create(transitionView, transitionName)
        );
        Intent intent = new Intent(activity, MediaActivity.class);
        intent.putExtra(EXTRA_TRANSITIONNAME, transitionName);
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            intent.putExtra(EXTRA_REVEALPOINT, revealPoint);
        } else {
            // Do nothing because of 2.3.7's:
            // "java.lang.RuntimeException: Parcel: unable to marshal value Point..."
        }
        intent.putExtra(EXTRA_REVEALRADIUS, revealStartRadius);
        intent.putExtra(EXTRA_REVEALCOLOR, revealColor);
        intent.putExtra(EXTRA_FROM_GALLERY, fromGallery);
        intent.putExtra(EXTRA_INITIALINDEX, index);
        intent.putExtra(EXTRA_INDEX, index);
        intent.putExtra(EXTRA_THREAD, Parcels.wrap(new Thread(thread.boardName, thread.threadNumber)));
        threadTransfer = thread;
        //intent.putExtra(THREAD, Parcels.wrap(thread));

        ActivityCompat.startActivity(activity, intent, options.toBundle());
    }

    @Inject ImageSaver imageSaver;

    @InjectView(R.id.revealbg) View background;
    @InjectView(R.id.releaseIndicator) View releaseIndicator;
    @InjectView(R.id.imageViewForTransition) ImageView imageViewForTransition;
    @InjectView(R.id.viewPager) HackyViewPager viewPager;

    MediaView[] pagerViews;

    @Override protected int getLayoutResource() { return R.layout.activity_media; }

    @Override protected void onCreate(final Bundle savedInstanceState) {
        supportPostponeEnterTransition();
        super.onCreate(savedInstanceState);
        getWindow().setBackgroundDrawableResource(R.color.transparent);
        background.getBackground().setAlpha(255);
        setTitle("");

        Bundle b = getIntent().getExtras();
        revealPoint   = b.getParcelable(EXTRA_REVEALPOINT);
        revealRadius  = b.getInt(EXTRA_REVEALRADIUS) / 2;
        revealColor   = b.getInt(EXTRA_REVEALCOLOR);

        // Ugly quick fix (transaction too large)
        thread = Parcels.unwrap(b.getParcelable(EXTRA_THREAD));
        if (threadTransfer != null && threadTransfer.threadNumber.equals(thread.threadNumber))
            thread = threadTransfer.deepCopy();
        threadTransfer = null;

        boardName     = thread.boardName;
        initialIndex  = b.getInt(EXTRA_INITIALINDEX);
        currentIndex  = b.getInt(EXTRA_INDEX);
        fromGallery   = b.getBoolean(EXTRA_FROM_GALLERY);

        transitionName = b.getString(EXTRA_TRANSITIONNAME);
        ViewCompat.setTransitionName(imageViewForTransition, transitionName);
        imageViewForTransition.setImageBitmap(transitionBitmap);
        if (prefs.theme().isLightTheme) background.setBackgroundColor(revealColor);

        if (savedInstanceState == null) {

        } else {
            thread = Parcels.unwrap(savedInstanceState.getParcelable(THREAD));
            currentIndex = savedInstanceState.getInt(EXTRA_INDEX);
        }

        setupViewPager();

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            getWindow().getSharedElementEnterTransition().addListener(new TransitionListenerAdapter() {
                @Override public void onTransitionEnd(Transition transition) {
                    if (savedInstanceState == null) {
                        makeToolbarShadowAppear();
                        makeViewPagerAppear();
                    }
                }
            });
        } else {
            if (savedInstanceState == null) {
                makeToolbarShadowAppear();
                makeViewPagerAppear();
            }
        }

        // Make activity full screen with fully transparent navigation and status bars
        // http://developer.android.com/reference/android/view/Window.html#setNavigationBarColor(int)
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP){
            Window window = getWindow();
            window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS);
            window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION);
            window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS);
            window.getDecorView().setSystemUiVisibility(
                    View.SYSTEM_UI_FLAG_LAYOUT_STABLE |
                    View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN |
                    View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION);
            setTopPadding(drawer, getStatusBarHeight());
            // TODO: The following should only be set if savedinstancestate == null and saved in onsaveinstance
            initialStatusBarColor = window.getStatusBarColor();
            targetStatusBarColor = getResources().getColor(R.color.transparent);
            window.setStatusBarColor(targetStatusBarColor);
            initialNavBarColor = window.getNavigationBarColor();
            targetNavBarColor = getResources().getColor(R.color.transparent);
            window.setNavigationBarColor(targetNavBarColor);
        }

        // Workarounds

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            // Otherwise toolbar shadow stays in same position (looks ugly)
            getWindow().getSharedElementReturnTransition().addListener(new TransitionListenerAdapter() {
                @Override public void onTransitionStart(Transition transition) {
                    toolbarShadow.setImageDrawable(null);
                }
            });

            // Workaround for glitchy toolbar shared return transition
            int statusBarHeight = getStatusBarHeight();
            toolbar.setTranslationY(statusBarHeight);
            toolbarShadow.setTranslationY(statusBarHeight);

            // http://stackoverflow.com/a/26748694/283607
            Transition fade = TransitionInflater.from(this).inflateTransition(android.R.transition.fade);
            fade.excludeTarget(R.id.revealbg, true);
            getWindow().setExitTransition(fade);
            getWindow().setEnterTransition(fade);

            if (savedInstanceState == null) {
                // http://stackoverflow.com/a/26861930/283607
                background.addOnLayoutChangeListener(new View.OnLayoutChangeListener() {
                    @Override public void onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, int oldTop, int oldRight, int oldBottom) {
                        if (Build.VERSION.SDK_INT >= 21) v.removeOnLayoutChangeListener(this);
                        revealBackground();
                    }
                });
            } else {
                background.setVisibility(View.VISIBLE);
            }
        } else {
            background.setVisibility(View.VISIBLE);
        }

        // http://stackoverflow.com/a/26748694/283607
        final ViewTreeObserver observer = getWindow().getDecorView().getViewTreeObserver();
        observer.addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {
            @Override
            public boolean onPreDraw() {
                // java.lang.IllegalStateException: This ViewTreeObserver is not alive, call getViewTreeObserver() again
                ViewTreeObserver observer2 = observer.isAlive() ? observer : getWindow().getDecorView().getViewTreeObserver();
                observer2.removeOnPreDrawListener(this);
                supportStartPostponedEnterTransition();
                return true;
            }
        });
    }

    // https://developer.android.com/training/system-ui/immersive.html
    @TargetApi(Build.VERSION_CODES.KITKAT) @Override
    public void onWindowFocusChanged(boolean hasFocus) {
        super.onWindowFocusChanged(hasFocus);
        if (hasFocus && prefs.immersiveMode()) {
            View decorView = getWindow().getDecorView();
            decorView.setSystemUiVisibility(
                    View.SYSTEM_UI_FLAG_LAYOUT_STABLE
                            | View.SYSTEM_UI_FLAG_FULLSCREEN
                            | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
                            | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
                            | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
                            | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY);
        }
    }

    @Override protected void onSaveInstanceState(Bundle outState) {
        super.onSaveInstanceState(outState);
        outState.putInt(EXTRA_INDEX, currentIndex);
        outState.putParcelable(THREAD, Parcels.wrap(thread));
        // TODO: save photoattacher state (zoom etc.)
    }

    private void setupViewPager() {
        // MediaViews are recycled
        pagerViews = new MediaView[4]; // Didn't manage to make it work with 3
        for (int i = 0; i < pagerViews.length; i++) {
            pagerViews[i] = (MediaView) LayoutInflater.from(this).inflate(R.layout.view_media, null);
            pagerViews[i].ion = ion;
            pagerViews[i].background = background;
            pagerViews[i].releaseIndicator = releaseIndicator;
            pagerViews[i].viewPager = viewPager;
        }

        viewPager.setAdapter(new MediaPagerAdapter());
        viewPager.setCurrentItem(currentIndex);
        viewPager.setOnPageChangeListener(new ViewPager.OnPageChangeListener() {
            @Override public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { }
            @Override public void onPageScrollStateChanged(int state) { }
            @Override public void onPageSelected(int position) {
                currentIndex = position;
                MediaPointer current = thread.mediaPointers.get(currentIndex);
                MediaView currentView = pagerView(currentIndex);
                currentView.prefs = prefs;
                currentView.setTransitionNameForImageView(currentIndex+"");
                currentView.bindTo(boardName, current);
                // Free bitmaps to be collected
                int l = pagerViews.length;
                for (int i = 0; i < l; i++) {
                    if (i != currentIndex % l) {
                        MediaView otherView = pagerView(i);
                        otherView.setTransitionNameForImageView("");
                        MediaPointer last = otherView.lastMediaPointer;
                        if (last != null) {
                            otherView.clear();
                            otherView.bindToPreview(boardName, last);
                        }
                    }
                }
            }
        });

        // TODO: Get crash report here http://crashes.to/s/43716a0559b
        // Either mediaPointers or currentIndex can be null need to maybe think of a smooth handling of this situation
        // TODO: Another crash report: http://crashes.to/s/e00853e900e
        // TODO: Another crash (IndexOutOfBounds): http://crashes.to/s/9c1a4e6ee0c
        final MediaPointer current = thread.mediaPointers.get(currentIndex);
        final MediaView currentView = pagerView(currentIndex);
        currentView.prefs = prefs;
        viewPager.postDelayed(new Runnable() {
            @Override public void run() {
                if (transitionBitmap != null) currentView.bindToPreview(transitionBitmap);
                transitionBitmap = null;
                currentView.setTransitionNameForImageView(currentIndex + "");
                currentView.bindTo(boardName, current);
            }
        }, 10);
    }

    private MediaView pagerView(int i) {
        return pagerViews[i % pagerViews.length];
    }

    private void makeToolbarShadowAppear() {
        // Delay as otherwise there is no shadow under overflow options icon
        toolbar.postDelayed(new Runnable() {
            @Override public void run() {
                updateToolbarShadow();
            }
        }, 50);
    }

    private void makeViewPagerAppear() {
        // Without delay it doesn't work
        imageViewForTransition.postDelayed(new Runnable() {
            @Override public void run() {
                viewPager.setVisibility(View.VISIBLE);
                imageViewForTransition.setVisibility(View.GONE);
            }
        }, 80);
    }

    // Lifecycle ///////////////////////////////////////////////////////////////////////////////////

    @Override public void onResume() {
        super.onResume();
        toolbar.getBackground().setAlpha(0);
    }

    @Override public void onConfigurationChanged(Configuration newConfig) {
        super.onConfigurationChanged(newConfig);
        toolbar.postDelayed(new Runnable() {
            @Override public void run() {
                updateToolbarShadow();
            }
        }, 100);
    }

    @Override public void onDestroy() {
        transitionBitmap = null;
        toolbarShadow.setImageBitmap(null);
        System.gc();
        super.onDestroy();
    }

    @Override public void onBackPressed() {
        if (drawerLayout.isDrawerOpen(drawer)) {
            drawerLayout.closeDrawers();
            return;
        }
        if (Build.VERSION.SDK_INT >= 21) {
            // Such that the image flies back from left/right back into its former position
            // in the previous activity depending on the direction the user swiped to.
            float translationAmount = 1 + Math.abs(currentIndex-initialIndex) * 0.1f;
            int screenOffset = Util.getScreenWidth(this);
            if (initialIndex < currentIndex) {
                imageViewForTransition.setTranslationX(-screenOffset*translationAmount);
                imageViewForTransition.setVisibility(View.VISIBLE);
            } else if (currentIndex < initialIndex) {
                imageViewForTransition.setTranslationX(+screenOffset*translationAmount);
                imageViewForTransition.setVisibility(View.VISIBLE);
            } else {
                MediaView currentView = pagerView(currentIndex);
                currentView.setTransitionNameForImageView(transitionName);
                currentView.removeAttacher(); // Very important, fixes: "java.lang.IllegalStateException: The ImageView's ScaleType has been changed since attaching a PhotoViewAttacher"
            }

            Drawable bg = toolbar.getBackground();
            int alpha = 0;
            if (Build.VERSION.SDK_INT >= 21) alpha = bg.getAlpha();
            animateAlpha(bg, alpha, 255, null);
            animateAlpha(toolbarShadow, 255, 0, new Runnable() {
                @Override public void run() {
                    toolbarShadow.setImageBitmap(null);
                }
            });

            // Workaround for glitchy nav bar shared return transition
            final Window window = getWindow();
            ValueAnimator colorAnimation = ValueAnimator.ofArgb(targetNavBarColor, initialNavBarColor);
            colorAnimation.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
                @TargetApi(Build.VERSION_CODES.LOLLIPOP)
                @Override public void onAnimationUpdate(ValueAnimator animation) {
                    window.setNavigationBarColor((int) animation.getAnimatedValue());
                }
            });
            colorAnimation.setDuration(400);
            colorAnimation.start();
            // It's funny. If I place this statement before colorAnimation.start()
            // or omit it entirely then the transition keeps being glitchy.
            window.setNavigationBarColor(initialNavBarColor);

            // Workaround for glitchy status bar shared return transition
            colorAnimation = ValueAnimator.ofArgb(targetStatusBarColor, initialStatusBarColor);
            colorAnimation.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
                @TargetApi(Build.VERSION_CODES.LOLLIPOP)
                @Override public void onAnimationUpdate(ValueAnimator animation) {
                    window.setStatusBarColor((int) animation.getAnimatedValue());
                }
            });
            colorAnimation.setDuration(400);
            colorAnimation.start();
            window.setStatusBarColor(initialStatusBarColor);

            // Otherwise background is removed too early and the return reveal animation glitches
            getWindow().setTransitionBackgroundFadeDuration(500);
            hideBackground();
        }
        super.onBackPressed();
    }

    private void updateToolbarShadow() {
        if (Build.VERSION.SDK_INT < 11) return;
        try {
            toolbar.setDrawingCacheEnabled(true);
            toolbar.buildDrawingCache();
            Bitmap bm = toolbar.getDrawingCache();
            if (bm == null) {
                toolbar.setDrawingCacheEnabled(false);
                return;
            }
            if (Build.VERSION.SDK_INT < 12) bm = Util.setHasAlphaCompat(bm);
            else bm.setHasAlpha(true);
            toolbarShadow.setImageBitmap(Util.blur(this, bm, 1f));
            toolbarShadow.setColorFilter(Color.argb(255, 0, 0, 0));
            toolbar.setDrawingCacheEnabled(false);
        } catch (RSRuntimeException ignored) {
            // Simply do not draw a shadow for devices that can't handle it
        }
    }

    // Toolbar Menu ////////////////////////////////////////////////////////////////////////////////

    @Override public boolean onCreateOptionsMenu(Menu menu) {
        getMenuInflater().inflate(R.menu.media, menu);
        return true;
    }

    @Override public boolean onOptionsItemSelected(MenuItem item) {
        MediaPointer current = thread.mediaPointers.get(currentIndex);
        String url = ApiModule.mediaUrl(boardName, current.id, current.ext);
        Intent intent;
        String path;
        // https://github.com/codepath/android_guides/wiki/Sharing-Content-with-Intents#sharing-remote-images-without-explicit-file-io
        switch(item.getItemId()) {
            case R.id.view:
                intent = new Intent();
                intent.setAction(Intent.ACTION_VIEW);
                if (current.isWebm()) {
                    Util.startWebmActivity(this, url);
                } else {
                    // TODO: This is not good, download asynchronously
                    Bitmap bitmap;
                    try {
                        // TODO: Write directly into Mediastore! Out of Memory possible
                        bitmap = ion.build(this).load(url).asBitmap().get();
                    } catch (InterruptedException | ExecutionException e) {
                        e.printStackTrace();
                        return true;
                    }
                    if (bitmap == null) return true;
                    path = MediaStore.Images.Media.insertImage(getContentResolver(), bitmap, null, null);
                    if (path == null) return false;
                    intent.putExtra(Intent.EXTRA_STREAM, Uri.parse(path));
                    intent.setType("image/*");
                    startActivity(Intent.createChooser(intent, getResources().getText(R.string.view_image)));
                }
                break;
            case R.id.share:
            case R.id.save:
                imageSaver.saveImage(this, url, current.post.filename, current.ext.substring(1), item.getItemId() == R.id.share);
                break;
            case R.id.gotoPost:
                gotoPost();
                break;
            case R.id.gallery:
                if (fromGallery) {
                    onBackPressed();
                } else {
                    // TODO: Shared element transition
                    // I find it weird that the background drawable of the toolbar is for some
                    // reason shared between activities in Lollipop... (if I don't set it to 255 it
                    // remains translucent in the next activity)
                    toolbar.getBackground().setAlpha(255);
                    GalleryActivity.launch(this, thread);
                    ActivityCompat.finishAfterTransition(this);
                }
                break;
        }
        
        return super.onOptionsItemSelected(item);
    }

    // Animations //////////////////////////////////////////////////////////////////////////////////

    // https://developer.android.com/training/material/animations.html#Reveal
    @TargetApi(Build.VERSION_CODES.LOLLIPOP) private void revealBackground() {
        int cx = revealPoint.x;
        int cy = revealPoint.y;
        int finalRadius = Math.max(background.getWidth(), background.getHeight());
        Animator anim = ViewAnimationUtils.createCircularReveal(
                background, cx, cy, revealRadius, finalRadius
        );
        anim.setDuration(300);
        background.setVisibility(View.VISIBLE);
        anim.start();
    }
    @TargetApi(Build.VERSION_CODES.LOLLIPOP) private void hideBackground() {
        int cx = revealPoint.x;
        int cy = revealPoint.y;
        int initialRadius = Math.max(background.getWidth(), background.getHeight());
        Animator anim = ViewAnimationUtils.createCircularReveal(
                background, cx, cy, initialRadius, revealRadius
        );
        anim.setDuration(300);
        anim.setStartDelay(100);
        anim.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                super.onAnimationEnd(animation);
                background.setVisibility(View.INVISIBLE);
            }
        });
        anim.start();
    }

    // Java's abstraction capabilities are limiting! I cannot reduce code size without a performance hit.
    @TargetApi(Build.VERSION_CODES.LOLLIPOP)
    private void animateAlpha(final Drawable d, int from, int to, final Runnable onFinish) {
        ValueAnimator v = ValueAnimator.ofInt(from, to);
        v.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override public void onAnimationUpdate(ValueAnimator animator) {
                d.setAlpha((int) animator.getAnimatedValue());
            }
        });
        if (onFinish != null) {
            v.addListener(new AnimatorListenerAdapter() {
                @Override public void onAnimationEnd(Animator animation) {
                    onFinish.run();
                }
            });
        }
        v.setDuration(400);
        v.start();
    }
    @TargetApi(Build.VERSION_CODES.LOLLIPOP)
    private void animateAlpha(final View d, int from, int to, final Runnable onFinish) {
        ValueAnimator v = ValueAnimator.ofInt(from, to);
        v.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override public void onAnimationUpdate(ValueAnimator animator) {
                d.setAlpha((int) animator.getAnimatedValue());
            }
        });
        if (onFinish != null) {
            v.addListener(new AnimatorListenerAdapter() {
                @Override public void onAnimationEnd(Animator animation) {
                    onFinish.run();
                }
            });
        }
        v.setDuration(400);
        v.start();
    }

    // Utility /////////////////////////////////////////////////////////////////////////////////////

    public void gotoPost() {
        // TODO: Shared return animation
        MediaPointer current = thread.mediaPointers.get(currentIndex);
        PostsActivity.launchFromMediaActivity(this, thread, thread.indexForPost(current.post));
        ActivityCompat.finishAfterTransition(this);
    }

    private int getStatusBarHeight() {
        int result = 0;
        int resourceId = getResources().getIdentifier("status_bar_height", "dimen", "android");
        if (resourceId > 0) {
            result = getResources().getDimensionPixelSize(resourceId);
        }
        return result;
    }

    private void setTopPadding(View v, int padding) {
        v.setPadding(v.getPaddingLeft(), padding, v.getPaddingRight(), v.getPaddingBottom());
    }

    // TODO: From http://developer.android.com/reference/android/support/v4/view/PagerAdapter.html
    /*
    PagerAdapter supports data set changes. Data set changes must occur on the main thread and must
    end with a call to notifyDataSetChanged() similar to AdapterView adapters derived from
    BaseAdapter. A data set change may involve pages being added, removed, or changing position.
    The ViewPager will keep the current page active provided the adapter implements the method
    getItemPosition(Object).
     */
    // I.e., when post and thus images are updated in the background notifyDataSetChanged should
    // be called on this adapter so that new images are added to the right. Related to manager object.
    private class MediaPagerAdapter extends PagerAdapter {

        @Override public Object instantiateItem(ViewGroup container, int position) {
            MediaView mediaView = pagerView(position);
            mediaView.prefs = prefs;
            MediaPointer current = thread.mediaPointers.get(position);
            mediaView.bindToPreview(boardName, current);
            if (container.indexOfChild(mediaView) != -1) container.removeView(mediaView);
            container.addView(mediaView, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
            return mediaView;
        }

        @Override public void destroyItem(ViewGroup container, int position, Object object) {
            MediaView mediaView = pagerView(position);
            mediaView.clear();
            container.removeView(mediaView);
        }

        @Override public int getCount() {
                if (thread.mediaPointers == null) return 0;
                return thread.mediaPointers.size();
        }
        @Override public boolean isViewFromObject(View view, Object object) { return view == object; }
    }

}
src/main/java/anabolicandroids/chanobol/api/ApiModule.java
@SuppressWarnings("UnusedDeclaration")
@Module(
        complete = false,
        library = true
)
public class ApiModule {
    public static final String endpoint = "http://a.4cdn.org";
    // http://t.4cdn.org/board/1358180697001s.jpg
    public static final String thumbCdn = "http://t.4cdn.org";
    // http://i.4cdn.org/board/1358180697001.ext
    public static final String mediaCdn = "http://i.4cdn.org";

    public static String thumbUrl(String board, String mediaId) {
        return thumbCdn + "/" + board + "/" + mediaId + "s.jpg";
    }

    public static String mediaUrl(String board, String mediaId, String ending) {
        return mediaCdn + "/" + board + "/" + mediaId + ending;
    }

    // http://boards.4chan.org/fit/thread/31627542
    public static String threadUrl(String board, String number) {
        return "http://boards.4chan.org/" + board + "/thread/" + number;
    }

    @Provides @Singleton
    Ion provideIon(Application app) {
        return Ion.getDefault(app);
    }

    @Provides @Singleton
    ChanService provideChanService(@ForApplication Context context, Ion ion) {
        return new ChanService(context, ion);
    }

    // From Clover
    private static final long FILE_CACHE_DISK_SIZE = 50 * 1024 * 1024; // 50mb
    private static final String FILE_CACHE_NAME = "filecache";

    @Provides @Singleton
    FileCache provideFileCache(App app, Ion ion) {
        File cacheDir = app.getExternalCacheDir() != null ? app.getExternalCacheDir() : app.getCacheDir();
        return new FileCache(new File(cacheDir, FILE_CACHE_NAME), FILE_CACHE_DISK_SIZE, ion);
    }
}
src/main/java/anabolicandroids/chanobol/api/data/Board.java
@Parcel
public class Board implements Comparable<Board> {
    @SerializedName("board")
    public String name;
    public String title;

    @Override public String toString() { return name + " - " + title; }
    @Override public int hashCode() { return name != null ? name.hashCode() : 0; }
    @Override public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Board board = (Board) o;
        return !(name != null ? !name.equals(board.name) : board.name != null);
    }

    @Override public int compareTo(@NonNull Board another) {
        return this.name.compareTo(another.name);
    }
}
src/main/java/anabolicandroids/chanobol/ui/posts/WatchlistPostsActivity.java
public class WatchlistPostsActivity extends PostsActivity {

    @Override protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            setTaskDescription(new ActivityManager.TaskDescription(getResources().getString(R.string.watchtask)));
        }
    }

}


Question: package anabolicandroids.chanobol;
import android.app.Application;
import android.content.Context;
import android.content.SharedPreferences;
import android.content.res.AssetManager;
import android.content.res.Resources;
import android.preference.PreferenceManager;
import android.view.LayoutInflater;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.google.gson.reflect.TypeToken;
import com.koushikdutta.ion.Ion;
import java.lang.reflect.Type;
import java.util.List;
import javax.inject.Named;
import javax.inject.Singleton;
import anabolicandroids.chanobol.annotations.ForApplication;
import anabolicandroids.chanobol.annotations.Nsfw;
import anabolicandroids.chanobol.annotations.SfwMode;
import anabolicandroids.chanobol.api.ApiModule;
import anabolicandroids.chanobol.api.data.Board;
import anabolicandroids.chanobol.ui.Settings;
import anabolicandroids.chanobol.ui.boards.BoardsActivity;
import anabolicandroids.chanobol.ui.boards.FavoritesActivity;
import anabolicandroids.chanobol.ui.media.GalleryActivity;
import anabolicandroids.chanobol.ui.media.MediaActivity;
import anabolicandroids.chanobol.ui.posts.PostsActivity;
import anabolicandroids.chanobol.ui.posts.PostsDialog;
import anabolicandroids.chanobol.ui.posts.WatchlistPostsActivity;
import anabolicandroids.chanobol.ui.scaffolding.PersistentData;
import anabolicandroids.chanobol.ui.scaffolding.Prefs;
import anabolicandroids.chanobol.ui.threads.ThreadsActivity;
import anabolicandroids.chanobol.ui.threads.WatchlistThreadsActivity;
import anabolicandroids.chanobol.util.FileCache;
import anabolicandroids.chanobol.util.ImageSaver;
import anabolicandroids.chanobol.util.Util;
import dagger.Module;
import dagger.Provides;






@SuppressWarnings("UnusedDeclaration")
@Module(
        injects = {
                App.class,
                Settings.class,
                BoardsActivity.class,
                FavoritesActivity.class,
                ThreadsActivity.class,
                WatchlistThreadsActivity.class,
                PostsActivity.class,
                WatchlistPostsActivity.class,
                PostsDialog.class,
                MediaActivity.class,
                GalleryActivity.class,
        },
        includes = {
                ApiModule.class
        },
        library = true
)
public class AppModule {
    private App app;

    public AppModule(App app) { this.app = app; }

    @Provides @Singleton
    App provideApp() { return app; }

    @Provides @Singleton
    Application provideApplication() { return app; }

    @Provides @Singleton @ForApplication
    Context provideApplicationContext() { return app; }

    @Provides @Singleton
    LayoutInflater provideLayoutInflater() {
        return (LayoutInflater) app.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
    }

    @Provides @Singleton
    Resources provideResources() { return app.getResources(); }

    @Provides @Singleton
    AssetManager provideAssetManager() { return app.getAssets(); }

    @Provides @Singleton
    SharedPreferences provideSharedPreferences(Application app) {
        return PreferenceManager.getDefaultSharedPreferences(app);
    }

    @Provides @Singleton
Answer:     Prefs providePrefs(SharedPreferences sharedPreferences) {